core/homeassistant/observers.py

377 lines
14 KiB
Python

"""
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 re
import json
import requests
import homeassistant as ha
STATE_CATEGORY_SUN = "weather.sun"
STATE_ATTRIBUTE_NEXT_SUN_RISING = "next_rising"
STATE_ATTRIBUTE_NEXT_SUN_SETTING = "next_setting"
STATE_CATEGORY_ALL_DEVICES = 'all_devices'
STATE_CATEGORY_DEVICE_FORMAT = '{}'
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
TIME_SPAN_FOR_ERROR_IN_SCANNING = timedelta(minutes=1)
# Return cached results if last scan was less then this time ago
TOMATO_MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
# Filename to save known devices to
KNOWN_DEVICES_FILE = "known_devices.csv"
def track_sun(eventbus, statemachine, latitude, longitude):
""" Tracks the state of the sun. """
logger = logging.getLogger(__name__)
try:
import ephem
except ImportError:
logger.exception(("TrackSun:Error while importing dependency ephem."))
return False
sun = ephem.Sun() # pylint: disable=no-member
def update_sun_state(now): # pylint: disable=unused-argument
""" Method to update the current state of the sun and
set time of next setting and rising. """
observer = ephem.Observer()
observer.lat = latitude
observer.long = longitude
next_rising = ephem.localtime(observer.next_rising(sun))
next_setting = ephem.localtime(observer.next_setting(sun))
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
logger.info("Sun:{}. Next change: {}".
format(new_state, next_change.strftime("%H:%M")))
state_attributes = {
STATE_ATTRIBUTE_NEXT_SUN_RISING: ha.datetime_to_str(next_rising),
STATE_ATTRIBUTE_NEXT_SUN_SETTING: ha.datetime_to_str(next_setting)
}
statemachine.set_state(STATE_CATEGORY_SUN, new_state, state_attributes)
# +10 seconds to be sure that the change has occured
ha.track_time_change(eventbus, update_sun_state,
point_in_time=next_change + timedelta(seconds=10))
update_sun_state(None)
return True
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
self.device_scanner = device_scanner
self.logger = logging.getLogger(__name__)
self.lock = threading.Lock()
# Dictionary to keep track of known devices and devices we track
self.known_devices = {}
# Did we encounter a valid known devices file
self.invalid_known_devices_file = False
# Read known devices if file exists
if os.path.isfile(KNOWN_DEVICES_FILE):
with open(KNOWN_DEVICES_FILE) as inp:
default_last_seen = datetime(1990, 1, 1)
# Temp variable to keep track of which categories we use
# so we can ensure we have unique categories.
used_categories = []
try:
for row in csv.DictReader(inp):
device = row['device']
row['track'] = True if row['track'] == '1' else False
# If we track this device setup tracking variables
if row['track']:
row['last_seen'] = default_last_seen
# Make sure that each device is mapped
# to a unique category name
name = row['name']
if not name:
name = "unnamed_device"
tries = 0
suffix = ""
while True:
tries += 1
if tries > 1:
suffix = "_{}".format(tries)
category = STATE_CATEGORY_DEVICE_FORMAT.format(
name + suffix)
if category not in used_categories:
break
row['category'] = category
used_categories.append(category)
self.known_devices[device] = row
except KeyError:
self.invalid_known_devices_file = False
self.logger.warning(("Invalid {} found. "
"We won't update it with new found devices.").
format(KNOWN_DEVICES_FILE))
if len(self.device_state_categories()) == 0:
self.logger.warning("No devices to track. Please update {}.".
format(KNOWN_DEVICES_FILE))
ha.track_time_change(eventbus,
lambda time: self.update_devices(device_scanner.scan_devices()))
def device_state_categories(self):
""" Returns a list containing all categories
that are maintained for devices. """
return [self.known_devices[device]['category'] for device
in self.known_devices if self.known_devices[device]['track']]
def update_devices(self, found_devices):
""" Update device states based on the found devices. """
self.lock.acquire()
temp_tracking_devices = [device for device in self.known_devices
if self.known_devices[device]['track']]
for device in found_devices:
# Are we tracking this device?
if device in temp_tracking_devices:
temp_tracking_devices.remove(device)
self.known_devices[device]['last_seen'] = datetime.now()
self.statemachine.set_state(
self.known_devices[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.known_devices[device]['last_seen'] >
TIME_SPAN_FOR_ERROR_IN_SCANNING):
self.statemachine.set_state(
self.known_devices[device]['category'],
DEVICE_STATE_NOT_HOME)
# Get the currently used statuses
states_of_devices = [self.statemachine.get_state(category)['state']
for category in self.device_state_categories()]
# Update the all devices category
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)
# If we come along any unknown devices we will write them to the
# known devices file but only if we did not encounter an invalid
# known devices file
if not self.invalid_known_devices_file:
unknown_devices = [device for device in found_devices
if device not in self.known_devices]
if len(unknown_devices) > 0:
try:
# If file does not exist we will write the header too
is_new_file = not os.path.isfile(KNOWN_DEVICES_FILE)
with open(KNOWN_DEVICES_FILE, 'a') as outp:
self.logger.info(("DeviceTracker:Found {} new devices,"
" updating {}").format(len(unknown_devices),
KNOWN_DEVICES_FILE))
writer = csv.writer(outp)
if is_new_file:
writer.writerow(("device", "name", "track"))
for device in unknown_devices:
# See if the device scanner knows the name
temp_name = self.device_scanner.get_device_name(
device)
name = temp_name if temp_name else "unknown_device"
writer.writerow((device, name, 0))
self.known_devices[device] = {'name':name,
'track': False}
except IOError:
self.logger.exception(("DeviceTracker:Error updating {}"
"with {} new devices").format(KNOWN_DEVICES_FILE,
len(unknown_devices)))
self.lock.release()
class TomatoDeviceScanner(object):
""" This class queries a wireless router running Tomato firmware
for connected devices.
A description of the Tomato API can be found on
http://paulusschoutsen.nl/blog/2013/10/tomato-api-documentation/
"""
def __init__(self, host, username, password, http_id):
self.req = requests.Request('POST',
'http://{}/update.cgi'.format(host),
data={'_http_id': http_id,
'exec': 'devlist'},
auth=requests.auth.HTTPBasicAuth(
username, password)).prepare()
self.parse_api_pattern = re.compile(r"(?P<param>\w*) = (?P<value>.*);")
self.logger = logging.getLogger(__name__)
self.lock = threading.Lock()
self.date_updated = None
self.last_results = {"wldev": [], "dhcpd_lease": []}
self.success_init = self._update_tomato_info()
def scan_devices(self):
""" Scans for new devices and return a
list containing found device ids. """
self._update_tomato_info()
return [item[1] for item in self.last_results['wldev']]
def get_device_name(self, device):
""" Returns the name of the given device or None if we don't know. """
# Make sure there are results
if not self.date_updated:
self._update_tomato_info()
filter_named = [item[0] for item in self.last_results['dhcpd_lease']
if item[2] == device]
if len(filter_named) == 0 or filter_named[0] == "":
return None
else:
return filter_named[0]
def _update_tomato_info(self):
""" Ensures the information from the Tomato router is up to date.
Returns boolean if scanning successful. """
self.lock.acquire()
# if date_updated is None or the date is too old we scan for new data
if (not self.date_updated or datetime.now() - self.date_updated >
TOMATO_MIN_TIME_BETWEEN_SCANS):
self.logger.info("Tomato:Scanning")
try:
response = requests.Session().send(self.req, timeout=3)
# Calling and parsing the Tomato api here. We only need the
# wldev and dhcpd_lease values. For API description see:
# http://paulusschoutsen.nl/blog/2013/10/tomato-api-documentation/
if response.status_code == 200:
for param, value in self.parse_api_pattern.findall(
response.text):
if param == 'wldev' or param == 'dhcpd_lease':
self.last_results[param] = json.loads(value.
replace("'",'"'))
self.date_updated = datetime.now()
return True
elif response.status_code == 401:
# Authentication error
self.logger.exception(("Tomato:Failed to authenticate, "
"please check your username and password"))
return False
except requests.exceptions.ConnectionError:
# We get this if we could not connect to the router or
# an invalid http_id was supplied
self.logger.exception(("Tomato:Failed to connect to the router"
" or invalid http_id supplied"))
return False
except requests.exceptions.Timeout:
# We get this if we could not connect to the router or
# an invalid http_id was supplied
self.logger.exception(("Tomato:Connection to the router "
"timed out"))
return False
except ValueError:
# If json decoder could not parse the response
self.logger.exception(("Tomato:Failed to parse response "
"from router"))
return False
finally:
self.lock.release()
else:
# We acquired the lock before the IF check,
# release it before we return True
self.lock.release()
return True