text: """
  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()))
  
      @property
      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
  