changeset 291:299ddd7e2070

start bt beacon tools Ignore-this: a19bb907ede601562ef44c27ae706dca
author drewp@bigasterisk.com
date Wed, 20 Jul 2016 23:52:03 -0700
parents 7b5cff542078
children 105969d248d6
files service/beacon/beacon-map.html service/beacon/beaconmap.html service/beacon/beaconmap.py service/beacon/house-model.html service/beacon/rssiscan.py
diffstat 5 files changed, 1177 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /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 @@
+<link rel="import" href="/lib/polymer/1.0.9/polymer/polymer.html">
+<link rel="import" href="/lib/polymer/1.0.9/iron-ajax/iron-ajax.html">
+
+<dom-module id="beacon-map">
+  <template>
+    <style>
+     #map { }
+     canvas { border: 1px solid gray; }
+    </style>
+    <iron-ajax on-response="onPoints" needs="redo"
+               url="points"
+               params="{{pointsParams}}"
+               ></iron-ajax>
+    <canvas id="map" width="500" height="800"></canvas>
+  </template>
+  <script src="dat.gui.js"></script>
+
+  <script>
+   HTMLImports.whenReady(function () {
+       Polymer({
+           is: "beacon-map",
+           properties: {
+               show: { type: Object, notify: true },
+               pointsParams: { computed: '_pointsParams(show)' }
+           },
+           ready: function() {
+               this.scaleSum = 1;
+               //var gui = new dat.GUI();
+               //gui.add(this, 'scaleSum', .001, 3);
+               //gui.listen({updateDisplay: this.redraw.bind(this)});
+
+           },
+           _pointsParams: function(show) {
+               if (show) {
+                   return { addr: show.addr };
+               }
+           },
+           onPoints: function(ev) {
+               this.points = ev.detail.response.points;
+               this.redraw();
+           },
+           redraw: function() {
+               if (!this.points || this.points.length < 1) {
+                   return;
+               }
+               var ctx = this.$.map.getContext('2d');
+               var w = this.$.map.width, h = this.$.map.height;
+               
+               ctx.clearRect(0, 0, w, h);
+
+               ctx.font = "12px serif";
+
+               var pos = [
+                   [.2, .2],
+                   [.2, .7],
+                   [.8, .2],
+                   [.9, .6],
+                   [.8, .8],
+                   [.4, .5],
+                   [.7, .6],
+               ];
+               
+               
+               ctx.fillText("changing", pos[0][0] * 400, pos[0][1] * 400);
+               ctx.fillText("garage",   pos[1][0] * 400, pos[1][1] * 400);
+               ctx.fillText("bed",      pos[2][0] * 400, pos[2][1] * 400);
+               ctx.fillText("dash",     pos[3][0] * 400, pos[3][1] * 400);
+               ctx.fillText("bang",     pos[4][0] * 400, pos[4][1] * 400);
+               ctx.fillText("living",   pos[5][0] * 400, pos[5][1] * 400);
+               ctx.fillText("kitchen",  pos[6][0] * 400, pos[6][1] * 400);
+               
+               ctx.beginPath();
+               var first = true;
+               this.points.forEach(function(p) {
+                   if (!p.map) {
+                       return;
+                       }
+                   var weak = -95, strong = -60;
+                   var w = p.map(function(rssi) {
+                       return (rssi - weak) / (strong - weak);
+                   });
+                   
+                   var sum = w[0] + w[1] + w[2] + w[3] + w[4] + w[5] + w[6];
+                   sum *= this.scaleSum;
+                   w[0] /= sum;
+                   w[1] /= sum;
+                   w[2] /= sum;
+                   w[3] /= sum;
+                   w[4] /= sum;
+                   w[5] /= sum;
+                   w[6] /= sum;
+
+                   var x=0, y=0;
+                   for (var i=0; i<7; i++) {
+                       x += pos[i][0] * w[i];
+                       y += pos[i][1] * w[i];
+                   }
+
+                   x *= 400;
+                   y *= 400;
+                   if (first) {
+                       ctx.moveTo(x, y);
+                       first = false;
+                   } else {
+                       ctx.lineTo(x, y);
+                       
+                   }
+               });
+               ctx.stroke();
+           }
+       });
+   });
+  </script>
+</dom-module>
--- /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 @@
+<!doctype html>
+<html>
+  <head>
+    <title>beaconmap</title>
+    <meta charset="utf-8" />
+    <script src="/lib/polymer/1.0.9/webcomponentsjs/webcomponents-lite.min.js"></script>
+    <script>
+      window.Polymer = {
+      dom: 'shadow',
+      };
+    </script>
+    <link rel="import" href="/lib/polymer/1.0.9/polymer/polymer.html">
+    <link rel="import" href="/lib/polymer/1.0.9/iron-ajax/iron-ajax.html">
+    <link rel="import" href="/lib/polymer/1.0.9/iron-list/iron-list.html"> 
+    <link rel="import" href="/lib/polymer/1.0.9/paper-header-panel/paper-header-panel.html">
+    <link rel="import" href="/lib/polymer/1.0.9/iron-flex-layout/iron-flex-layout-classes.html">
+    <style is="custom-style" include="iron-flex iron-flex-alignment iron-positioning"></style>
+   
+    <script src="dat.gui.min.js"></script>
+    <link rel="import" href="beacon-map.html">
+    <link rel="import" href="house-model.html">
+  </head>
+  <body class="fullbleed layout vertical">
+    <dom-module id="beacon-devices">
+      <template>
+
+        <style>
+         iron-list { height: 100%; }
+         .row { border: 1px outset #dadada; margin: 2px; background: #f7f7f7;}
+        </style>
+        <iron-ajax url="devices"
+                   auto
+                   last-response="{{response}}"></iron-ajax>
+        <iron-list items="[[response.devices]]"
+                   selection-enabled="true"
+                   selected-item="{{selected}}">
+          <template>
+            <div>
+              <div class="row"> {{item.addr}} {{item.name}} ({{item.recentCount}})</div>
+            </div>
+          </template>
+        </iron-list>
+      </template>
+      <script>
+       HTMLImports.whenReady(function () {
+           Polymer({
+               is: "beacon-devices",
+               properties: {
+                   response: { type: Object, notify: true },
+                   selected: { type: Object, notify: true },
+               }
+           });
+       });
+      </script>
+    </dom-module>
+
+    <script src="/lib/rickshaw/90852d8/vendor/d3.min.js"></script>
+    <script>
+     var hashCode = function(s){
+         var hash = 0;
+         if (s.length == 0) return hash;
+         for (var i = 0; i < s.length; i++) {
+             var character = s.charCodeAt(i);
+             hash = ((hash<<5)-hash)+character;
+             hash = hash & hash; // Convert to 32bit integer
+         }
+         return hash;
+     };
+     var colorForAddr = function(addr) {
+         var hue = hashCode(addr) % 360;
+         return d3.hsl(hue, 1, 0.5) + "";
+     };
+     var colorForSensor = function(from) {
+         var hue = hashCode(from) % 360;
+         return d3.hsl(hue, .7, 0.7) + "";
+     };
+    </script>
+    
+    <dom-module id="beacon-sensor-graph">
+      <link rel="import" type="css" href="/lib/rickshaw/90852d8/src/css/graph.css">
+      <link rel="import" type="css" href="/lib/rickshaw/90852d8/src/css/detail.css">
+      <link rel="import" type="css" href="/lib/rickshaw/90852d8/src/css/legend.css">
+      
+      <template>
+        <iron-ajax id="get" url="sensor" params='{{params}}' auto last-response="{{response}}"></iron-ajax>
+        <div>{{sensor.from}}</div>
+        <div id="chart_container" on-click="onClick">
+	  <div id="chart"></div>
+        </div>
+
+      </template>
+      <script src="/lib/jquery-2.0.3.min.js"></script>
+      <script src="/lib/rickshaw/90852d8/vendor/d3.min.js"></script>
+      <script src="/lib/rickshaw/90852d8/rickshaw.min.js"></script>
+      <script>
+       HTMLImports.whenReady(function () {
+           Polymer({
+               is: "beacon-sensor-graph",
+               properties: {
+                   sensor: {type: Object},
+                   response: {type: Object, notify: true},
+                   params: {computed: '_params(sensor.from)'}
+               },
+               _params: function(from) {
+                   return {from: from, secs: 60*5};
+               },
+               observers: [
+                   'onResponse(response)'
+               ],
+               ready: function() {
+                   this.scopeSubtree(this.$.chart_container, true);
+
+               },
+               onClick: function() {
+                   this.$.get.generateRequest();
+               },
+               redraw: function() {
+                   var serieses = [];
+
+                   for (var addr of Object.keys(this.points)) {
+                       var pts = this.points[addr];
+                       var transformed = pts.map(function(p) {
+                           return {x: p[0] + this.startTime,
+                                   y: .5 * (p[1]-(-120)) / (-60+120)};
+                       }.bind(this));
+                       serieses.push({
+                           name: addr,
+                           data: transformed,
+                           color: colorForAddr(addr),
+                           });
+                   }
+                   
+                   this.$.chart.innerHTML = '';
+                   var graph = new Rickshaw.Graph( {
+	               element: this.$.chart,
+	               width: 400,
+	               height: 60,
+	               renderer: 'line',
+	               series: serieses,
+                   } );
+
+                   graph.render();
+
+                   var hoverDetail = new Rickshaw.Graph.HoverDetail( {
+	               graph: graph
+                   } );
+                 
+                   var axes = new Rickshaw.Graph.Axis.Time( {
+	               graph: graph
+                   } );
+                   axes.render();
+
+                   
+                   var yAxis = new Rickshaw.Graph.Axis.Y( {
+	               graph: graph,
+	               tickFormat: Rickshaw.Fixtures.Number.formatKMBT,
+                   } );
+
+                   yAxis.render();
+                   
+                   this.graph = graph;
+               },
+               onResponse: function(response) {
+                   this.points = response.points;
+                   this.startTime = response.startTime;
+                   this.redraw();
+
+               }
+           });
+       });
+      </script>
+    </dom-module>
+    
+    <dom-module id="beacon-device-graph">
+      <link rel="import" type="css" href="/lib/rickshaw/90852d8/src/css/graph.css">
+      <link rel="import" type="css" href="/lib/rickshaw/90852d8/src/css/detail.css">
+      <link rel="import" type="css" href="/lib/rickshaw/90852d8/src/css/legend.css">
+      <template>
+       
+        <div>{{addr}} (continuous update)</div>
+        <div id="chart_container" on-click="onClick">
+	  <div id="chart"></div>
+        </div>
+        <div>{{latest}}</div>
+      </template>
+      <script src="/lib/jquery-2.0.3.min.js"></script>
+      <script src="/lib/rickshaw/90852d8/vendor/d3.min.js"></script>
+      <script src="/lib/rickshaw/90852d8/rickshaw.min.js"></script>
+      <script>
+       HTMLImports.whenReady(function () {
+           Polymer({
+               is: "beacon-device-graph",
+               properties: {
+                   addr: {type: String, notify: true},
+                   params: {computed: '_params(addr)'}
+
+               },
+               _params: function(from) {
+                   return {addr: this.addr};
+               },
+               observers: [
+                   'onResponse(response)',
+                   'startEvents(addr)',
+               ],
+               ready: function() {
+                   this.scopeSubtree(this.$.chart_container, true);
+               },
+               startEvents: function(addr) {
+                   if (this.events) {
+                       this.events.close();
+                   }
+                   if (addr) {
+                       console.log('new es', addr);
+                       this.events = new EventSource('points?addr=' + encodeURIComponent(addr));
+                       this.events.addEventListener('message', function(e) {
+                           var body = JSON.parse(e.data);
+                           this.points = body.points;
+                           this.startTime = body.startTime;
+                           this.redraw();
+                       }.bind(this));
+                   }
+               },
+               
+               onClick: function() {
+                   this.$.get.generateRequest();
+               },
+               redraw: function() {
+                   var serieses = [];
+                   var latestForSensor = {};
+                   for (var row of this.points) {
+                       var transformed = row.points.map(function(p) {
+                           return {x: p[0] + this.startTime,
+                                   y: .5 * (p[1]-(-120)) / (-60+120)};
+                       }.bind(this));
+                       serieses.push({
+                           name: row.from,
+                           data: transformed,
+                           color: colorForSensor(row.from),
+                       });
+                       latestForSensor[row.from] = row.points[row.points.length - 1][1];
+                   }
+                   this.latest = JSON.stringify(latestForSensor);
+                   this.$.chart.innerHTML = '';
+                   var graph = new Rickshaw.Graph( {
+	               element: this.$.chart,
+	               width: 640,
+	               height: 300,
+	               renderer: 'line',
+	               series: serieses,
+                   } );
+
+                   graph.render();
+
+                   var hoverDetail = new Rickshaw.Graph.HoverDetail( {
+	               graph: graph
+                   } );
+                 
+                   var axes = new Rickshaw.Graph.Axis.Time( {
+	               graph: graph
+                   } );
+                   axes.render();
+                   
+                   var yAxis = new Rickshaw.Graph.Axis.Y( {
+	               graph: graph,
+	               tickFormat: Rickshaw.Fixtures.Number.formatKMBT,
+                   } );
+
+                   yAxis.render();
+                   
+                   this.graph = graph;
+               },
+           
+           });
+       });
+      </script>
+    </dom-module>
+    
+    <dom-module id="beacon-page">
+      <template>
+        <link rel="import" href="/lib/polymer/1.0.9/iron-flex-layout/iron-flex-layout-classes.html">
+        <style is="custom-style" include="iron-flex iron-flex-alignment iron-flex-factors"></style>
+        <style>
+        </style>
+        <paper-header-panel class="flex">
+          <div class="paper-header">beacon map</div>
+          <div class="layout horizontal">
+
+            <beacon-devices style="height: 500px"
+                            class="layout justified flex-1"
+                            selected="{{sel}}"></beacon-devices>
+            <div class="layout vertical" style="flex-grow: 2">
+              <house-model position-estimates="{{positionEstimates}}" beacons="{{beacons}}"></house-model>
+              <beacon-device-graph addr="{{sel.addr}}"></beacon-device-graph>
+              <div>
+                save white levels:
+                <iron-ajax url="save"
+                           method="POST"
+                           id="save"
+                           verbose="true"
+                           handle-as="text"
+                           last-response="{{saveResponse}}"></iron-ajax>
+                  <button on-click="onSave" style="padding: 20px">save</button> {{saveResponse}}
+              </div>
+                
+              <div id="sensors">
+                <iron-ajax url="sensors"
+                           auto
+                           last-response="{{sensorsResponse}}"></iron-ajax>
+                <template is="dom-repeat" items="{{sensorsResponse.sensors}}">
+                  <beacon-sensor-graph sensor="{{item}}"></beacon-sensor-graph>
+                </template>
+              </div>
+              
+            <beacon-map class="flex-4" show="{{sel}}"></beacon-map>
+            </div>
+          </div> 
+        </paper-header-panel>
+        beacon page gets this
+
+      </template>
+      <script>
+       HTMLImports.whenReady(function () {
+           Polymer({
+               is: "beacon-page",
+               properties: {
+                   sel: { type: Object, notify: true },
+                   saveResponse: { type: String, notify: true},
+               },
+               onSave: function() {
+                   this.$.save.generateRequest();
+               },
+               ready: function() {
+
+this.sel = {addr: '00:ea:23:23:c6:c4'};
+                   
+                   var events = new EventSource('positionEstimates?addr=' +
+                                                encodeURIComponent('00:ea:23:23:c6:c4'));
+                   events.addEventListener('message', function(e) {
+                       var body = JSON.parse(e.data);
+                       this.positionEstimates = body.nearest;
+                       this.set('beacons', [{label: 'apollo', pos: body.weightedCoord}]);
+                   }.bind(this));
+               },
+           });
+       });
+
+      </script>
+    </dom-module>
+
+    <style>
+     .paper-header {
+         background-color: var(--paper-light-blue-500);
+     }
+    </style>
+    
+    <beacon-page class="layout fit"></beacon-page>
+  </body>
+</html>
--- /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()
--- /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 @@
+<link rel="import" href="/lib/polymer/1.0.9/polymer/polymer.html">
+
+<dom-module id="house-model">
+  <template>
+    <style>
+     #scene {
+         position: relative;
+         overflow: hidden;
+     }
+     .label {
+         position: absolute;
+         color: white;
+         font-family: sans-serif;
+         font-size: 10px;
+         font-weight: bold;
+         text-shadow: 0 0 1px black;
+     }
+    </style>
+    <div id="scene"></div>
+  </template>
+  <script src="/lib/threejs/r78/three.js"></script>
+  <script src="dat.gui.js"></script>
+
+  <script>
+
+   function Bar(sceneParent, domParent, pos) {
+       this.domParent = domParent;
+       this.mesh = new THREE.Mesh(Bar.geometry, Bar.material);
+       this.mesh.scale.set(.1, 1, .1);
+       this.mesh.translateX(pos.x);
+       this.mesh.translateZ(pos.z);
+       this.pos = pos;
+       
+       sceneParent.add(this.mesh);
+
+   }
+   Bar.material = new THREE.MeshLambertMaterial({
+       "color": 0xe314a3,
+       "emissive": 0x531251
+   });
+   Bar.geometry = new THREE.CylinderGeometry( 1, 1, 1, 32 );
+   Bar.prototype.setValue = function(value, label) {
+       this.value = value;
+       this.label = label;
+       this.mesh.position.setY(this.pos.y + value / 2);
+       this.mesh.scale.setY(value);
+       if (this.div) {
+           this.div.innerText = '' + label;
+       }
+   };
+   Bar.prototype.update2d = function(camera, screenSize) {
+       if (!this.div) {
+           this.div = document.createElement("div");
+           this.div.classList.add("label");
+           this.domParent.appendChild(this.div);
+       }
+       
+       var vector = this.mesh.position.clone().project(camera);
+       vector.x = (vector.x + 1) / 2 * screenSize.x;
+       vector.y = -(vector.y - 1) / 2 * screenSize.y;
+
+       this.div.style.left = vector.x + 'px';
+       this.div.style.top = vector.y + 'px';
+   };
+
+
+   
+   Polymer({
+       is: "house-model",
+       properties: {
+           positionEstimates: {type: Object, notify: true, observer: 'onPositionEstimates'},
+           beacons: {type: Array, notify: true, observer: 'onBeacons'},
+       },
+       addLights: function(scene) {
+           var addLight = function(color, pos) {
+               var light = new THREE.PointLight(color, 1, 0, 0);
+               light.position.set.apply(pos);
+               scene.add(light);
+               return light;
+           };
+           addLight(0xffafff, [0, 6, 0]);
+           addLight(0xffffaf, [-6, 6, 9]);
+           addLight(0xffcfaf, [5, 5, 0]);
+           addLight(0xefffef, [6, 4, 2]);
+       },
+       setMaterials: function(scene) {
+           this.wall = new THREE.MeshPhongMaterial({
+               color : 0x7c7c7c,
+               emissive: 0x303030,
+               name : "wall",
+               opacity : 0.34,
+               shininess : 1.3,
+               side : THREE.DoubleSide,
+               specular : 16777215,
+               transparent : true,
+               depthTest: true,
+               depthWrite: false,
+               alphaTest: 0.01,
+           });
+
+           
+           scene.children.forEach(function(obj) {
+               obj.material = this.wall;
+           }.bind(this));
+       },
+       ready: function() {
+           var container = this.$.scene;
+
+           this.renderer = new THREE.WebGLRenderer( { antialias: true } );
+           this.size = new THREE.Vector2(800, 500);
+           this.renderer.setSize(this.size.x, this.size.y);
+           container.appendChild( this.renderer.domElement );
+
+           this.config = {
+               camRotSpeed: 5,
+               camY: 8.5,
+               camDist: 11,
+               cursorX: 0, cursorY: 0, cursorZ: 0,
+           };
+           
+           var gui = new dat.GUI();
+           gui.add(this.config, 'camRotSpeed', -80, 80);
+           gui.add(this.config, 'camY', -2, 10);
+           gui.add(this.config, 'camDist', 1, 25);
+           gui.add(this.config, 'cursorX', -7, 7);
+           gui.add(this.config, 'cursorY', -7, 7);
+           gui.add(this.config, 'cursorZ', -7, 7);
+
+           this.bars = {}; // pos : Bar
+           this.scene = null;
+           
+           var loader = new THREE.ObjectLoader();
+           loader.load('house.json', function(scene) {             
+               window.scene = scene;
+               this.scene = scene;
+               this.addLights(scene);
+
+               this.setMaterials(scene);
+               
+               gui.add(this.wall, 'depthTest');
+               gui.add(this.wall, 'depthWrite');
+               gui.add(this.wall, 'transparent');
+               gui.add(this.wall, 'opacity', 0, 1);
+               gui.add(this.wall, 'shininess', 0, 3);
+               gui.add(this.wall, 'alphaTest', 0, 1);
+               gui.listen({updateDisplay: function() { this.wall.needsUpdate = true; }.bind(this)});
+
+               this.camera = new THREE.PerspectiveCamera( 45, this.size.x / this.size.y, .1, 100 );
+               scene.add(this.camera);
+
+               var geometry = new THREE.SphereGeometry( .3, 32, 32 );
+               var material = new THREE.MeshBasicMaterial( {color: 0xffff00} );
+               this.cursor = new THREE.Mesh( geometry, material );
+               scene.add(this.cursor);
+
+               this.rotY = 0;
+               this.startAnimLoop();
+           }.bind(this));
+       },
+       startAnimLoop: function() {
+           var lastTime = Date.now();
+           requestAnimationFrame(function animate(){
+               var now = Date.now();
+               var dt = (now - lastTime) / 1000;
+               lastTime = now;
+               this.step(dt);
+               requestAnimationFrame(animate.bind(this));
+           }.bind(this))
+       },
+       step: function(dt) {
+           this.rotY += this.config.camRotSpeed * dt;
+           
+           var p = new THREE.Vector3(this.config.camDist, this.config.camY, 0);
+           p.applyAxisAngle(new THREE.Vector3(0, 1, 0), this.rotY / 360 * 6.28);
+           this.camera.position.copy(p);
+
+           this.camera.lookAt(new THREE.Vector3(-1, 0, 0));
+
+           this.cursor.position.set(this.config.cursorX, this.config.cursorY, this.config.cursorZ);
+           for (var p of Object.keys(this.bars)) {
+               var b = this.bars[p];
+               b.update2d(this.camera, this.size);
+               b.setValue(b.value, b.label);
+           }
+           this.renderer.render(this.scene, this.camera); 
+       },
+       onPositionEstimates: function() {
+           if (!this.scene) {
+               return;
+           }
+
+           for (var p of Object.keys(this.bars)) {
+               var b = this.bars[p];
+               b.setValue(0.001, '');
+           }
+           this.positionEstimates.forEach(function(row) {
+               var posKey = (Math.round(row[1][0] * 100) + ' ' +
+                             Math.round(row[1][1] * 100) + ' ' +
+                             Math.round(row[1][2] * 100));
+               var b = this.bars[posKey];
+               if (!b) {
+                   b = new Bar(this.scene, this.$.scene,
+                               new THREE.Vector3(-row[1][0], row[1][2], row[1][1]));
+                   this.bars[posKey] = b;
+               }
+               b.setValue(10 / row[0], Math.round(row[0] * 100) / 100);
+           }.bind(this));
+       },
+       onBeacons: function() {
+           this.beacons.forEach(function(b) {
+               this.config.cursorX = -b.pos[0];
+               this.config.cursorY = b.pos[2];
+               this.config.cursorZ = b.pos[1];
+           }.bind(this));
+       }
+   });
+  </script>
+</dom-module>
--- /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("<BB", enable, 0x00)
+    bluez.hci_send_cmd(sock, OGF_LE_CTL, OCF_LE_SET_SCAN_ENABLE, cmd_pkt)
+
+
+# ported from android/source/external/bluetooth/hcidump/parser/hci.c
+
+def evttype2str(etype):
+    return {
+            0x00: "ADV_IND", # Connectable undirected advertising
+            0x01: "ADV_DIRECT_IND", # Connectable directed advertising
+            0x02: "ADV_SCAN_IND", # Scannable undirected advertising
+            0x03: "ADV_NONCONN_IND", # Non connectable undirected advertising
+            0x04: "SCAN_RSP", # Scan Response
+    }.get(etype, "Reserved")
+
+def bdaddrtype2str(btype):
+    return {
+            0x00: "Public",
+            0x01: "Random",
+            }.get(btype, "Reserved")
+
+
+# from https://github.com/google/eddystone/blob/master/eddystone-url/implementations/linux/scan-for-urls
+
+schemes = [
+        "http://www.",
+        "https://www.",
+        "http://",
+        "https://",
+        ]
+
+extensions = [
+        ".com/", ".org/", ".edu/", ".net/", ".info/", ".biz/", ".gov/",
+        ".com", ".org", ".edu", ".net", ".info", ".biz", ".gov",
+        ]
+
+def decodeUrl(encodedUrl):
+    """
+    Decode a url encoded with the Eddystone (or UriBeacon) URL encoding scheme
+    """
+
+    decodedUrl = schemes[encodedUrl[0]]
+    for c in encodedUrl[1:]:
+        if c <= 0x20:
+            decodedUrl += extensions[c]
+        else:
+            decodedUrl += chr(c)
+
+    return decodedUrl
+
+def decodeBeacon(data, row):
+    # this padding makes the offsets line up to the scan-for-urls code
+    padData = map(ord, '*' * 14 + data)
+    
+    # Eddystone
+    if len(padData) >= 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)