| // 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; |