Merge pull request #80 from balloob/component-logbook

Add component logbook
pull/81/head
Paulus Schoutsen 2015-03-31 09:12:03 -07:00
commit b02c11c31d
14 changed files with 460 additions and 25 deletions

View File

@ -22,7 +22,7 @@ from homeassistant.const import (
SERVICE_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED, SERVICE_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED,
EVENT_CALL_SERVICE, ATTR_NOW, ATTR_DOMAIN, ATTR_SERVICE, MATCH_ALL, EVENT_CALL_SERVICE, ATTR_NOW, ATTR_DOMAIN, ATTR_SERVICE, MATCH_ALL,
EVENT_SERVICE_EXECUTED, ATTR_SERVICE_CALL_ID, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_EXECUTED, ATTR_SERVICE_CALL_ID, EVENT_SERVICE_REGISTERED,
TEMP_CELCIUS, TEMP_FAHRENHEIT) TEMP_CELCIUS, TEMP_FAHRENHEIT, ATTR_FRIENDLY_NAME)
import homeassistant.util as util import homeassistant.util as util
DOMAIN = "homeassistant" DOMAIN = "homeassistant"
@ -325,19 +325,23 @@ class EventOrigin(enum.Enum):
class Event(object): class Event(object):
""" Represents an event within the Bus. """ """ Represents an event within the Bus. """
__slots__ = ['event_type', 'data', 'origin'] __slots__ = ['event_type', 'data', 'origin', 'time_fired']
def __init__(self, event_type, data=None, origin=EventOrigin.local): def __init__(self, event_type, data=None, origin=EventOrigin.local,
time_fired=None):
self.event_type = event_type self.event_type = event_type
self.data = data or {} self.data = data or {}
self.origin = origin self.origin = origin
self.time_fired = util.strip_microseconds(
time_fired or dt.datetime.now())
def as_dict(self): def as_dict(self):
""" Returns a dict representation of this Event. """ """ Returns a dict representation of this Event. """
return { return {
'event_type': self.event_type, 'event_type': self.event_type,
'data': dict(self.data), 'data': dict(self.data),
'origin': str(self.origin) 'origin': str(self.origin),
'time_fired': util.datetime_to_str(self.time_fired),
} }
def __repr__(self): def __repr__(self):
@ -482,6 +486,18 @@ class State(object):
""" Returns domain of this state. """ """ Returns domain of this state. """
return util.split_entity_id(self.entity_id)[0] return util.split_entity_id(self.entity_id)[0]
@property
def object_id(self):
""" Returns object_id of this state. """
return util.split_entity_id(self.entity_id)[1]
@property
def name(self):
""" Name to represent this state. """
return (
self.attributes.get(ATTR_FRIENDLY_NAME) or
self.object_id.replace('_', ' '))
def copy(self): def copy(self):
""" Creates a copy of itself. """ """ Creates a copy of itself. """
return State(self.entity_id, self.state, return State(self.entity_id, self.state,

View File

@ -1,2 +1,2 @@
""" DO NOT MODIFY. Auto-generated by build_frontend script """ """ DO NOT MODIFY. Auto-generated by build_frontend script """
VERSION = "a063d1482fd49e9297d64e1329324f1c" VERSION = "b06d3667e9e461173029ded9c0c9b815"

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,25 @@
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="../resources/moment-js.html">
<polymer-element name="display-time" attributes="dateObj">
<template>
{{ time }}
</template>
<script>
(function() {
var timeFormatOptions = {hour: 'numeric', minute: '2-digit'};
Polymer({
time: "",
dateObjChanged: function(oldVal, newVal) {
if (!newVal) {
this.time = "";
}
this.time = newVal.toLocaleTimeString([], timeFormatOptions);
},
});
})();
</script>
</polymer-element>

View File

@ -0,0 +1,17 @@
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="../components/logbook-entry.html">
<polymer-element name="ha-logbook" attributes="entries" noscript>
<template>
<style>
.logbook {
}
</style>
<div class='logbook'>
<template repeat="{{entries as entry}}">
<logbook-entry entryObj="{{entry}}"></logbook-entry>
</template>
</div>
</template>
</polymer>

View File

@ -0,0 +1,60 @@
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="../bower_components/core-style/core-style.html">
<link rel="import" href="domain-icon.html">
<link rel="import" href="display-time.html">
<link rel="import" href="relative-ha-datetime.html">
<polymer-element name="logbook-entry" attributes="entryObj">
<template>
<core-style ref='ha-main'></core-style>
<style>
.logbook-entry {
line-height: 2em;
}
.time {
width: 55px;
font-size: .8em;
}
.icon {
margin: 0 8px 0 16px;
}
.name {
text-transform: capitalize;
}
.message {
}
</style>
<div horizontal layout class='logbook-entry'>
<display-time dateObj="{{entryObj.when}}" class='time secondary-text-color'></display-time>
<domain-icon domain="{{entryObj.domain}}" class='icon primary-text-color'></domain-icon>
<div class='message primary-text-color' flex>
<template if="{{!entryObj.entityId}}">
<span class='name'>{{entryObj.name}}</span>
</template>
<template if="{{entryObj.entityId}}">
<a href='#' on-click="{{entityClicked}}" class='name'>{{entryObj.name}}</a>
</template>
{{entryObj.message}}
</div>
</div>
</template>
<script>
(function() {
var uiActions = window.hass.uiActions;
Polymer({
entityClicked: function() {
uiActions.showMoreInfoDialog(this.entryObj.entityId);
}
});
})();
</script>
</polymer-element>

@ -1 +1 @@
Subproject commit e048bf6ece91983b9f03aafeb414ae5c535288a2 Subproject commit 282004e3e27134a3de1b9c0e6c264ce811f3e510

View File

@ -10,6 +10,7 @@
<link rel="import" href="../layouts/partial-states.html"> <link rel="import" href="../layouts/partial-states.html">
<link rel="import" href="../layouts/partial-history.html"> <link rel="import" href="../layouts/partial-history.html">
<link rel="import" href="../layouts/partial-logbook.html">
<link rel="import" href="../layouts/partial-dev-fire-event.html"> <link rel="import" href="../layouts/partial-dev-fire-event.html">
<link rel="import" href="../layouts/partial-dev-call-service.html"> <link rel="import" href="../layouts/partial-dev-call-service.html">
<link rel="import" href="../layouts/partial-dev-set-state.html"> <link rel="import" href="../layouts/partial-dev-set-state.html">
@ -96,6 +97,13 @@
</paper-item> </paper-item>
</template> </template>
<template if="{{hasLogbookComponent}}">
<paper-item data-panel="logbook">
<core-icon icon="list"></core-icon>
Logbook
</paper-item>
</template>
<div flex></div> <div flex></div>
<paper-item on-click="{{handleLogOutClick}}"> <paper-item on-click="{{handleLogOutClick}}">
@ -136,6 +144,9 @@
<template if="{{selected == 'history'}}"> <template if="{{selected == 'history'}}">
<partial-history main narrow="{{narrow}}" togglePanel="{{togglePanel}}"></partial-history> <partial-history main narrow="{{narrow}}" togglePanel="{{togglePanel}}"></partial-history>
</template> </template>
<template if="{{selected == 'logbook'}}">
<partial-logbook main narrow="{{narrow}}" togglePanel="{{togglePanel}}"></partial-logbook>
</template>
<template if="{{selected == 'fire-event'}}"> <template if="{{selected == 'fire-event'}}">
<partial-dev-fire-event main narrow="{{narrow}}" togglePanel="{{togglePanel}}"></partial-dev-fire-event> <partial-dev-fire-event main narrow="{{narrow}}" togglePanel="{{togglePanel}}"></partial-dev-fire-event>
</template> </template>
@ -161,6 +172,7 @@ Polymer(Polymer.mixin({
narrow: false, narrow: false,
activeFilters: [], activeFilters: [],
hasHistoryComponent: false, hasHistoryComponent: false,
hasLogbookComponent: false,
isStreaming: false, isStreaming: false,
hasStreamError: false, hasStreamError: false,
@ -185,7 +197,7 @@ Polymer(Polymer.mixin({
componentStoreChanged: function(componentStore) { componentStoreChanged: function(componentStore) {
this.hasHistoryComponent = componentStore.isLoaded('history'); this.hasHistoryComponent = componentStore.isLoaded('history');
this.hasScriptComponent = componentStore.isLoaded('script'); this.hasLogbookComponent = componentStore.isLoaded('logbook');
}, },
streamStoreChanged: function(streamStore) { streamStoreChanged: function(streamStore) {

View File

@ -0,0 +1,56 @@
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="./partial-base.html">
<link rel="import" href="../components/ha-logbook.html">
<polymer-element name="partial-logbook" attributes="narrow togglePanel">
<template>
<style>
.content {
background-color: white;
padding: 8px;
}
</style>
<partial-base narrow="{{narrow}}" togglePanel="{{togglePanel}}">
<span header-title>Logbook</span>
<span header-buttons>
<paper-icon-button icon="refresh"
on-click="{{handleRefreshClick}}"></paper-icon-button>
</span>
<div flex class="{{ {content: true, narrow: narrow, wide: !narrow} | tokenList }}">
<ha-logbook entries="{{entries}}"></ha-logbook>
</div>
</partial-base>
</template>
<script>
var storeListenerMixIn = window.hass.storeListenerMixIn;
var logbookActions = window.hass.logbookActions;
Polymer(Polymer.mixin({
entries: null,
attached: function() {
this.listenToStores(true);
},
detached: function() {
this.stopListeningToStores();
},
logbookStoreChanged: function(logbookStore) {
if (logbookStore.isStale()) {
logbookActions.fetch();
}
this.entries = logbookStore.all.toArray();
},
handleRefreshClick: function() {
logbookActions.fetch();
},
}, storeListenerMixIn));
</script>
</polymer>

View File

@ -51,7 +51,7 @@ window.hass.uiUtil.domainIcon = function(domain, state) {
case "media_player": case "media_player":
var icon = "hardware:cast"; var icon = "hardware:cast";
if (state !== "idle") { if (state && state !== "idle") {
icon += "-connected"; icon += "-connected";
} }

View File

@ -1,5 +1,30 @@
<link rel="import" href="../bower_components/core-style/core-style.html"> <link rel="import" href="../bower_components/core-style/core-style.html">
<core-style id='ha-main'>
/* Palette generated by Material Palette - materialpalette.com/light-blue/orange */
.dark-primary-color { background: #0288D1; }
.default-primary-color { background: #03A9F4; }
.light-primary-color { background: #B3E5FC; }
.text-primary-color { color: #FFFFFF; }
.accent-color { background: #FF9800; }
.primary-text-color { color: #212121; }
.secondary-text-color { color: #727272; }
.divider-color { border-color: #B6B6B6; }
/* extra */
.accent-text-color { color: #FF9800; }
body {
color: #212121;
}
a {
color: #FF9800;
text-decoration: none;
}
</core-style>
<core-style id='ha-animations'> <core-style id='ha-animations'>
@-webkit-keyframes ha-spin { @-webkit-keyframes ha-spin {
0% { 0% {

View File

@ -0,0 +1,183 @@
"""
homeassistant.components.logbook
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Parses events and generates a human log
"""
from datetime import datetime
from itertools import groupby
from homeassistant import State, DOMAIN as HA_DOMAIN
from homeassistant.const import (
EVENT_STATE_CHANGED, STATE_HOME, STATE_ON, STATE_OFF,
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
import homeassistant.util as util
import homeassistant.components.recorder as recorder
import homeassistant.components.sun as sun
DOMAIN = "logbook"
DEPENDENCIES = ['recorder', 'http']
URL_LOGBOOK = '/api/logbook'
QUERY_EVENTS_AFTER = "SELECT * FROM events WHERE time_fired > ?"
QUERY_EVENTS_BETWEEN = """
SELECT * FROM events WHERE time_fired > ? AND time_fired < ?
ORDER BY time_fired
"""
GROUP_BY_MINUTES = 15
def setup(hass, config):
""" Listens for download events to download files. """
hass.http.register_path('GET', URL_LOGBOOK, _handle_get_logbook)
return True
def _handle_get_logbook(handler, path_match, data):
""" Return logbook entries. """
start_today = datetime.now().date()
import time
print(time.mktime(start_today.timetuple()))
handler.write_json(humanify(
recorder.query_events(QUERY_EVENTS_AFTER, (start_today,))))
class Entry(object):
""" A human readable version of the log. """
# pylint: disable=too-many-arguments, too-few-public-methods
def __init__(self, when=None, name=None, message=None, domain=None,
entity_id=None):
self.when = when
self.name = name
self.message = message
self.domain = domain
self.entity_id = entity_id
def as_dict(self):
""" Convert Entry to a dict to be used within JSON. """
return {
'when': util.datetime_to_str(self.when),
'name': self.name,
'message': self.message,
'domain': self.domain,
'entity_id': self.entity_id,
}
def humanify(events):
"""
Generator that converts a list of events into Entry objects.
Will try to group events if possible:
- if 2+ sensor updates in GROUP_BY_MINUTES, show last
- if home assistant stop and start happen in same minute call it restarted
"""
# pylint: disable=too-many-branches
# Group events in batches of GROUP_BY_MINUTES
for _, g_events in groupby(
events,
lambda event: event.time_fired.minute // GROUP_BY_MINUTES):
events_batch = list(g_events)
# Keep track of last sensor states
last_sensor_event = {}
# group HA start/stop events
# Maps minute of event to 1: stop, 2: stop + start
start_stop_events = {}
# Process events
for event in events_batch:
if event.event_type == EVENT_STATE_CHANGED:
entity_id = event.data['entity_id']
if entity_id.startswith('sensor.'):
last_sensor_event[entity_id] = event
elif event.event_type == EVENT_HOMEASSISTANT_STOP:
if event.time_fired.minute in start_stop_events:
continue
start_stop_events[event.time_fired.minute] = 1
elif event.event_type == EVENT_HOMEASSISTANT_START:
if event.time_fired.minute not in start_stop_events:
continue
start_stop_events[event.time_fired.minute] = 2
# Yield entries
for event in events_batch:
if event.event_type == EVENT_STATE_CHANGED:
# Do not report on new entities
if 'old_state' not in event.data:
continue
to_state = State.from_dict(event.data.get('new_state'))
if not to_state:
continue
domain = to_state.domain
# Skip all but the last sensor state
if domain == 'sensor' and \
event != last_sensor_event[to_state.entity_id]:
continue
yield Entry(
event.time_fired,
name=to_state.name,
message=_entry_message_from_state(domain, to_state),
domain=domain,
entity_id=to_state.entity_id)
elif event.event_type == EVENT_HOMEASSISTANT_START:
if start_stop_events.get(event.time_fired.minute) == 2:
continue
yield Entry(
event.time_fired, "Home Assistant", "started",
domain=HA_DOMAIN)
elif event.event_type == EVENT_HOMEASSISTANT_STOP:
if start_stop_events.get(event.time_fired.minute) == 2:
action = "restarted"
else:
action = "stopped"
yield Entry(
event.time_fired, "Home Assistant", action,
domain=HA_DOMAIN)
def _entry_message_from_state(domain, state):
""" Convert a state to a message for the logbook. """
# We pass domain in so we don't have to split entity_id again
if domain == 'device_tracker':
return '{} home'.format(
'arrived' if state.state == STATE_HOME else 'left')
elif domain == 'sun':
if state.state == sun.STATE_ABOVE_HORIZON:
return 'has risen'
else:
return 'has set'
elif state.state == STATE_ON:
# Future: combine groups and its entity entries ?
return "turned on"
elif state.state == STATE_OFF:
return "turned off"
return "changed to {}".format(state.state)

View File

@ -9,7 +9,7 @@ import logging
import threading import threading
import queue import queue
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime, date
import time import time
import json import json
import atexit import atexit
@ -70,9 +70,10 @@ def row_to_state(row):
def row_to_event(row): def row_to_event(row):
""" Convert a databse row to an event. """ """ Convert a databse row to an event. """
try: try:
return Event(row[1], json.loads(row[2]), EventOrigin[row[3].lower()]) return Event(row[1], json.loads(row[2]), EventOrigin[row[3].lower()],
datetime.fromtimestamp(row[5]))
except ValueError: except ValueError:
# When json.oads fails # When json.loads fails
_LOGGER.exception("Error converting row to event: %s", row) _LOGGER.exception("Error converting row to event: %s", row)
return None return None
@ -225,13 +226,13 @@ class Recorder(threading.Thread):
""" Save an event to the database. """ """ Save an event to the database. """
info = ( info = (
event.event_type, json.dumps(event.data, cls=JSONEncoder), event.event_type, json.dumps(event.data, cls=JSONEncoder),
str(event.origin), datetime.now() str(event.origin), datetime.now(), event.time_fired,
) )
self.query( self.query(
"INSERT INTO events (" "INSERT INTO events ("
"event_type, event_data, origin, created" "event_type, event_data, origin, created, time_fired"
") VALUES (?, ?, ?, ?)", info) ") VALUES (?, ?, ?, ?, ?)", info)
def query(self, sql_query, data=None, return_value=None): def query(self, sql_query, data=None, return_value=None):
""" Query the database. """ """ Query the database. """
@ -271,6 +272,7 @@ class Recorder(threading.Thread):
atexit.register(self._close_connection) atexit.register(self._close_connection)
# Have datetime objects be saved as integers # Have datetime objects be saved as integers
sqlite3.register_adapter(date, _adapt_datetime)
sqlite3.register_adapter(datetime, _adapt_datetime) sqlite3.register_adapter(datetime, _adapt_datetime)
# Validate we are on the correct schema or that we have to migrate # Validate we are on the correct schema or that we have to migrate
@ -328,6 +330,16 @@ class Recorder(threading.Thread):
save_migration(1) save_migration(1)
if migration_id < 2:
cur.execute("""
ALTER TABLE events
ADD COLUMN time_fired integer
""")
cur.execute('UPDATE events SET time_fired=created')
save_migration(2)
def _close_connection(self): def _close_connection(self):
""" Close connection to the database. """ """ Close connection to the database. """
_LOGGER.info("Closing database") _LOGGER.info("Closing database")

View File

@ -262,7 +262,7 @@ class JSONEncoder(json.JSONEncoder):
def default(self, obj): def default(self, obj):
""" Converts Home Assistant objects and hands """ Converts Home Assistant objects and hands
other objects to the original method. """ other objects to the original method. """
if isinstance(obj, (ha.State, ha.Event)): if hasattr(obj, 'as_dict'):
return obj.as_dict() return obj.as_dict()
try: try: