changeset 867:d9bc9a82dcca

redo wifi scraper to work with zyxel router report page too. add last connected time (from mongo) to web table Ignore-this: 18bade72e14d40532bd019791d03fa7d darcs-hash:20130407000819-312f9-6b262edf71421b17947050e80560005d7bffa80b
author drewp <drewp@bigasterisk.com>
date Sat, 06 Apr 2013 17:08:19 -0700
parents a99b4d5afb83
children 31610c14a34c
files service/tomatoWifi/dhcpparse.py service/tomatoWifi/table.mustache service/tomatoWifi/tomatoWifi.py service/tomatoWifi/wifi.py
diffstat 4 files changed, 129 insertions(+), 110 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/service/tomatoWifi/dhcpparse.py	Sat Apr 06 17:08:19 2013 -0700
@@ -0,0 +1,23 @@
+def addDhcpData(rows):
+    """given dicts with 'mac', add other data from the dhcp cache"""
+    currentLease = None
+    for line in open('/var/lib/dhcp/dhcpd.leases'):
+        if line.startswith('lease '):
+            currentLease = {'ip': line.split()[1]}
+        elif line.startswith('  hardware ethernet '):
+            currentLease['mac'] = line.split()[2].strip(';').upper()
+        elif line.startswith('  client-hostname'):
+            currentLease['clientHostname'] = line.split(None, 2)[1].strip('";')
+        elif line.startswith('  binding state'):
+            currentLease['bindingState'] = line.split()[2].strip(';')
+        elif line.startswith('}'):
+            if currentLease.get('bindingState') == 'active':
+                # there seem to be a lot of 'active' blocks
+                # for the same ip addr and mac. I haven't
+                # looked into whether they hold different
+                # lease terms or what
+                for r in rows:
+                    if r['mac'] == currentLease['mac']:
+                        r.update(currentLease)
+                        
+            
--- a/service/tomatoWifi/table.mustache	Sun Feb 10 13:41:35 2013 -0800
+++ b/service/tomatoWifi/table.mustache	Sat Apr 06 17:08:19 2013 -0700
@@ -5,8 +5,8 @@
 
   <table><tr> 
 
-    <th class="name">Name</th> 
-    <th class="lease">Lease</th> 
+    <th class="name">Name</th>
+    <th class="connected">Connected</th>
     <th class="ip">IP address</th> 
     <th class="rssi">dBm</th> 
     <th class="mac">MAC address</th> 
@@ -15,7 +15,7 @@
   {{#rows}}
   <tr class="{{cls}}">
     <td>{{name}}</td>
-    <td class="lease">{{lease}}</td>
+    <td class="connected">{{connectedAgo}}</td>
     <td class="ip">{{ip}}</td>
     <td class="rssi">{{signal}}</td>
     <td class="mac">{{mac}}</td>
@@ -25,4 +25,4 @@
   </table>
 
 
-</div>
\ No newline at end of file
+</div>
--- a/service/tomatoWifi/tomatoWifi.py	Sun Feb 10 13:41:35 2013 -0800
+++ b/service/tomatoWifi/tomatoWifi.py	Sat Apr 06 17:08:19 2013 -0700
@@ -12,18 +12,19 @@
 """
 from __future__ import division
 import sys, cyclone.web, json, traceback, time, pystache, datetime, logging
+import web.utils
 from cyclone.httpclient import fetch
 sys.path.append("/home/drewp/projects/photo/lib/python2.7/site-packages")
 from dateutil import tz
 from twisted.internet import reactor, task
-from twisted.internet.defer import inlineCallbacks, returnValue
-
+from twisted.internet.defer import inlineCallbacks
 
 from pymongo import Connection, DESCENDING
 from rdflib import Namespace, Literal, URIRef
 sys.path.append("/my/site/magma")
 from stategraph import StateGraph
 from wifi import Wifi
+from dhcpparse import addDhcpData
 
 sys.path.append("/my/proj/homeauto/lib")
 from cycloneerr import PrettyErrorHandler
@@ -53,21 +54,44 @@
 
 class Table(PrettyErrorHandler, cyclone.web.RequestHandler):
     def get(self):
-        def rowDict(addr):
-            addr['cls'] = "signal" if addr.get('signal') else "nosignal"
-            if 'lease' in addr:
-                addr['lease'] = addr['lease'].replace("0 days, ", "")
-            return addr
+        def rowDict(row):
+            row['cls'] = "signal" if row.get('connected') else "nosignal"
+            if 'name' not in row:
+                row['name'] = row['clientHostname']
+            if 'signal' not in row:
+                row['signal'] = 'yes' if row['connected'] else 'no'
+
+            try:
+                conn = self.whenConnected(row['mac'])
+                row['connectedAgo'] = web.utils.datestr(
+                    conn.astimezone(tz.tzutc()).replace(tzinfo=None))
+            except ValueError:
+                pass
+            
+            return row
 
         self.set_header("Content-Type", "application/xhtml+xml")
         self.write(pystache.render(
             open("table.mustache").read(),
             dict(
                 rows=sorted(map(rowDict, self.settings.poller.lastAddrs),
-                            key=lambda a: (a.get('router'),
-                                           a.get('name'),
-                                           a.get('mac'))))))
+                            key=lambda a: (not a.get('connected'),
+                                           a.get('name'))))))
 
+    def whenConnected(self, macThatIsNowConnected):
+        lastArrive = None
+        for ev in self.settings.mongo.find({'address': macThatIsNowConnected},
+                                           sort=[('created', -1)],
+                                           max_scan=100000):
+            if ev['action'] == 'arrive':
+                lastArrive = ev
+            if ev['action'] == 'leave':
+                break
+        if lastArrive is None:
+            raise ValueError("no past arrivals")
+        
+        return lastArrive['created']
+        
 
 class Json(PrettyErrorHandler, cyclone.web.RequestHandler):
     def get(self):
@@ -91,36 +115,22 @@
             raise ValueError("poll data is stale. age=%s" % age)
 
         for dev in self.settings.poller.lastAddrs:
-            if not dev.get('signal'):
+            if not dev.get('connected'):
                 continue
             uri = URIRef("http://bigasterisk.com/wifiDevice/%s" % dev['mac'])
             g.add((uri, ROOM['macAddress'], Literal(dev['mac'])))
+            
             g.add((uri, ROOM['connected'], aps))
-            if 'rawName' in dev:
-                g.add((uri, ROOM['wifiNetworkName'], Literal(dev['rawName'])))
-            g.add((uri, ROOM['deviceName'], Literal(dev['name'])))
-            g.add((uri, ROOM['signalStrength'], Literal(dev['signal'])))
+            if 'clientHostname' in dev:
+                g.add((uri, ROOM['wifiNetworkName'], Literal(dev['clientHostname'])))
+            if 'name' in dev:
+                g.add((uri, ROOM['deviceName'], Literal(dev['name'])))
+            if 'signal' in dev:
+                g.add((uri, ROOM['signalStrength'], Literal(dev['signal'])))
 
         self.set_header('Content-type', 'application/x-trig')
         self.write(g.asTrig())
 
-class Application(cyclone.web.Application):
-    def __init__(self, wifi, poller):
-        handlers = [
-            (r"/", Index),
-            (r'/json', Json),
-            (r'/graph', GraphHandler),
-            (r'/table', Table),
-            #(r'/activity', Activity),
-        ]
-        settings = {
-            'wifi' : wifi,
-            'poller' : poller,
-            'mongo' : Connection('bang', 27017,
-                                 tz_aware=True)['house']['sensor']
-            }
-        cyclone.web.Application.__init__(self, handlers, **settings)
-
 class Poller(object):
     def __init__(self, wifi, mongo):
         self.wifi = wifi
@@ -137,8 +147,9 @@
     def poll(self):
         try:
             newAddrs = yield self.wifi.getPresentMacAddrs()
-
-            newWithSignal = [a for a in newAddrs if a.get('signal')]
+            addDhcpData(newAddrs)
+            
+            newWithSignal = [a for a in newAddrs if a.get('connected')]
 
             actions = self.computeActions(newWithSignal)
             for action in actions:
@@ -161,33 +172,27 @@
             log.error("poll error: %s\n%s", e, traceback.format_exc())
 
     def computeActions(self, newWithSignal):
-        def removeVolatile(a):
-            ret = dict((k,v) for k,v in a.items() if k in ['name', 'mac'])
-            ret['signal'] = bool(a.get('signal'))
-            return ret
-
-        def find(a, others):
-            a = removeVolatile(a)
-            return any(a == removeVolatile(o) for o in others)
-
         actions = []
 
         def makeAction(addr, act):
-            return dict(sensor="wifi",
+            d = dict(sensor="wifi",
                         address=addr.get('mac'),
                         name=addr.get('name'),
-                        networkName=addr.get('rawName'),
+                        networkName=addr.get('clientHostname'),
                         action=act)
+            if act == 'arrive' and 'ip' in addr:
+                # this won't cover the possible case that you get on
+                # wifi but don't have an ip yet. We'll record an
+                # action with no ip and then never record your ip.
+                d['ip'] = addr['ip']
+            return d                             
 
         for addr in newWithSignal:
-            if not find(addr, self.lastWithSignal):
-                # the point of all the removeVolatile stuff is so
-                # I have the complete addr object here, although
-                # it is currently mostly thrown out by makeAction
+            if addr['mac'] not in [r['mac'] for r in self.lastWithSignal]:
                 actions.append(makeAction(addr, 'arrive'))
 
         for addr in self.lastWithSignal:
-            if not find(addr, newWithSignal):
+            if addr['mac'] not in [r['mac'] for r in newWithSignal]:
                 actions.append(makeAction(addr, 'leave'))
 
         return actions
@@ -227,13 +232,24 @@
     from twisted.python import log as twlog
     #log.startLogging(sys.stdout)
     #log.setLevel(10)
-    log.setLevel(logging.DEBUG)
+    #log.setLevel(logging.DEBUG)
 
-    mongo = Connection('bang', 27017)['visitor']['visitor']
+    mongo = Connection('bang', 27017, tz_aware=True)['visitor']['visitor']
 
     wifi = Wifi()
     poller = Poller(wifi, mongo)
     task.LoopingCall(poller.poll).start(1/config['pollFrequency'])
 
-    reactor.listenTCP(config['servePort'], Application(wifi, poller))
+    reactor.listenTCP(config['servePort'],
+                      cyclone.web.Application(
+                          [
+                              (r"/", Index),
+                              (r'/json', Json),
+                              (r'/graph', GraphHandler),
+                              (r'/table', Table),
+                              #(r'/activity', Activity),
+                          ],
+                          wifi=wifi,
+                          poller=poller,
+                          mongo=mongo))
     reactor.run()
--- a/service/tomatoWifi/wifi.py	Sun Feb 10 13:41:35 2013 -0800
+++ b/service/tomatoWifi/wifi.py	Sat Apr 06 17:08:19 2013 -0700
@@ -1,4 +1,5 @@
 import re, ast, logging, socket
+import lxml.html.soupparser
 from twisted.internet.defer import inlineCallbacks, returnValue
 from cyclone.httpclient import fetch
 from rdflib import Literal, Graph
@@ -12,6 +13,8 @@
 class Wifi(object):
     """
     gather the users of wifi from the tomato routers
+
+    with host names from /var/lib/dhcp/dhcpd.leases
     """
     def __init__(self, tomatoConfig="/my/site/magma/tomato_config.js",
                  accessN3="/my/proj/openid_proxy/access.n3"):
@@ -48,15 +51,14 @@
 	    userPass, tail = tail.split("@", 1)
             r.url = http + '//' + tail
             r.headers = {'Authorization': ['Basic %s' % userPass.encode('base64').strip()]}
-            r.name = {'tomato1' : 'bigasterisk3',
+            r.name = {'tomato1' : 'bigasterisk5',
                       'tomato2' : 'bigasterisk4'}[name.split('/')[1]]
             self.routers.append(r)
 
     @inlineCallbacks
     def getPresentMacAddrs(self):
-        aboutIp = {}
-        byMac = {} # mac : [ip]
-
+        rows = []
+        
         for router in self.routers:
             log.debug("GET %s", router)
             try:
@@ -66,57 +68,35 @@
                 log.warn("get on %s failed" % router)
                 continue
             data = resp.body
-
-            for (ip, mac, iface) in jsValue(data, 'arplist'):
-                aboutIp.setdefault(ip, {}).update(dict(
-                    ip=ip,
-                    router=router.name,
-                    mac=mac,
-                    iface=iface,
-                    ))
-
-                byMac.setdefault(mac, set()).add(ip)
-
-            for (name, ip, mac, lease) in jsValue(data, 'dhcpd_lease'):
-                if lease.startswith('0 days, '):
-                    lease = lease[len('0 days, '):]
-                aboutIp.setdefault(ip, {}).update(dict(
-                    router=router.name,
-                    rawName=name,
-                    mac=mac,
-                    lease=lease
-                    ))
-
-                byMac.setdefault(mac, set()).add(ip)
+            if 'Wireless -- Authenticated Stations' in data:
+                # zyxel 'Station Info' page
+                rows.extend(self.parseZyxel(data, router.name))
+            else:
+                # tomato page
+                rows.extend(self.parseTomato(data, router.name))
 
-            for iface, mac, signal in jsValue(data, 'wldev'):
-                matched = False
-                for addr in aboutIp.values():
-                    if (addr['router'], addr['mac']) == (router.name, mac):
-                        addr.update(dict(signal=signal, iface=iface))
-                        matched = True
-                if not matched:
-                    aboutIp["mac-%s-%s" % (router, mac)] = dict(
-                        router=router.name,
-                        mac=mac,
-                        signal=signal,
-                        )
+        for r in rows:
+            try:
+                r['name'] = self.knownMacAddr[r['mac']]
+            except KeyError:
+                pass
+                
+        returnValue(rows)
+        
+    def parseZyxel(self, data, routerName):
+        root = lxml.html.soupparser.fromstring(data)
+        for tr in root.cssselect('tr'):
+            mac, assoc, uth, ssid, iface = [td.text_content().strip() for td in tr.getchildren()]
+            if mac == "MAC":
+                continue
+            assoc = assoc.lower() == 'yes'
+            yield dict(router=routerName, mac=mac, assoc=assoc, connected=assoc)
 
-        ret = []
-        for addr in aboutIp.values():
-            if addr.get('ip') in ['192.168.1.1', '192.168.1.2', '192.168.0.2']:
-                continue
-            try:
-                addr['name'] = self.knownMacAddr[addr['mac']]
-            except KeyError:
-                addr['name'] = addr.get('rawName')
-                if addr['name'] in [None, '*']:
-                    addr['name'] = 'unknown'
-            ret.append(addr)
-
-        returnValue(ret)
-
-
+    def parseTomato(self, data, routerName):
+        for iface, mac, signal in jsValue(data, 'wldev'):
+            yield dict(router=routerName, mac=mac, signal=signal, connected=bool(signal))
+        
+            
 def jsValue(js, variableName):
     # using literal_eval instead of json parser to handle the trailing commas
     val = re.search(variableName + r'\s*=\s*(.*?);', js, re.DOTALL).group(1)