diff --git a/.gitignore b/.gitignore index 729cae85f8a..6a77c4ee913 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ home-assistant.conf -tomato_known_devices.csv +known_devices.csv # Hide sublime text stuff *.sublime-project diff --git a/README.md b/README.md index 26909dcf39e..df3139ee4e2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Installation instructions * Copy home-assistant.conf.default to home-assistant.conf and adjust the config values to match your setup. * For Tomato you will have to not only setup your host, username and password but also a http_id. The http_id can be retrieved by going to the admin console of your router, view the source of any of the pages and search for `http_id`. * Setup PHue by running `python -m phue --host HUE_BRIDGE_IP_ADDRESS` from the commandline. -* The first time the script will start it will create a file called `tomato_known_devices.csv` which will contain the detected devices. Adjust the track variable for the devices you want the script to act on and restart the script. +* The first time the script will start it will create a file called `known_devices.csv` which will contain the detected devices. Adjust the track variable for the devices you want the script to act on and restart the script. Done. Start it now by running `python start.py` diff --git a/homeassistant/observers.py b/homeassistant/observers.py index 533ce471cdc..fb838bfd28d 100644 --- a/homeassistant/observers.py +++ b/homeassistant/observers.py @@ -34,9 +34,13 @@ 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) +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) -TOMATO_KNOWN_DEVICES_FILE = "tomato_known_devices.csv" + +# Filename to save known devices to +KNOWN_DEVICES_FILE = "known_devices.csv" def track_sun(eventbus, statemachine, latitude, longitude): @@ -80,158 +84,184 @@ class DeviceTracker(object): def __init__(self, eventbus, statemachine, device_scanner): self.statemachine = statemachine self.eventbus = eventbus + self.device_scanner = device_scanner + self.logger = logging.getLogger(__name__) - temp_devices_to_track = device_scanner.get_devices_to_track() + self.lock = threading.Lock() - self.devices_to_track = { device: { 'name': temp_devices_to_track[device], - 'category': STATE_CATEGORY_DEVICE_FORMAT.format(temp_devices_to_track[device]) } - for device in temp_devices_to_track } + self.known_devices = {} - # Add categories to state machine and update last_seen attribute - initial_search = device_scanner.get_active_devices() + # 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) - 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 = [] - for device in self.devices_to_track: - if device in initial_search: - new_state = DEVICE_STATE_HOME - new_last_seen = datetime.now() - else: - new_state = DEVICE_STATE_NOT_HOME - new_last_seen = default_last_seen + for row in csv.DictReader(inp): + device = row['device'] - self.devices_to_track[device]['last_seen'] = new_last_seen - self.statemachine.set_state(self.devices_to_track[device]['category'], new_state) + row['track'] = True if row['track'] == '1' else False - # Update all devices state - statemachine.set_state(STATE_CATEGORY_ALL_DEVICES, DEVICE_STATE_HOME if len(initial_search) > 0 else DEVICE_STATE_NOT_HOME) + self.known_devices[device] = row - track_time_change(eventbus, lambda time: self.update_devices(device_scanner.get_active_devices())) + # If we track this device setup tracking variables + if row['track']: + self.known_devices[device]['last_seen'] = default_last_seen + # Make sure that each device is mapped to a unique category name + name = row['name'] if row['name'] else "unnamed_device" + + tries = 0 + + while True: + tries += 1 + + category = STATE_CATEGORY_DEVICE_FORMAT.format(name if tries == 1 else "{}_{}".format(name, tries)) + + if category not in used_categories: + break + + self.known_devices[device]['category'] = category + used_categories.append(category) + + + if len(self.device_state_categories()) == 0: + self.logger.warning("No devices to track. Please update {}.".format(KNOWN_DEVICES_FILE)) + + + track_time_change(eventbus, lambda time: self.update_devices(device_scanner.scan_devices())) def device_state_categories(self): - """ Returns a list of categories of devices that are being tracked by this class. """ - return [self.devices_to_track[device]['category'] for device in self.devices_to_track] - + """ 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): """ Keep track of devices that are home, all that are not will be marked not home. """ + self.lock.acquire() - temp_tracking_devices = self.devices_to_track.keys() + 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.devices_to_track[device]['last_seen'] = datetime.now() - self.statemachine.set_state(self.devices_to_track[device]['category'], DEVICE_STATE_HOME) + 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.devices_to_track[device]['last_seen'] > TOMATO_TIME_SPAN_FOR_ERROR_IN_SCANNING: - self.statemachine.set_state(self.devices_to_track[device]['category'], DEVICE_STATE_NOT_HOME) + 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(self.devices_to_track[device]['category']).state for device in self.devices_to_track] + 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 + 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 + should_write_header = 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 should_write_header: + 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 tracks devices connected to a wireless router running Tomato firmware. """ + """ 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.host = host - self.username = username - self.password = password - self.http_id = 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.logger = logging.getLogger(__name__) self.lock = threading.Lock() self.date_updated = None - self.last_results = None + self.last_results = {"wldev": [], "dhcpd_lease": []} - # Read known devices if file exists - if os.path.isfile(TOMATO_KNOWN_DEVICES_FILE): - with open(TOMATO_KNOWN_DEVICES_FILE) as inp: - self.known_devices = { row['mac']: row for row in csv.DictReader(inp) } + def scan_devices(self): + """ Scans for new devices and returns a list containing found device ids. """ - # Create a dict with ID: NAME of the devices to track - self.devices_to_track = {mac: info['name'] for mac, info in self.known_devices.items() if info['track'] == '1'} - - if len(self.devices_to_track) == 0: - self.logger.warning("No devices to track. Please update {}.".format(TOMATO_KNOWN_DEVICES_FILE)) - - def get_devices_to_track(self): - """ Returns a ``dict`` with device_id: device_name values. """ - return self.devices_to_track - - def get_active_devices(self): - """ Scans for new devices and returns a list containing 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] + + return None if len(filter_named) == 0 or filter_named[0] == "" else 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 not defined (update has never ran) or the date is too old we scan for new data - if self.date_updated is None or datetime.now() - self.date_updated > TOMATO_MIN_TIME_BETWEEN_SCANS: + # 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: - req = requests.post('http://{}/update.cgi'.format(self.host), - data={'_http_id':self.http_id, 'exec':'devlist'}, - auth=requests.auth.HTTPBasicAuth(self.username, self.password)) + response = requests.Session().send(self.req) # Calling and parsing the Tomato api here. We only need the wldev and dhcpd_lease values. # See http://paulusschoutsen.nl/blog/2013/10/tomato-api-documentation/ for what's going on here. self.last_results = {param: json.loads(value.replace("'",'"')) - for param, value in re.findall(r"(?P\w*) = (?P.*);", req.text) + for param, value in re.findall(r"(?P\w*) = (?P.*);", response.text) if param in ["wldev","dhcpd_lease"]} self.date_updated = datetime.now() - # If we come along any unknown devices we will write them to the known devices file - unknown_devices = [(name, mac) for name, _, mac, _ in self.last_results['dhcpd_lease'] if mac not in self.known_devices] - - if len(unknown_devices) > 0: - self.logger.info("Tomato:Found {} new devices, updating {}".format(len(unknown_devices), TOMATO_KNOWN_DEVICES_FILE)) - - with open(TOMATO_KNOWN_DEVICES_FILE, 'a') as outp: - writer = csv.writer(outp) - - for name, mac in unknown_devices: - writer.writerow((mac, name, 0)) - self.known_devices[mac] = {'name':name, 'track': '0'} except requests.ConnectionError: # If we could not connect to the router self.logger.exception("Tomato:Failed to connect to the router") - return False - except ValueError: # If json decoder could not parse the response self.logger.exception("Tomato:Failed to parse response from router") - return False - - except IOError: - # If scanning was successful but we failed to be able to write to the known devices file - self.logger.exception("Tomato:Updating {} failed".format(TOMATO_KNOWN_DEVICES_FILE)) - - return True - finally: self.lock.release() @@ -239,5 +269,3 @@ class TomatoDeviceScanner(object): # We acquired the lock before the IF check, release it before we return True self.lock.release() - - return True