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