1224
|
1 #!/usr/bin/python
|
|
2 """
|
|
3 scrape the tomato router status pages to see who's connected to the
|
|
4 wifi access points. Includes leases that aren't currently connected.
|
|
5
|
|
6 Returns:
|
|
7 json listing (for magma page)
|
|
8 rdf graph (for reasoning)
|
|
9 activity stream, when we start saving history
|
|
10
|
|
11 Todo: this should be the one polling and writing to mongo, not entrancemusic
|
|
12
|
|
13 """
|
|
14 from __future__ import division
|
|
15 import sys, cyclone.web, json, traceback, time, pystache, datetime, logging
|
1223
|
16 from cyclone.httpclient import fetch
|
|
17
|
1224
|
18 from dateutil import tz
|
|
19 from twisted.internet import reactor, task
|
|
20 from twisted.internet.defer import inlineCallbacks
|
|
21 import docopt
|
|
22 from influxdb import InfluxDBClient
|
|
23 from pymongo import MongoClient as Connection, DESCENDING
|
|
24 from rdflib import Namespace, Literal, URIRef, ConjunctiveGraph
|
|
25
|
|
26 from scrape import Wifi
|
|
27
|
|
28 from patchablegraph import PatchableGraph, CycloneGraphEventsHandler, CycloneGraphHandler
|
|
29
|
|
30 from cycloneerr import PrettyErrorHandler
|
|
31 from logsetup import log
|
1223
|
32
|
1224
|
33
|
|
34 DEV = Namespace("http://projects.bigasterisk.com/device/")
|
|
35 ROOM = Namespace("http://projects.bigasterisk.com/room/")
|
|
36 reasoning = "http://bang:9071/"
|
|
37
|
|
38 class Index(PrettyErrorHandler, cyclone.web.RequestHandler):
|
|
39 def get(self):
|
|
40
|
|
41 age = time.time() - self.settings.poller.lastPollTime
|
|
42 if age > 10:
|
|
43 raise ValueError("poll data is stale. age=%s" % age)
|
|
44
|
|
45 self.set_header("Content-Type", "text/html")
|
|
46 self.write(open("index.html").read())
|
1223
|
47
|
1224
|
48 def whenConnected(mongo, macThatIsNowConnected):
|
|
49 lastArrive = None
|
|
50 for ev in mongo.find({'address': macThatIsNowConnected.upper()},
|
|
51 sort=[('created', -1)],
|
|
52 max_scan=100000):
|
|
53 if ev['action'] == 'arrive':
|
|
54 lastArrive = ev
|
|
55 if ev['action'] == 'leave':
|
|
56 break
|
|
57 if lastArrive is None:
|
|
58 raise ValueError("no past arrivals")
|
|
59
|
|
60 return lastArrive['created']
|
|
61
|
|
62 def connectedAgoString(conn):
|
|
63 return web.utils.datestr(
|
|
64 conn.astimezone(tz.tzutc()).replace(tzinfo=None))
|
|
65
|
|
66 class Table(PrettyErrorHandler, cyclone.web.RequestHandler):
|
|
67 def get(self):
|
|
68 def rowDict(row):
|
|
69 row['cls'] = "signal" if row.get('connected') else "nosignal"
|
|
70 if 'name' not in row:
|
|
71 row['name'] = row.get('clientHostname', '-')
|
|
72 if 'signal' not in row:
|
|
73 row['signal'] = 'yes' if row.get('connected') else 'no'
|
1223
|
74
|
1224
|
75 try:
|
|
76 conn = whenConnected(self.settings.mongo, row.get('mac', '??'))
|
|
77 row['connectedAgo'] = connectedAgoString(conn)
|
|
78 except ValueError:
|
|
79 row['connectedAgo'] = 'yes' if row.get('connected') else ''
|
|
80 row['router'] = row.get('ssid', '')
|
|
81 return row
|
|
82
|
|
83 self.set_header("Content-Type", "application/xhtml+xml")
|
|
84 self.write(pystache.render(
|
|
85 open("table.mustache").read(),
|
|
86 dict(
|
|
87 rows=sorted(map(rowDict, self.settings.poller.lastAddrs),
|
|
88 key=lambda a: (not a.get('connected'),
|
|
89 a.get('name'))))))
|
|
90
|
1223
|
91
|
1224
|
92 class Json(PrettyErrorHandler, cyclone.web.RequestHandler):
|
|
93 def get(self):
|
|
94 self.set_header("Content-Type", "application/json")
|
|
95 age = time.time() - self.settings.poller.lastPollTime
|
|
96 if age > 10:
|
|
97 raise ValueError("poll data is stale. age=%s" % age)
|
|
98 self.write(json.dumps({"wifi" : self.settings.poller.lastAddrs,
|
|
99 "dataAge" : age}))
|
|
100
|
|
101 class Poller(object):
|
|
102 def __init__(self, wifi, mongo):
|
|
103 self.wifi = wifi
|
|
104 self.mongo = mongo
|
|
105 self.lastAddrs = []
|
|
106 self.lastWithSignal = []
|
|
107 self.lastPollTime = 0
|
|
108
|
|
109 def assertCurrent(self):
|
|
110 dt = time.time() - self.lastPollTime
|
|
111 assert dt < 10, "last poll was %s sec ago" % dt
|
1223
|
112
|
|
113 @inlineCallbacks
|
1224
|
114 def poll(self):
|
|
115 now = int(time.time())
|
|
116
|
|
117 # UVA mode:
|
|
118 addDhcpData = lambda *args: None
|
1223
|
119
|
1224
|
120 try:
|
|
121 newAddrs = yield self.wifi.getPresentMacAddrs()
|
|
122 addDhcpData(newAddrs)
|
|
123
|
|
124 newWithSignal = [a for a in newAddrs if a.get('connected')]
|
|
125
|
|
126 actions = self.computeActions(newWithSignal)
|
|
127 points = []
|
|
128 for action in actions:
|
|
129 log.info("action: %s", action)
|
|
130 action['created'] = datetime.datetime.now(tz.gettz('UTC'))
|
|
131 mongo.save(action)
|
|
132 points.append(
|
|
133 self.influxPoint(now, action['address'].lower(),
|
|
134 1 if action['action'] == 'arrive' else 0))
|
|
135 try:
|
|
136 self.doEntranceMusic(action)
|
|
137 except Exception as e:
|
|
138 log.error("entrancemusic error: %r", e)
|
|
139
|
|
140 if now // 3600 > self.lastPollTime // 3600:
|
|
141 log.info('hourly writes')
|
|
142 for addr in newWithSignal:
|
|
143 points.append(self.influxPoint(now, addr['mac'].lower(), 1))
|
|
144
|
|
145 influx.write_points(points, time_precision='s')
|
|
146 self.lastWithSignal = newWithSignal
|
|
147 if actions: # this doesn't currently include signal strength changes
|
|
148 fetch(reasoning + "immediateUpdate",
|
|
149 method='PUT',
|
|
150 timeout=2,
|
|
151 headers={'user-agent': ['tomatoWifi']}).addErrback(log.warn)
|
|
152 self.lastAddrs = newAddrs
|
|
153 self.lastPollTime = now
|
1223
|
154
|
1224
|
155 self.updateGraph(masterGraph)
|
|
156 except Exception as e:
|
|
157 log.error("poll error: %r\n%s", e, traceback.format_exc())
|
|
158
|
|
159 def influxPoint(self, now, address, value):
|
|
160 return {
|
|
161 'measurement': 'presence',
|
|
162 'tags': {'sensor': 'wifi', 'address': address,},
|
|
163 'fields': {'value': value},
|
|
164 'time': now,
|
|
165 }
|
1223
|
166
|
1224
|
167 def computeActions(self, newWithSignal):
|
|
168 actions = []
|
1223
|
169
|
1224
|
170 def makeAction(addr, act):
|
|
171 d = dict(sensor="wifi",
|
|
172 address=addr.get('mac').upper(), # mongo data is legacy uppercase
|
|
173 name=addr.get('name'),
|
|
174 networkName=addr.get('clientHostname'),
|
|
175 action=act)
|
|
176 if act == 'arrive' and 'ip' in addr:
|
|
177 # this won't cover the possible case that you get on
|
|
178 # wifi but don't have an ip yet. We'll record an
|
|
179 # action with no ip and then never record your ip.
|
|
180 d['ip'] = addr['ip']
|
|
181 return d
|
|
182
|
|
183 for addr in newWithSignal:
|
|
184 if addr['mac'] not in [r['mac'] for r in self.lastWithSignal]:
|
|
185 actions.append(makeAction(addr, 'arrive'))
|
|
186
|
|
187 for addr in self.lastWithSignal:
|
|
188 if addr['mac'] not in [r['mac'] for r in newWithSignal]:
|
|
189 actions.append(makeAction(addr, 'leave'))
|
|
190
|
|
191 return actions
|
1223
|
192
|
|
193
|
1224
|
194 # these need to move out to their own service
|
|
195 def doEntranceMusic(self, action):
|
|
196 import restkit, json
|
|
197 dt = self.deltaSinceLastArrive(action['name'])
|
|
198 log.debug("dt=%s", dt)
|
|
199 if dt > datetime.timedelta(hours=1):
|
|
200 hub = restkit.Resource(
|
|
201 # PSHB not working yet; "http://bang:9030/"
|
|
202 "http://slash:9049/"
|
|
203 )
|
|
204 action = action.copy()
|
|
205 del action['created']
|
|
206 del action['_id']
|
|
207 log.info("post to %s", hub)
|
|
208 hub.post("visitorNet", payload=json.dumps(action))
|
|
209
|
|
210 def deltaSinceLastArrive(self, name):
|
|
211 results = list(self.mongo.find({'name' : name}).sort('created',
|
|
212 DESCENDING).limit(1))
|
|
213 if not results:
|
|
214 return datetime.timedelta.max
|
|
215 now = datetime.datetime.now(tz.gettz('UTC'))
|
|
216 last = results[0]['created'].replace(tzinfo=tz.gettz('UTC'))
|
|
217 return now - last
|
|
218
|
|
219 def updateGraph(self, masterGraph):
|
|
220
|
|
221 g = ConjunctiveGraph()
|
|
222 ctx = DEV['wifi']
|
|
223
|
|
224 # someday i may also record specific AP and their strength,
|
|
225 # for positioning. But many users just want to know that the
|
|
226 # device is connected to some bigasterisk AP.
|
|
227 age = time.time() - self.lastPollTime
|
|
228 if age > 10:
|
|
229 raise ValueError("poll data is stale. age=%s" % age)
|
|
230
|
|
231 for dev in self.lastAddrs:
|
|
232 if not dev.get('connected'):
|
|
233 continue
|
|
234 uri = URIRef("http://bigasterisk.com/mac/%s" % dev['mac'].lower())
|
|
235 g.add((uri, ROOM['macAddress'], Literal(dev['mac'].lower()), ctx))
|
1223
|
236
|
1224
|
237 g.add((uri, ROOM['connected'], {
|
|
238 'wireless': URIRef("http://bigasterisk.com/wifiAccessPoints"),
|
|
239 '2.4G': URIRef("http://bigasterisk.com/wifiAccessPoints"),
|
|
240 '5G': URIRef("http://bigasterisk.com/wifiAccessPoints"),
|
|
241 '-': URIRef("http://bigasterisk.com/wifiUnknownConnectionType"),
|
|
242 'Unknown': URIRef("http://bigasterisk.com/wifiUnknownConnectionType"),
|
|
243 'wired': URIRef("http://bigasterisk.com/houseOpenNet")}[dev['contype']], ctx))
|
|
244 if 'clientHostname' in dev and dev['clientHostname']:
|
|
245 g.add((uri, ROOM['wifiNetworkName'], Literal(dev['clientHostname']), ctx))
|
|
246 if 'name' in dev and dev['name']:
|
|
247 g.add((uri, ROOM['deviceName'], Literal(dev['name']), ctx))
|
|
248 if 'signal' in dev:
|
|
249 g.add((uri, ROOM['signalStrength'], Literal(dev['signal']), ctx))
|
|
250 if 'model' in dev:
|
|
251 g.add((uri, ROOM['networkModel'], Literal(dev['model']), ctx))
|
|
252 try:
|
|
253 conn = whenConnected(mongo, dev['mac'])
|
|
254 except ValueError:
|
|
255 traceback.print_exc()
|
|
256 pass
|
|
257 else:
|
|
258 g.add((uri, ROOM['connectedAgo'],
|
|
259 Literal(connectedAgoString(conn)), ctx))
|
|
260 g.add((uri, ROOM['connected'], Literal(conn), ctx))
|
|
261 masterGraph.setToGraph(g)
|
1223
|
262
|
1224
|
263
|
|
264 if __name__ == '__main__':
|
|
265 args = docopt.docopt('''
|
|
266 Usage:
|
|
267 tomatoWifi [options]
|
1223
|
268
|
1224
|
269 Options:
|
|
270 -v, --verbose more logging
|
|
271 --port=<n> serve on port [default: 9070]
|
|
272 --poll=<freq> poll frequency [default: .2]
|
|
273 ''')
|
|
274 if args['--verbose']:
|
|
275 from twisted.python import log as twlog
|
|
276 twlog.startLogging(sys.stdout)
|
|
277 log.setLevel(10)
|
|
278 log.setLevel(logging.DEBUG)
|
|
279
|
|
280 mongo = Connection('bang', 27017, tz_aware=True)['visitor']['visitor']
|
|
281 influx = InfluxDBClient('bang', 9060, 'root', 'root', 'main')
|
1223
|
282
|
1224
|
283 masterGraph = PatchableGraph()
|
|
284 wifi = Wifi()
|
|
285 poller = Poller(wifi, mongo)
|
|
286 task.LoopingCall(poller.poll).start(1/float(args['--poll']))
|
1223
|
287
|
1224
|
288 reactor.listenTCP(int(args['--port']),
|
|
289 cyclone.web.Application(
|
|
290 [
|
|
291 (r"/", Index),
|
|
292 (r'/json', Json),
|
|
293 (r'/graph', CycloneGraphHandler, {'masterGraph': masterGraph}),
|
|
294 (r'/graph/events', CycloneGraphEventsHandler, {'masterGraph': masterGraph}),
|
|
295 (r'/table', Table),
|
|
296 #(r'/activity', Activity),
|
|
297 ],
|
|
298 wifi=wifi,
|
|
299 poller=poller,
|
|
300 mongo=mongo))
|
|
301 import twisted; print('twisted', twisted.__version__)
|
|
302 reactor.run()
|