blob: da1b06be2e678a92fc42dd47558c3abdaa225e60 [file] [log] [blame]
// Copyright 2015 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
require('es6-shim');
var $ = require('../util/jquery');
var defineClass = require('../util/define-class');
var _ = require('lodash');
var Multimap = require('multimap');
var marshalling = require('./marshalling');
var SyncbaseWrapper = require('../vanadium-wrapper/syncbase-wrapper');
var escape = SyncbaseWrapper.escapeKeyElement;
var unescape = SyncbaseWrapper.unescapeKeyElement;
var HEARTBEAT_PERIOD = 2500; //ms
var DEVICE_SEEN_RECENTLY = 5000; //ms
var LEFT = 'left';
var RIGHT = 'right';
var UP = 'up';
var DOWN = 'down';
var FORWARDS = 'forwards';
var BACKWARDS = 'backwards';
var NEAR = 'near';
var FAR = 'far';
var RE = 6.371e6;
function cartesian(geo) {
var lat = geo.latitude * Math.PI / 180;
var lng = geo.longitude * Math.PI / 180;
var planeFactor = Math.cos(lng);
return {
x: RE * Math.cos(lat) * planeFactor,
y: RE * Math.sin(lat) * planeFactor,
z: RE * Math.sin(lng)
};
}
function negateVector(vector) {
return {
x: -vector.x,
y: -vector.y,
z: -vector.z
};
}
function negateDirection(direction) {
switch(direction) {
case LEFT:
return RIGHT;
case RIGHT:
return LEFT;
case UP:
return DOWN;
case DOWN:
return UP;
case FORWARDS:
return BACKWARDS;
case BACKWARDS:
return FORWARDS;
default:
return {
mean: negateVector(direction.mean),
margin: direction.margin
};
}
}
var DeviceSync = defineClass({
statics: {
LEFT: LEFT,
RIGHT: RIGHT,
UP: UP,
DOWN: DOWN,
FORWARDS: FORWARDS,
BACKWARDS: BACKWARDS,
NEAR: NEAR,
FAR: FAR,
deviceKey: function(owner, device) {
var mutableArgs = _.flattenDeep(arguments);
//arguments 0 and 1 are a username and device ID, respectively
//if present, arguments 3 and 4 are that way too
for (var i = 0; i <= 4; i++) {
if (mutableArgs[i] && i !== 2) {
mutableArgs[i] = escape(mutableArgs[i]);
}
}
return ['devices'].concat(mutableArgs);
},
negateRelativePosition: function(relativePosition) {
return {
direction: negateDirection(relativePosition.direction),
magnitude: relativePosition.magnitude
};
}
},
publics: {
relate: function(remoteOwner, remoteDevice, relativePosition) {
var self = this;
return this.identityPromise.then(function(identity) {
return self.sbw.batch(function(ops) {
return Promise.all([
self.relateUnidirectional(ops,
identity.username, identity.deviceName,
remoteOwner, remoteDevice,
relativePosition),
self.relateUnidirectional(ops,
remoteOwner, remoteDevice,
identity.username, identity.deviceName,
self.negateRelativePosition(relativePosition))]);
});
});
},
getRelatedDevices: function(direction) {
return this.relatedDevices.get(direction) || new Multimap();
},
getUnconnectedCastTargets: function() {
return this.unconnectedCastTargets;
},
getPossibleCastTargets: function() {
return this.possibleCastTargets;
},
processDevices: function(data) {
var self = this;
var now = Date.now();
var possibleCastTargets = new Multimap();
var unconnectedCastTargets = new Multimap();
var connections;
this.identityPromise.then(function(identity) {
var hasCastTargets;
$.each(data, function(owner, devices) {
owner = unescape(owner);
$.each(devices, function(deviceName, deviceData) {
deviceName = unescape(deviceName);
if (owner === identity.username &&
deviceName === identity.deviceName) {
connections = deviceData.connections;
} else if (now - deviceData.lastSeen <= DEVICE_SEEN_RECENTLY) {
var deviceLocation = marshalling.unmarshal(deviceData.location);
var isNearby = self.isNearby(deviceLocation);
if (isNearby !== false) {
hasCastTargets = true;
possibleCastTargets.set(owner, deviceName);
unconnectedCastTargets.set(owner, deviceName);
}
}
});
});
/* This could just be a multimap of direction => {owner, deviceName},
* but to keep the interface consistent we should have the RHS be a
* multimap of owner => deviceName. */
var relatedDevices = new Map();
if (connections) {
$.each(connections, function(owner, devices) {
owner = unescape(owner);
$.each(devices, function(deviceName, relPos) {
deviceName = unescape(deviceName);
if (unconnectedCastTargets.has(owner, deviceName)) {
relPos = marshalling.unmarshal(relPos);
// TODO(rosswang): handle vector directions
var bucket = relatedDevices.get(relPos.direction);
if (!bucket) {
relatedDevices.set(relPos.direction, bucket = new Multimap());
}
bucket.set(owner, deviceName);
unconnectedCastTargets.delete(owner, deviceName);
}
});
});
}
if (hasCastTargets && !self.hasCastTargets) {
self.onPossibleNearbyDevices();
}
self.hasCastTargets = hasCastTargets;
self.possibleCastTargets = possibleCastTargets;
self.unconnectedCastTargets = unconnectedCastTargets;
self.relatedDevices = relatedDevices;
}).catch(this.onError);
},
/*
* Adds or updates an "extend" screencast with the given control points.
* @param remoteName the Vanadium name of the remote RPC server.
* @param adjustment normalized adjustment in local coordinates, { x, y }
*
extendTo: function(remoteName, adjustment) {
if (!this.deviceSetId) {
this.deviceSetId = uuid.v4();
}
return this.sbw.put(
this.deviceSetKey(this.deviceSetId, this.localName, remoteName),
marshalling.marshal(adjustment));
},
handleBoundsUpdate: function(newBounds) {
var self = this;
var deviceSetId = this.deviceSetId;
this.localBounds = newBounds;
if (this.currentBoundsUpdate) {
return this.currentBoundsUpdate;
} else {
if (deviceSetId) {
this.currentBoundsUpdate = this.runOrQueueBoundsUpdate()
.catch(function(err) {
delete self.currentBoundsUpdate;
throw err;
});
return this.currentBoundsUpdate;
} else {
return Promise.resolve();
}
}
}*/
},
privates: {
relateUnidirectional: function(dao, fromOwner, fromDevice,
toOwner, toDevice, relativePosition) {
return dao.put(this.deviceKey(
fromOwner, fromDevice, 'connections', toOwner, toDevice),
marshalling.marshal(relativePosition));
},
heartbeat: function() {
var self = this;
return this.identityPromise.then(function(identity) {
return self.sbw.put(
self.deviceKey(identity.username, identity.deviceName, 'lastSeen'),
Date.now());
});
},
updateGeolocation: function(geolocation) {
var self = this;
this.geolocation = geolocation;
return this.identityPromise.then(function(identity) {
return self.sbw.put(
self.deviceKey(identity.username, identity.deviceName, 'location'),
self.serializeGeolocation(geolocation));
});
},
/**
* The properties on these objects don't appear to be enumerable, so
* serialize them explicitly here.
*/
serializeGeolocation: function(geolocation) {
var coords = geolocation.coords;
return marshalling.marshal({
coords: {
accuracy: coords.accuracy,
altitude: coords.altitude,
altitudeAccuracy: coords.altitudeAccuracy,
heading: coords.heading,
latitude: coords.latitude,
longitude: coords.longitude,
speed: coords.speed
},
timestamp: geolocation.timestamp
});
},
/**
* The true calculation here would involve the distance between two
* cylinders in spherical space... let's just punt on that for now.
*
* TODO(rosswang): find a library or factor this out.
* location-math seems ideal but doesn't seem to contain any code
* coordinate-systems may be suitable
*
* Also, if we keep doing this based on Cartesian coordinates, cache the
* current Cartesian location.
*/
isNearby: function(geolocation) {
var a = this.geolocation && this.geolocation.coords || {};
var b = geolocation && geolocation.coords || {};
if (typeof a.altitude === 'number' && typeof b.altitude === 'number' &&
typeof a.altitudeAccuracy === 'number' &&
typeof b.altitudeAccuracy === 'number' &&
Math.abs(a.altitude - b.altitude) >
a.altitudeAccuracy + b.altitudeAccuracy + 50) {
return false;
}
if (typeof a.latitude === 'number' && typeof b.latitude === 'number' &&
typeof a.longitude === 'number' && typeof b.longitude === 'number' &&
typeof a.accuracy === 'number' && typeof b.accuracy === 'number') {
var va = cartesian(a);
var vb = cartesian(b);
var vd = {
x: va.x - vb.x,
y: va.y - vb.y,
z: va.z - vb.z
};
var tolerance = a.accuracy + b.accuracy + 50;
return vd.x * vd.x + vd.y * vd.y + vd.z * vd.z <= tolerance * tolerance;
}
//else return undefined
}
/*
runOrQueueBoundsUpdate: function() {
var self = this;
var delay = this.lastBoundsUpdate === undefined? 0 :
MIN_UPDATE_PERIOD - (Date.now() - this.lastBoundsUpdate);
console.debug(delay);
if (delay > 0) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve(self.runCurrentBoundsUpdate());
}, delay);
});
} else {
return this.runCurrentBoundsUpdate();
}
},
runCurrentBoundsUpdate: function() {
var self = this;
var deviceSetId = this.deviceSetId;
if (deviceSetId) {
console.debug('update');
this.lastBoundsUpdate = Date.now();
return this.sbw.pull(this.deviceSetKey(deviceSetId))
.then(function(data) {
var update = self.updateNeighborsBounds(
data.deviceSets[deviceSetId], self.localBounds);
delete self.currentBoundsUpdate;
return update;
});
} else {
delete this.currentBoundsUpdate;
return Promise.resolve();
}
},
updateNeighborsBounds: function(deviceSetData, bounds) {
var self = this;
var promises = [];
var lnEsc = SyncbaseWrapper.escapeKeyElement(this.localName);
var forwardsNeighbors = deviceSetData[lnEsc];
$.each(forwardsNeighbors, function(neighborName, adjustment) {
promises.push(self.updateNeighborBounds(
SyncbaseWrapper.unescapeKeyElement(neighborName),
bounds, marshalling.unmarshal(adjustment)));
});
$.each(deviceSetData, function(neighborName, neighborNeighbors) {
if (neighborName !== lnEsc) {
var invAdjustment = marshalling.unmarshal(neighborNeighbors[lnEsc]);
if (invAdjustment) {
var adjustment = {
x: -invAdjustment.x,
y: -invAdjustment.y
};
promises.push(self.updateNeighborBounds(
SyncbaseWrapper.unescapeKeyElement(neighborName),
bounds, adjustment));
}
}
});
return Promise.all(promises);
},
transform: function(bounds, adjustment) {
var sw = bounds.getSouthWest();
var ne = bounds.getNorthEast();
var span = bounds.toSpan();
var offset = new this.maps.LatLng(
adjustment.y * span.lat(),
adjustment.x * span.lng());
return new this.maps.LatLngBounds(
new this.maps.LatLng(sw.lat() + offset.lat(), sw.lng() + offset.lng()),
new this.maps.LatLng(ne.lat() + offset.lat(), ne.lng() + offset.lng()));
},
updateNeighborBounds: function(name, bounds, adjustment) {
var neighborBounds = this.transform(bounds, adjustment);
return this.updateRemoteBounds(name, neighborBounds);
},
handleMouseMove: function(e) {
}*/
},
events: {
onError: 'memory',
/**
* Triggered when devices are discovered (possibly) nearby, where none were
* present before.
*/
onPossibleNearbyDevices: ''
},
init: function(maps, identityPromise, deferredSyncbaseWrapper) {
var self = this;
this.maps = maps;
this.identityPromise = identityPromise;
this.sbw = deferredSyncbaseWrapper;
function heartbeatLoop() {
self.heartbeat();
setTimeout(heartbeatLoop, HEARTBEAT_PERIOD);
}
process.nextTick(heartbeatLoop);
if (global.navigator && global.navigator.geolocation) {
global.navigator.geolocation.watchPosition(this.updateGeolocation);
}
}
});
module.exports = DeviceSync;