# HG changeset patch # User drewp # Date 1469083923 25200 # Node ID f42fd56049dd7f5af115d0b5be2cb0f372f3fa97 # Parent 870d1bbae402445906ef9d648b1f319be7a4f566 start bt beacon tools Ignore-this: a19bb907ede601562ef44c27ae706dca darcs-hash:293ce348a62ad02dd33058661593aa4f0caaaa24 diff -r 870d1bbae402 -r f42fd56049dd service/beacon/beacon-map.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/beacon/beacon-map.html Wed Jul 20 23:52:03 2016 -0700 @@ -0,0 +1,114 @@ + + + + + + + + + diff -r 870d1bbae402 -r f42fd56049dd service/beacon/beaconmap.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/beacon/beaconmap.html Wed Jul 20 23:52:03 2016 -0700 @@ -0,0 +1,358 @@ + + + + beaconmap + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 870d1bbae402 -r f42fd56049dd service/beacon/beaconmap.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/beacon/beaconmap.py Wed Jul 20 23:52:03 2016 -0700 @@ -0,0 +1,293 @@ +from __future__ import division +import sys, cyclone.web, json, datetime, time +import arrow +from twisted.internet import reactor, task +from pymongo import MongoClient +from dateutil.tz import tzlocal +import math +import cyclone.sse +from locator import Locator, Measurement + +sys.path.append("/my/proj/homeauto/lib") +from cycloneerr import PrettyErrorHandler +from logsetup import log + +class Devices(PrettyErrorHandler, cyclone.web.RequestHandler): + def get(self): + devices = [] + startCount = datetime.datetime.now(tzlocal()) - datetime.timedelta(seconds=60*60*2) + filt = { + #"addr_type": "Public", + } + for addr in scan.distinct('addr', filt): + filtAddr = filt.copy() + filtAddr['addr'] = addr + row = scan.find_one(filtAddr, sort=[('t', -1)], limit=1) + filtAddrRecent = filtAddr.copy() + filtAddrRecent['t'] = {'$gt': startCount} + freq = scan.count(filtAddrRecent) + if not freq: + continue + name = None + if addr == "00:ea:23:23:c6:c4": + name = 'apollo' + if addr == "00:ea:23:21:e0:a4": + name = 'white' + if addr == "00:ea:23:24:f8:d4": + name = 'green' + if 'Eddystone-URL' in row: + name = row['Eddystone-URL'] + devices.append({ + 'addr': addr, + 'recentCount': freq, + 'lastSeen': row['t'].isoformat(), + 'name': name}) + devices.sort(key=lambda d: (d['name'] or 'zzz', + -d['recentCount'], + d['addr'])) + self.set_header("Content-Type", "application/json") + self.write(json.dumps({'devices': devices})) + + +class Poller(object): + def __init__(self): + self.listeners = [] # Points handlers + + self.lastPointTime = {} # addr : secs + self.lastValues = {} # addr : {sensor: (secs, rssi)} + task.LoopingCall(self.poll).start(1) + + def poll(self): + addrs = set(l.addr for l in self.listeners if l.addr) + seconds = 60 * 20 + now = datetime.datetime.now(tzlocal()) + startTime = (now - datetime.timedelta(seconds=seconds)) + startTimeSec = arrow.get(startTime).timestamp + for addr in addrs: + points = {} # from: [offsetSec, rssi] + for row in scan.find({'addr': addr, 't': {'$gt': startTime}, + #'addr_type': 'Public', + }, + sort=[('t', 1)]): + t = (arrow.get(row['t']) - startTime).total_seconds() + points.setdefault(row['from'], []).append([ + round(t, 2), row['rssi']]) + self.lastValues.setdefault(addr, {})[row['from']] = ( + now, row['rssi']) + + for pts in points.values(): + smooth(pts) + + if not points: + continue + + last = max(pts[-1][0] + startTimeSec for pts in points.values()) + if self.lastPointTime.get(addr, 0) == last: + continue + self.lastPointTime[addr] = last + msg = json.dumps({ + 'addr': addr, + 'startTime': startTimeSec, + 'points': [{'from': k, 'points': v} + for k,v in sorted(points.items())]}) + for lis in self.listeners: + if lis.addr == addr: + lis.sendEvent(msg) + + def lastValue(self, addr, maxSensorAgeSec=30): + """note: only considers actively polled addrs""" + out = {} # from: rssi + now = datetime.datetime.now(tzlocal()) + for sensor, (t, rssi) in self.lastValues.get(addr, {}).iteritems(): + print 'consider %s %s' % (t, now) + if (now - t).total_seconds() < maxSensorAgeSec: + out[sensor] = rssi + return out + +def smooth(pts): + # see https://filterpy.readthedocs.io/en/latest/kalman/UnscentedKalmanFilter.html + for i in range(0, len(pts)): + if i == 0: + prevT, smoothX = pts[i] + else: + t, x = pts[i] + if t - prevT < 30: + smoothX = .8 * smoothX + .2 * x + else: + smoothX = x + pts[i] = [t, round(smoothX, 1)] + prevT = t + +class Points(cyclone.sse.SSEHandler): + def __init__(self, application, request, **kw): + cyclone.sse.SSEHandler.__init__(self, application, request, **kw) + if request.headers['accept'] != 'text/event-stream': + raise ValueError('ignoring bogus request') + self.addr = request.arguments.get('addr', [None])[0] + + def bind(self): + if not self.addr: + return + poller.listeners.append(self) + def unbind(self): + if not self.addr: + return + poller.listeners.remove(self) + +class LocatorEstimatesPoller(object): + def __init__(self): + self.listeners = [] + self.lastResult = {} + self.locator = Locator() + task.LoopingCall(self.poll).start(1) + + def poll(self): + addrs = set(l.addr for l in self.listeners if l.addr) + now = datetime.datetime.now(tzlocal()) + cutoff = (now - datetime.timedelta(seconds=60)) + + for addr in addrs: + d = {} # from: [(t, rssi)] + for row in scan.find({'addr': addr, 't': {'$gt': cutoff}}, + sort=[('t', 1)]): + d.setdefault(row['from'], []).append((arrow.get(row['t']).timestamp, row['rssi'])) + + for pts in d.values(): + smooth(pts) + meas = Measurement(dict((k, v[-1][1]) for k, v in d.items())) + nearest = [ + (dist, coord) for dist, coord in self.locator.nearestPoints(meas) if dist < 25 + ] + if nearest: + floors = [row[1][2] for row in nearest] + freqs = [(floors.count(z), z) for z in floors] + freqs.sort() + bestFloor = freqs[-1][1] + sameFloorMatches = [(dist, coord) for dist, coord in nearest + if coord[2] == bestFloor] + weightedCoord = [0, 0, 0] + totalWeight = 0 + for dist, coord in sameFloorMatches: + weight = 25 / (dist + .001) + totalWeight += weight + for i in range(3): + weightedCoord[i] += weight * coord[i] + for i in range(3): + weightedCoord[i] /= totalWeight + + self.lastResult[addr] = {'nearest': nearest, 'weightedCoord': weightedCoord} + + for lis in self.listeners: + lis.sendEvent(self.lastResult[addr]) + + +class PositionEstimates(cyclone.sse.SSEHandler): + def __init__(self, application, request, **kw): + cyclone.sse.SSEHandler.__init__(self, application, request, **kw) + if request.headers['accept'] != 'text/event-stream': + raise ValueError('ignoring bogus request') + self.addr = request.arguments.get('addr', [None])[0] + + def bind(self): + if not self.addr: + return + locatorEstimatesPoller.listeners.append(self) + def unbind(self): + if not self.addr: + return + locatorEstimatesPoller.listeners.remove(self) + + + +class Sensors(PrettyErrorHandler, cyclone.web.RequestHandler): + def get(self): + t1 = datetime.datetime.now(tzlocal()) - datetime.timedelta(seconds=60*10) + out = [] + for sens in scan.distinct('from', {'t': {'$gt': t1}}): + rssiHist = {} # level: count + for row in scan.find({'from': sens, 't': {'$gt': t1}}, + {'_id': False, 'rssi': True}): + bucket = (row['rssi'] // 5) * 5 + rssiHist[bucket] = rssiHist.get(bucket, 0) + 1 + + recent = {} + for row in scan.find({'from': sens}, + {'_id': False, + 'addr': True, + 't': True, + 'rssi': True, + 'addr_type': True}, + sort=[('t', -1)], + modifiers={'$maxScan': 100000}): + addr = row['addr'] + if addr not in recent: + recent[addr] = row + recent[addr]['t'] = arrow.get(recent[addr]['t']).timestamp + + out.append({ + 'from': sens, + 'count': sum(rssiHist.values()), + 'hist': rssiHist, + 'recent': sorted(recent.values()) + }) + + self.set_header("Content-Type", "application/json") + self.write(json.dumps({'sensors': out})) + + + +class Sensor(PrettyErrorHandler, cyclone.web.RequestHandler): + def get(self): + from_ = self.get_argument('from') + if not from_: + return + seconds = int(self.get_argument('secs', default=60 * 2)) + startTime = (datetime.datetime.now(tzlocal()) - + datetime.timedelta(seconds=seconds)) + points = {} # addr : [offsetSec, rssi] + for row in scan.find({'from': from_, 't': {'$gt': startTime}, + #'addr_type': 'Public', + }, + {'_id': False, + 'addr': True, + 't': True, + 'rssi': True, + }, + sort=[('t', 1)]): + points.setdefault(row['addr'], []).append([ + round((arrow.get(row['t']) - startTime).total_seconds(), 2), + row['rssi']]) + + self.set_header("Content-Type", "application/json") + self.write(json.dumps({ + 'sensor': from_, + 'startTime': arrow.get(startTime).timestamp, + 'points': points})) + +class Save(PrettyErrorHandler, cyclone.web.RequestHandler): + def post(self): + lines = open('saved_points').readlines() + lineNum = len(lines) + 1 + row = poller.lastValue('00:ea:23:21:e0:a4') + with open('saved_points', 'a') as out: + out.write('%s %r\n' % (lineNum, row)) + self.write('wrote line %s: %r' % (lineNum, row)) + +scan = MongoClient('bang', 27017, tz_aware=True)['beacon']['scan'] +poller = Poller() +locatorEstimatesPoller = LocatorEstimatesPoller() + +reactor.listenTCP( + 9113, + cyclone.web.Application([ + (r"/(|.*\.(?:js|html|json))$", cyclone.web.StaticFileHandler, { + "path": ".", "default_filename": "beaconmap.html"}), + (r"/devices", Devices), + (r'/points', Points), + (r'/sensors', Sensors), + (r'/sensor', Sensor), + (r'/save', Save), + (r'/positionEstimates', PositionEstimates), + ])) +log.info('serving on 9113') +reactor.run() diff -r 870d1bbae402 -r f42fd56049dd service/beacon/house-model.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/beacon/house-model.html Wed Jul 20 23:52:03 2016 -0700 @@ -0,0 +1,218 @@ + + + + + + + + + diff -r 870d1bbae402 -r f42fd56049dd service/beacon/rssiscan.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/service/beacon/rssiscan.py Wed Jul 20 23:52:03 2016 -0700 @@ -0,0 +1,194 @@ +# BLE iBeaconScanner based on https://github.com/adamf/BLE/blob/master/ble-scanner.py +# JCS 06/07/14 +# Adapted for Python3 by Michael duPont 2015-04-05 + +#DEBUG = False +# BLE scanner based on https://github.com/adamf/BLE/blob/master/ble-scanner.py +# BLE scanner, based on https://code.google.com/p/pybluez/source/browse/trunk/examples/advanced/inquiry-with-rssi.py + +# https://github.com/pauloborges/bluez/blob/master/tools/hcitool.c for lescan +# https://kernel.googlesource.com/pub/scm/bluetooth/bluez/+/5.6/lib/hci.h for opcodes +# https://github.com/pauloborges/bluez/blob/master/lib/hci.c#L2782 for functions used by lescan + +import struct +import sys +import bluetooth._bluetooth as bluez +from pymongo import MongoClient +import datetime +from dateutil.tz import tzlocal +from bson.binary import Binary + +LE_META_EVENT = 0x3e +OGF_LE_CTL=0x08 +OCF_LE_SET_SCAN_ENABLE=0x000C + +# these are actually subevents of LE_META_EVENT +EVT_LE_CONN_COMPLETE=0x01 +EVT_LE_ADVERTISING_REPORT=0x02 + +def hci_enable_le_scan(sock): + enable = 0x01 + cmd_pkt = struct.pack("= 20 and padData[19] == 0xaa and padData[20] == 0xfe: + serviceDataLength = padData[21] + frameType = padData[25] + + # Eddystone-URL + if frameType == 0x10: + row["Eddystone-URL"] = decodeUrl(padData[27:22 + serviceDataLength]) + elif frameType == 0x00: + row["Eddystone-UID"] = Binary(data) + elif frameType == 0x20: + row["Eddystone-TLM"] = Binary(data) + else: + row["Eddystone"] = "Unknown Eddystone frame type: %r data: %r" % (frameType, data) + + # UriBeacon + elif len(padData) >= 20 and padData[19] == 0xd8 and padData[20] == 0xfe: + serviceDataLength = padData[21] + row["UriBeacon"] = decodeUrl(padData[27:22 + serviceDataLength]) + + else: + pass # "Unknown beacon type" + +def decodeInquiryData(data, row): + # offset 19 is totally observed from data, not any spec. IDK if the preceding part is variable-length. + if len(data) > 20 and data[19] in ['\x08', '\x09']: + localName = data[20:] + if data[19] == '\x08': + row['local_name_shortened'] = Binary(localName) + else: + row['local_name_complete'] = Binary(localName) + # more at android/source/external/bluetooth/hcidump/parser/hci.c ext_inquiry_data_dump + +# from android/source/external/bluetooth/hcidump/parser/hci.c +def evt_le_advertising_report_dump(frm, now): + num_reports = ord(frm[0]) + frm = frm[1:] + + for i in range(num_reports): + fmt = 'B B 6B B' + row = {'t': now} + + evt_type, bdaddr_type, b5, b4, b3, b2, b1, b0, length = struct.unpack(fmt, frm[:struct.calcsize(fmt)]) + frm = frm[struct.calcsize(fmt):] + + row['addr'] = '%02x:%02x:%02x:%02x:%02x:%02x' % (b0, b1, b2, b3, b4, b5) + row['addr_type'] = bdaddrtype2str(bdaddr_type) + row['evt_type'] = evttype2str(evt_type) + + data = frm[:length] + frm = frm[length:] + row['data'] = Binary(data) + #row['data_hex'] = ' '.join('%02x' % ord(c) for c in data) + + decodeBeacon(data, row) + decodeInquiryData(data, row) + + row['rssi'], = struct.unpack('b', frm[-1]) + frm = frm[1:] + yield row + + + +def parse_events(sock, loop_count, source, coll): + old_filter = sock.getsockopt( bluez.SOL_HCI, bluez.HCI_FILTER, 14) + flt = bluez.hci_filter_new() + bluez.hci_filter_all_events(flt) + bluez.hci_filter_set_ptype(flt, bluez.HCI_EVENT_PKT) + sock.setsockopt( bluez.SOL_HCI, bluez.HCI_FILTER, flt ) + + for i in range(0, loop_count): + pkt = sock.recv(255) + ptype, event, plen = struct.unpack("BBB", pkt[:3]) + now = datetime.datetime.now(tzlocal()) + if event == bluez.EVT_INQUIRY_RESULT_WITH_RSSI: + print "EVT_INQUIRY_RESULT_WITH_RSSI" + elif event == bluez.EVT_NUM_COMP_PKTS: + print "EVT_NUM_COMP_PKTS" + elif event == bluez.EVT_DISCONN_COMPLETE: + print "EVT_DISCONN_COMPLETE" + elif event == LE_META_EVENT: + subevent, = struct.unpack("B", pkt[3:4]) + pkt = pkt[4:] + if subevent == EVT_LE_CONN_COMPLETE: + pass + elif subevent == EVT_LE_ADVERTISING_REPORT: + rows = list(evt_le_advertising_report_dump(pkt, now)) + for row in sorted(rows): + #print row['addr'], row['t'], row['rssi'], row + row['from'] = source + coll.insert(row) + + + sock.setsockopt( bluez.SOL_HCI, bluez.HCI_FILTER, old_filter ) + + +if __name__ == '__main__': + mongoHost, myLocation = sys.argv[1:] + + client = MongoClient(mongoHost) + coll = client['beacon']['scan'] + + dev_id = 0 + sock = bluez.hci_open_dev(dev_id) + sock.getsockopt( bluez.SOL_HCI, bluez.HCI_FILTER, 14) + + hci_enable_le_scan(sock) + + while True: + parse_events(sock, 10, source=myLocation, coll=coll)