diff --git a/config.js b/config.js index 9b128c8..37969ed 100644 --- a/config.js +++ b/config.js @@ -62,9 +62,8 @@ module.exports = { "vflip": false, "shutter": null, "drc": null, - "mode": 1, + "mode": 0, "bitrate": null, - "width": 400, "height": 400, "framerate": 10, diff --git a/lib/camera/index.js b/lib/camera/index.js index 30c90c2..874dc7f 100644 --- a/lib/camera/index.js +++ b/lib/camera/index.js @@ -116,7 +116,7 @@ class Camera extends EventEmitter { } startRecording() { - var outputPath = '/home/pi/output.h264'; + var outputPath = 'output.h264'; var opts = this.config.record; if (!this.config.previewWhenRecording) { @@ -165,13 +165,21 @@ class Camera extends EventEmitter { reject(err); }); + var exited = false; recorder.on('exit', function (code) { if (code === 0) { resolve(); } else { reject(code); } + exited = true; }); + + setTimeout(function () { + if (!exited) { + recorder.kill(); + } + }, 5000); }); } } @@ -202,4 +210,3 @@ Camera.defaultOptions = { }; module.exports = Camera; - diff --git a/lib/camera/stream.js b/lib/camera/stream.js index 1164b99..ba81166 100644 --- a/lib/camera/stream.js +++ b/lib/camera/stream.js @@ -1,4 +1,5 @@ var child = require('child_process'); +var cmdExists = require('cmd-exists-sync'); var raspivid = function (options) { options = options || {}; @@ -29,48 +30,64 @@ var raspivid = function (options) { }); }; -// Doesn't support non-seekable streams with mp4 -var avconv = function () { +var avconv = function (options) { + options = options || {}; + var args = [ - '-i', - 'pipe:0', - '-f', - 'mp4', - '-loglevel', - 'error', // See http://blog.jungkyungsuk.com/tag/loglevel/ - 'pipe:1' + '-f', 'video4linux2', + '-r', '30000/1001', + '-i', '/dev/video0', + //'-b:a', '2M', // ? + '-bt', '4M', + '-vcodec', 'libx264', + //'-pass', '1', + '-profile:v', 'baseline', + '-coder', '0', + '-bf', '0', + '-flags', '-loop', '-an', + '-bsf:v', 'h264_mp4toannexb', + '-f', 'h264' ]; - return child.spawn('avconv', args, { - stdio: ['pipe', 'pipe', 'inherit'] - }); -}; + if (options.timeout) { + args.push('-t'); + args.push(options.timeout); + } -// Doesn't support mp4 streaming -var cvlc = function () { - var args = [ - '-I', - 'dummy', - '-v', - 'v4l2:///dev/video0', - '--v4l2-chroma', - 'h264', - '--v4l2-width', - '800', - '--v4l2-height', - '600', - '--sout', - //'#standard{access=file,mux=mp4,dst=-}', - '#standard{access=http,mux=ts,dst=0.0.0.0:3001}', // TODO - ':demux=h264' - ]; + if (options.width && options.height) { + args.push('-vf'); + args.push('scale='+options.width+':'+options.height); + } - // cvlc v4l2:///dev/video0 --v4l2-width 800 --v4l2-height 600 --v4l2-chroma h264 --sout '#standard{access=http,mux=ts,dst=0.0.0.0:12345}' -vvv + if (options.framerate) { + args.push('-r'); + args.push(options.framerate); + } + + if (options.bitrate) { + args.push('-b'); + args.push(options.bitrate); + } - return child.spawn('cvlc', args, { + if (options.output) { + args.push(options.output); + } else { + args.push('-'); + } + + console.log('avconv', args.join(' ')); + + return child.spawn('avconv', args, { stdio: ['pipe', 'pipe', 'inherit'] }); }; -module.exports = raspivid; - +if (cmdExists('raspivid')) { + module.exports = raspivid; +} else if (cmdExists('avconv')) { + module.exports = avconv; +} else { + module.exports = function () { + throw new Error('No recorder available'); + }; +} diff --git a/lib/controller.js b/lib/controller.js index 96e6586..6517053 100644 --- a/lib/controller.js +++ b/lib/controller.js @@ -5,14 +5,16 @@ var path = require('path'); var EventEmitter = require('events').EventEmitter; function loadUpdaters() { - var dir = 'controller'; - - var updaters = {}; - fs.readdirSync(__dirname+'/'+dir).forEach(function (file) { - var name = path.basename(file, '.js'); - updaters[name] = require('./'+dir+'/'+name); - }); - return updaters; + var dir = 'controller'; + + var updaters = {}; + fs.readdirSync(__dirname+'/'+dir).forEach(function (file) { + var name = path.basename(file, '.js'); + if (name[0] === '_') return; + + updaters[name] = require('./'+dir+'/'+name); + }); + return updaters; } var updaters = loadUpdaters(); diff --git a/lib/controller/base.js b/lib/controller/_base.js similarity index 100% rename from lib/controller/base.js rename to lib/controller/_base.js diff --git a/lib/controller/dummy.js b/lib/controller/dummy.js index 0343221..968b3cb 100644 --- a/lib/controller/dummy.js +++ b/lib/controller/dummy.js @@ -1,6 +1,6 @@ 'use strict'; -var BaseUpdater = require('./base'); +var BaseUpdater = require('./_base'); /** * A dummy updater. diff --git a/lib/controller/physics-quantif.js b/lib/controller/physics-quantif.js index 118d858..a0a49a0 100644 --- a/lib/controller/physics-quantif.js +++ b/lib/controller/physics-quantif.js @@ -1,6 +1,6 @@ 'use strict'; -var BaseUpdater = require('./base'); +var BaseUpdater = require('./_base'); /** * The physics updater. diff --git a/lib/controller/physics.js b/lib/controller/physics.js index 1c648ee..aff7b6f 100644 --- a/lib/controller/physics.js +++ b/lib/controller/physics.js @@ -1,6 +1,6 @@ 'use strict'; -var BaseUpdater = require('./base'); +var BaseUpdater = require('./_base'); /** * The physics updater. diff --git a/lib/controller/rate-simple.js b/lib/controller/rate-simple.js index 590f8bc..9d3593d 100644 --- a/lib/controller/rate-simple.js +++ b/lib/controller/rate-simple.js @@ -1,7 +1,7 @@ 'use strict'; var PidController = require('node-pid-controller'); -var BaseUpdater = require('./base'); +var BaseUpdater = require('./_base'); /** * A simple rate updater. diff --git a/lib/controller/stabilize-simple.js b/lib/controller/stabilize-simple.js index fea2b15..7e3cb97 100644 --- a/lib/controller/stabilize-simple.js +++ b/lib/controller/stabilize-simple.js @@ -1,7 +1,7 @@ 'use strict'; var PidController = require('node-pid-controller'); -var BaseUpdater = require('./base'); +var BaseUpdater = require('./_base'); var forEachPid = Symbol(); diff --git a/lib/quadcopter-base.js b/lib/quadcopter-base.js index dd0adcf..3801f27 100644 --- a/lib/quadcopter-base.js +++ b/lib/quadcopter-base.js @@ -1,6 +1,7 @@ 'use strict'; var EventEmitter = require('events').EventEmitter; +var extend = require('extend'); var Controller = require('./controller'); /** @@ -50,8 +51,8 @@ class QuadcopterBase extends EventEmitter { set config(config) { if (typeof config != 'object') throw new Error('Cannot set quadcopter "config" property: must be an object'); - this._config = config; - this.emit('config', config); + extend(this._config, config); + this.emit('config', this.config); } get power() { diff --git a/lib/quadcopter.js b/lib/quadcopter.js index f4645f8..f4f5dc5 100644 --- a/lib/quadcopter.js +++ b/lib/quadcopter.js @@ -36,13 +36,13 @@ class Quadcopter extends QuadcopterBase { that._resetMotorsSpeeds(true); }, function (err) { - console.error('WARN: cannot load motors', err); + console.error('WARN: cannot load motors:', err); }); // Then Inertial Measurement Unit console.log('Loading sensors...'); var promise = loadSensors(this).catch(function (err) { - console.error('WARN: cannot load sensors', err); + console.error('WARN: cannot load sensors:', err); }).then(function (sensors) { that.sensors = sensors || {}; if (that.sensors.mpu6050) { @@ -58,7 +58,7 @@ class Quadcopter extends QuadcopterBase { return that.camera.check().then(function () { that.features.push('camera'); }, function (err) { - console.error('WARN: camera not available', err); + console.error('WARN: camera not available:', err); }); }).then(function () { // Ready :-) diff --git a/package.json b/package.json index fd5802a..5868676 100644 --- a/package.json +++ b/package.json @@ -20,22 +20,27 @@ "dependencies": { "bootstrap": "^3.3.6", "broadway-player": "^0.1.1", + "browser-request": "^0.3.3", "c3": "^0.4.11-rc4", + "cmd-exists-sync": "^0.1.0", "events": "^1.0.2", + "expand-flatten": "^1.0.0", "express": "^4.12.3", "express-browserify-lite": "^0.1.2", "express-ws": "^0.2.6", "extend": "^2.0.1", + "global": "^4.3.0", "i2c-bus": "^0.11.1", "i2c-mpu6050": "^2.2.0", - "jquery": "^2.2.0", + "mercury": "^14.1.0", "mpu6050-dmp": "^0.0.6", "msgpack5": "^3.3.0", "node-pid-controller": "^0.1.2", + "round-to": "^1.1.0", "servoblaster": "^0.1.0", "smoothie": "^1.27.0", "stream-split": "^0.2.0", - "svg-injector": "^1.1.3", + "throttleit": "^1.0.0", "through": "^2.3.8", "util": "^0.10.3" } diff --git a/public/assets/main.css b/public/assets/main.css index 090371c..587187b 100644 --- a/public/assets/main.css +++ b/public/assets/main.css @@ -76,6 +76,9 @@ template { box-shadow: none; border-bottom-color: rgba(0, 100, 200, 0.3); } +.form-control:invalid { + border-bottom-color: rgba(255, 0, 0, 0.2); +} input[type="number"].form-control { -moz-appearance: textfield; } @@ -83,6 +86,9 @@ input[type="number"].form-control { .form-inline input[type="number"].form-control { width: 65px; } +.form-inline input[type="number"][size="1"].form-control { + width: 35px; +} #console { height: 200px; @@ -98,7 +104,7 @@ input[type="number"].form-control { color: rgb(0, 100, 0); } -.joystick { +.direction-mouse { position: relative; width: 300px; height: 300px; @@ -106,7 +112,7 @@ input[type="number"].form-control { border-radius: 50%; margin: 10px auto; } -.joystick .handle { +.direction-mouse .handle { position: absolute; width: 16px; height: 16px; @@ -115,7 +121,17 @@ input[type="number"].form-control { opacity: 0.2; pointer-events: none; } -.joystick.active .handle { +.direction-mouse .handle .tooltip { + width: 100px; + top: -35px; + left: -42px; +} +.direction-mouse:active .handle, +.direction-mouse.active .handle { + opacity: 1; +} +.direction-mouse:active .handle .tooltip, +.direction-mouse.active .handle .tooltip { opacity: 1; } @@ -150,10 +166,8 @@ input[type="range"][orient="vertical"]::-moz-range-track { width: 5px; } -#power-input { - width: 100px; +.power-input { height: 300px; - margin: auto; } select { @@ -242,21 +256,24 @@ select { border-color: rgba(0, 100, 200, 0.3); } -.quadcopter-schema { +.quadcopter-outline { + margin: auto; +} +.quadcopter-outline svg { fill: rgb(58, 58, 58); transition: 0.2s transform; } -.quadcopter-schema g { +.quadcopter-outline g { transition: 0.2s fill; } -#quadcopter-top { +#quadcopter-outline-top { width: 300px; height: 300px; } -.quadcopter-side { +.quadcopter-outline-side { width: 300px; height: 75px; - margin: 37px 0; + margin-top: 37px; } #camera-video canvas { diff --git a/public/index.html b/public/index.html index a7a1460..02b61a5 100644 --- a/public/index.html +++ b/public/index.html @@ -8,502 +8,13 @@ - + + - - -
-

-	
- -
- -
-
-
-
-
- - -
- -
- -
- - -
-
-
-

- Load average:
- Memory: -

-
-
-
- -
- -
-
-
-
- Enable sensors
-
- - -
-
-
- -
- Power - -
-
- Rotation - -
-
- -
-
-

- Motors speed (x10µs):
- -

-
-
-

- Motors forces (N):
- -

-
-
-
-
- - -

- Gyro (°/s):
- Accel (g):
- Rotation (°):
- Temperature (°C): -

- - -
- - -
- - -
- -
- Gyro - -
-
- Accel - -
-
- Rotation - -
-
- Motors speed - -
-
- Graphs settings -
- - -
-
-
-
- -
-
- -
-
-
- - -
-
-
-
-
-

-
-
- - - -
- - - - -
-
-
-
-
- -
- -
-
-
-
-
- -
- , , , -
-
- -
- -
- - (x10µs) -
-
- -
- -
- OS status: s
- Orientation: s -
-
- -
- -
-
- Motor mass: g -
-
- Structure mass: g -
-
- Diagonal length: cm -
-
-
- -
- -
-
- - -
- -
-
-
-
-
- -
- ms -
-
- -
- -
-
- Rate: , , -
- -
- Stabilize: , , -
-
-
-
-
- -
-
-
-
- - -
-
-
-
-
-
- -
- - diff --git a/public/js/camera-preview.js b/public/js/camera-preview.js index 9278464..e173115 100644 --- a/public/js/camera-preview.js +++ b/public/js/camera-preview.js @@ -78,7 +78,7 @@ CameraPreview.prototype.start = function () { this._initPlayer(); this._startWebsocket(function () { - that.emit('start'); + that.emit('start', that.player.canvas); }); this._cmd.send('camera-preview', true); @@ -91,6 +91,8 @@ CameraPreview.prototype.stop = function () { //this.player.worker.terminate(); this._cmd.send('camera-preview', false); + + this.emit('stop'); }; CameraPreview.prototype.restart = function () { diff --git a/public/js/chart-recorder.js b/public/js/chart-recorder.js new file mode 100644 index 0000000..1e2f914 --- /dev/null +++ b/public/js/chart-recorder.js @@ -0,0 +1,89 @@ +'use strict'; + +var flatten = require('expand-flatten').flatten; +var exportFile = require('./export'); + +module.exports = function (quad) { + var isRecording = false; + var startedAt = 0; + var header = ['time']; + var lines = []; + + function createAppender(prefix) { + var isFirst = true; + + function newline() { + var line = new Array(header.length); + line[0] = (Date.now() - startedAt) / 1000; + lines.push(line); + return line; + } + + return function append(data) { + if (prefix) { + data = { [prefix]: data }; + } + + data = flatten(data); + + if (isFirst) { + isFirst = false; + header = header.concat(Object.keys(data)); + } + + var line = lines[lines.length - 1] || newline(); + for (var i = 0; i < header.length; i++) { + var key = header[i]; + + if (typeof data[key] === 'undefined') { + continue; + } + + if (typeof line[i] !== 'undefined') { + line = newline(); + } + + line[i] = data[key]; + } + }; + } + + var appendOrientation = createAppender(); + var appendMotorsSpeed = createAppender('motorsSpeed'); + var appendCmd = createAppender('cmd'); + + return { + start: function () { + if (isRecording) return; + + this.reset(); + startedAt = Date.now(); + + quad.on('orientation', appendOrientation); + quad.on('motors-speed', appendMotorsSpeed); + quad.cmd.on('orientation', appendCmd); + }, + stop: function () { + if (!isRecording) return; + + quad.removeListener('orientation', appendOrientation); + quad.removeListener('motors-speed', appendMotorsSpeed); + quad.cmd.removeListener('orientation', appendCmd); + }, + reset: function () { + lines = []; + }, + export: function () { + var csv = header.join(',') + '\n'; + + for (var i = 0; i < lines.length; i++) { + csv += lines[i].map(function (val) { + if (typeof val === 'undefined') return ''; + return val; + }).join(',') + '\n'; + } + + return exportFile({ body: csv, type: 'text/csv' }); + } + }; +}; diff --git a/public/js/client.js b/public/js/client.js index 6e6f998..3761617 100644 --- a/public/js/client.js +++ b/public/js/client.js @@ -15,15 +15,18 @@ Client.prototype.connect = function (cb) { if (!cb) cb = function () {}; var that = this; + this.emit('connecting'); + var ws = new WebSocket('ws://'+window.location.host+'/socket'); ws.binaryType = 'arraybuffer'; ws.addEventListener('open', function () { cb(null); - that.emit('connect'); + that.emit('connected'); }); ws.addEventListener('error', function (event) { + var error = 'Websocket error'; cb(error); that.emit('error', error); }); diff --git a/public/js/component/canvas.js b/public/js/component/canvas.js new file mode 100644 index 0000000..10223b8 --- /dev/null +++ b/public/js/component/canvas.js @@ -0,0 +1,23 @@ +'use strict'; + +var document = require('global/document'); +var hg = require('mercury'); +var h = require('mercury').h; + +function Canvas(canvas) { + if (!(this instanceof Canvas)) { + return new Canvas(canvas); + } + + this.canvas = canvas; +} + +Canvas.prototype.type = 'Widget'; + +Canvas.prototype.init = function () { + return this.canvas; +}; + +Canvas.prototype.update = function (prev, elem) {}; + +module.exports = Canvas; diff --git a/public/js/component/form-group.js b/public/js/component/form-group.js new file mode 100644 index 0000000..8e75395 --- /dev/null +++ b/public/js/component/form-group.js @@ -0,0 +1,10 @@ +'use strict'; + +var h = require('mercury').h; + +module.exports = function (label, inputs) { + return h('.form-group', [ + h('label.col-md-2.control-label', label), + h('.col-md-10.form-inline', inputs) + ]); +}; diff --git a/public/js/component/inline-svg.js b/public/js/component/inline-svg.js new file mode 100644 index 0000000..5e5eb98 --- /dev/null +++ b/public/js/component/inline-svg.js @@ -0,0 +1,43 @@ +'use strict'; + +var document = require('global/document'); +var request = require('browser-request'); + +function InlineSvg(url) { + if (!(this instanceof InlineSvg)) { + return new InlineSvg(url); + } + + this.url = url; +} + +InlineSvg.prototype.type = 'Widget'; + +InlineSvg.prototype.init = function () { + var self = this; + + var tmp = document.createElement('svg'); + this.element = tmp; + + request(this.url, function (err, res, body) { + if (err) { + return; // TODO + } + + var parser = new DOMParser(); + var doc = parser.parseFromString(body, 'application/xml'); + var svg = doc.documentElement; + self.element = svg; + + svg.removeAttribute('width'); + svg.removeAttribute('height'); + + tmp.parentNode.replaceChild(svg, tmp); + }); + + return tmp; +}; + +InlineSvg.prototype.update = function () {}; + +module.exports = InlineSvg; diff --git a/public/js/component/streaming-chart.js b/public/js/component/streaming-chart.js new file mode 100644 index 0000000..d687b4b --- /dev/null +++ b/public/js/component/streaming-chart.js @@ -0,0 +1,37 @@ +'use strict'; + +var document = require('global/document'); +var hg = require('mercury'); +var h = require('mercury').h; +var smoothie = require('smoothie'); + +var chartStyle = { + grid: { + fillStyle: 'transparent', + borderVisible: false + } +}; + +function StreamingChart() { + if (!(this instanceof StreamingChart)) { + return new StreamingChart(); + } + + this.chart = new smoothie.SmoothieChart(chartStyle); +} + +StreamingChart.prototype.type = 'Widget'; + +StreamingChart.prototype.init = function () { + var canvas = document.createElement('canvas'); + canvas.width = 400; + canvas.height = 100; + + this.chart.streamTo(canvas); + + return canvas; +}; + +StreamingChart.prototype.update = function (prev, elem) {}; + +module.exports = StreamingChart; diff --git a/public/js/component/switch.js b/public/js/component/switch.js new file mode 100644 index 0000000..419bf68 --- /dev/null +++ b/public/js/component/switch.js @@ -0,0 +1,40 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; + +var nextId = 0; + +function Switch(id) { + if (!id) { + id = 'switch-' + (nextId++); + } + + return hg.state({ + id: id, + value: hg.value(false), + disabled: hg.value(false), + channels: { + change: change + } + }); +} + +function change(state, data) { + state.value.set(data.value); +} + +Switch.render = function (state) { + return h('.switch', [ + h('input', { + type: 'checkbox', + id: state.id, + name: 'value', + disabled: state.disabled, + 'ev-event': hg.sendChange(state.channels.change) + }), + h('label', { htmlFor: state.id }) + ]); +}; + +module.exports = Switch; diff --git a/public/js/component/tabs.js b/public/js/component/tabs.js new file mode 100644 index 0000000..bd33e83 --- /dev/null +++ b/public/js/component/tabs.js @@ -0,0 +1,41 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; + +function Tabs(tabs, active) { + var state = hg.state({ + tabs: hg.array(tabs || []), + active: hg.value(active || 0), + channels: { + select: select + } + }); + + return state; +} + +function select(state, data) { + state.active.set(data.item); +} + +Tabs.render = function (state) { + return h('ul.nav.nav-tabs.nav-justified', state.tabs.map(function (title, i) { + return h('li' + ((i == state.active) ? '.active' : ''), [ + h('a', { + href: '#', + 'ev-click': hg.sendClick(state.channels.select, { item: i }, { preventDefault: true }) + }, title) + ]); + })); +}; + +Tabs.renderContainer = function (state, name, children) { + var visible = (state.active === name || state.tabs[state.active] === name); + + return h('div', { + style: { display: (visible) ? 'block' : 'none' } + }, children); +}; + +module.exports = Tabs; diff --git a/public/js/direction-sender.js b/public/js/direction-sender.js new file mode 100644 index 0000000..3506ec5 --- /dev/null +++ b/public/js/direction-sender.js @@ -0,0 +1,27 @@ +var throttle = require('throttleit'); + +module.exports = function (cmd) { + return function (state) { + if (!state.x || !state.y) { + throw new Error('Provided state is missing values x, y'); + } + + function update() { + cmd.send('orientation', { + x: state.x(), + y: state.y(), + z: (state.z) ? state.z() : 0 + }); + } + + var throttled = throttle(update, 100); + + state.x(throttled); + state.y(throttled); + if (state.z) { + state.z(throttled); + } + + // TODO: power + }; +}; diff --git a/public/js/direction/device-orientation.js b/public/js/direction/device-orientation.js new file mode 100644 index 0000000..3bd7beb --- /dev/null +++ b/public/js/direction/device-orientation.js @@ -0,0 +1,38 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var window = require('global/window'); +var Switch = require('../component/switch'); + +function DeviceOrientationDirection() { + var state = hg.state({ + switch: Switch('device-orientation-switch'), + x: hg.value(0), + y: hg.value(0), + z: hg.value(0) + }); + + state.switch.disabled.set(typeof window.DeviceOrientationEvent === 'undefined'); + + window.addEventListener('deviceorientation', function (event) { + if (!state.switch.value()) return; + + state.x.set(event.beta / 180); // front-to-back tilt in degrees, where front is positive + state.y.set(event.gamma / 180); // left-to-right tilt in degrees, where right is positive + state.z.set(event.alpha / 180); + }); + + return state; +} + +DeviceOrientationDirection.render = function (state) { + return h('.text-center', [ + Switch.render(state.switch), + h('label', { + htmlFor: state.switch.id + }, 'Use sensors to control orientation') + ]); +}; + +module.exports = DeviceOrientationDirection; diff --git a/public/js/direction/gamepad.js b/public/js/direction/gamepad.js new file mode 100644 index 0000000..2817bd4 --- /dev/null +++ b/public/js/direction/gamepad.js @@ -0,0 +1,63 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var window = require('global/window'); +var extend = require('extend'); + +function GamepadDirection() { + var state = hg.state({ + enabled: hg.value(false), + x: hg.value(0), + y: hg.value(0), + z: hg.value(0), + power: hg.value(0) + }); + + //state.enabled.set(!!navigator.getGamepads || !!navigator.webkitGetGamepads); + + window.addEventListener('gamepadconnected', function (event) { + console.log('Gamepad connected at index %d: %s. %d buttons, %d axes.', + event.gamepad.index, event.gamepad.id, + event.gamepad.buttons.length, event.gamepad.axes.length); + + state.enabled.set(true); + + connected(state, event.gamepad); + }); + + window.addEventListener('gamepaddisconnected', function (event) { + state.enabled.set(false); + }); + + return state; +} + +function connected(state, gamepad) { + var calibration = extend([], this.gamepad.axes); + + var interval = setInterval(function () { + if (!that.gamepad.connected) { + return clearInterval(interval); + } + + var axes = that.gamepad.axes.map(function (value, i) { + if (calibration && calibration[i]) { + return value - calibration[i]; + } + return value; + }); + + state.x.set('x', axes[0]); + state.y.set('y', axes[1]); + state.z.set('z', axes[2]); + + state.power.set(-axes[3]); + }, 200); +} + +GamepadDirection.render = function (state) { + return h('p.text-center', (state.enabled) ? 'Gamepad connected' : 'Connect a gamepad'); +}; + +module.exports = GamepadDirection; diff --git a/public/js/direction/mouse.js b/public/js/direction/mouse.js new file mode 100644 index 0000000..eb21858 --- /dev/null +++ b/public/js/direction/mouse.js @@ -0,0 +1,101 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var roundTo = require('round-to'); +var sendDrag = require('../event/drag'); +var AttributeHook = require('../hook/attribute'); + +var WIDTH = 300, HEIGHT = 300; +var HANDLE_WIDTH = 16, HANDLE_HEIGHT = 16; + +var center = { + x: WIDTH/2 - HANDLE_WIDTH, + y: HEIGHT/2 - HANDLE_HEIGHT +}; + +function MouseDirection() { + return hg.state({ + x: hg.value(0), + y: hg.value(0), + z: hg.value(0), + range: hg.value(90), + channels: { + moveHandle: moveHandle, + changeRange: changeRange, + changeRotation: changeRotation + } + }); +} + +function moveHandle(state, data) { + var x = 0, y = 0; + if (data.down) { + x = (data.x - WIDTH/2) / (WIDTH/2); + y = (data.y - HEIGHT/2) / (HEIGHT/2); + } + + var dist = Math.sqrt(x*x + y*y); + if (dist > 1) { + return; + } + + var range = state.range(); + state.x.set(x * range); + state.y.set(y * range); +} + +function changeRange(state, data) { + state.range.set(parseFloat(data.range)); +} + +function changeRotation(state, data) { + state.z.set(parseFloat(data.rotation)); +} + +MouseDirection.render = function (state) { + var x = center.x + state.x/state.range*WIDTH/2; + var y = center.y + state.y/state.range*HEIGHT/2; + + return h('.row', [ + h('.col-sm-9', [ + h('.direction-mouse', { + 'ev-mousedown': sendDrag(state.channels.moveHandle) + }, [ + h('.handle' + ((state.x !== 0 || state.y !== 0) ? '.active' : ''), { + style: { left: x + 'px', top: y + 'px' } + }, [ + h('.tooltip.top', [ + h('.tooltip-arrow'), + h('.tooltip-inner', roundTo(state.x, 2) + ', ' + roundTo(state.y, 2)) + ]) + ]) + ]), + h('.form-inline', [ + h('label.control-label', { htmlFor: 'direction-mouse-range-input' }, 'Range:'), + h('input#direction-mouse-range-input.form-control', { + type: 'number', + name: 'range', + 'ev-event': hg.sendChange(state.channels.changeRange), + value: state.range + }), + ' °' + ]) + ]), + h('.col-sm-3', [ + h('span', 'Rotation'), + h('input', { + type: 'range', + name: 'rotation', + value: state.z, + min: -90, + max: 90, + step: 5, + orient: AttributeHook('vertical'), + 'ev-event': hg.sendChange(state.channels.changeRotation) + }) + ]) + ]); +}; + +module.exports = MouseDirection; diff --git a/public/js/direction/ramp.js b/public/js/direction/ramp.js new file mode 100644 index 0000000..4445e36 --- /dev/null +++ b/public/js/direction/ramp.js @@ -0,0 +1,90 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; + +function RampDirection() { + return hg.state({ + x: hg.value(0), + y: hg.value(0), + z: hg.value(0), + + enabled: hg.value(false), + axis: hg.value('x'), + slope: hg.value(0), + max: hg.value(0), + + channels: { + start: start, + stop: stop + } + }); +} + +function start(state, data) { + if (state.enabled()) { + stop(state); + } + + state.enabled.set(true); + + state.axis.set(data.axis); + state.slope.set(parseFloat(data.slope) || 0); + state.max.set(parseFloat(data.max) || 0); + + var axis = state.axis(), + slope = state.slope(), + max = state.max(); + + // Reset axis + state[axis].set(0); + + var startedAt = Date.now(); + var interval = setInterval(function () { + var t = (Date.now() - startedAt) / 1000; + var val = t * slope; + + if (val > max) { + val = max; + } + + state[axis].set(val); + + if (val === max) { + stop(state); + } + }, 200); + + var removeListener = state.enabled(function (val) { + if (val) return; + + clearInterval(interval); + removeListener(); + }); +} + +function stop(state) { + state.enabled.set(false); +} + +RampDirection.render = function (state) { + return h('form.form-inline', { 'ev-submit': hg.sendSubmit(state.channels.start) }, [ + h('div', [ + 'Axis: ', + h('select.form-control', { name: 'axis' }, ['x', 'y', 'z'].map(function (axis) { + return h('option', { selected: (state.axis === axis) }, axis); + })) + ]), + h('div', ['Slope: ', h('input.form-control', { type: 'number', name: 'slope', value: state.slope, step: 'any' }), ' °/s']), + h('div', ['Max: ', h('input.form-control', { type: 'number', name: 'max', value: state.max, step: 'any' }), ' °']), + h('br'), + h('.btn-group', [ + h('button.btn.btn-primary', { type: 'submit' }, 'Start'), + h('button.btn.btn-danger', { type: 'button', 'ev-click': hg.sendClick(state.channels.stop) }, 'Stop') + ]), + ' ', + h('button.btn.btn-default', { type: 'reset' }, 'Reset') + ]); +}; + +module.exports = RampDirection; diff --git a/public/js/direction/sine.js b/public/js/direction/sine.js new file mode 100644 index 0000000..85ceb78 --- /dev/null +++ b/public/js/direction/sine.js @@ -0,0 +1,85 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; + +function SineDirection() { + return hg.state({ + x: hg.value(0), + y: hg.value(0), + z: hg.value(0), + + enabled: hg.value(false), + axis: hg.value('x'), + amplitude: hg.value(0), + frequency: hg.value(0), + offset: hg.value(0), + + channels: { + start: start, + stop: stop + } + }); +} + +function start(state, data) { + if (state.enabled()) { + stop(state); + } + + state.enabled.set(true); + + state.axis.set(data.axis); + state.amplitude.set(parseFloat(data.amplitude) || 0); + state.frequency.set(parseFloat(data.frequency) || 0); + state.offset.set(parseFloat(data.offset) || 0); + + var axis = state.axis(), + A = state.amplitude(), + f = state.frequency(), + phi = state.offset(); + + // Reset axis + state[axis].set(0); + + var startedAt = Date.now(); + var interval = setInterval(function () { + var t = (Date.now() - startedAt) / 1000; + + state[axis].set(A * Math.sin(2 * Math.PI * f * t + phi)); + }, 200); + + var removeListener = state.enabled(function (val) { + if (val) return; + + clearInterval(interval); + removeListener(); + }); +} + +function stop(state) { + state.enabled.set(false); +} + +SineDirection.render = function (state) { + return h('form.form-inline', { 'ev-submit': hg.sendSubmit(state.channels.start) }, [ + h('div', [ + 'Axis: ', + h('select.form-control', { name: 'axis' }, ['x', 'y', 'z'].map(function (axis) { + return h('option', { selected: (state.axis === axis) }, axis); + })) + ]), + h('div', ['Amplitude: ', h('input.form-control', { type: 'number', name: 'amplitude', value: state.amplitude, step: 'any' }), ' °']), + h('div', ['Frequency: ', h('input.form-control', { type: 'number', name: 'frequency', value: state.frequency, step: 'any' }), ' Hz']), + h('div', ['Offset: ', h('input.form-control', { type: 'number', name: 'offset', value: state.offset, step: 'any' }), ' rad']), + h('br'), + h('.btn-group', [ + h('button.btn.btn-primary', { type: 'submit' }, 'Start'), + h('button.btn.btn-danger', { type: 'button', 'ev-click': hg.sendClick(state.channels.stop) }, 'Stop') + ]), + ' ', + h('button.btn.btn-default', { type: 'reset' }, 'Reset') + ]); +}; + +module.exports = SineDirection; diff --git a/public/js/direction/step.js b/public/js/direction/step.js new file mode 100644 index 0000000..6a9b7e0 --- /dev/null +++ b/public/js/direction/step.js @@ -0,0 +1,35 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; + +function StepDirection() { + return hg.state({ + x: hg.value(0), + y: hg.value(0), + z: hg.value(0), + channels: { + send: send + } + }); +} + +function send(state, data) { + state.x.set(parseFloat(data.x) || 0); + state.y.set(parseFloat(data.y) || 0); + state.z.set(parseFloat(data.z) || 0); +} + +StepDirection.render = function (state) { + return h('form.form-inline', { 'ev-submit': hg.sendSubmit(state.channels.send) }, [ + h('div', ['x: ', h('input.form-control', { type: 'number', name: 'x', value: state.x, step: 'any' }), ' °']), + h('div', ['y: ', h('input.form-control', { type: 'number', name: 'y', value: state.y, step: 'any' }), ' °']), + h('div', ['z: ', h('input.form-control', { type: 'number', name: 'z', value: state.z, step: 'any' }), ' °']), + h('br'), + h('button.btn.btn-primary', { type: 'submit' }, 'Send'), + ' ', + h('button.btn.btn-default', { type: 'reset' }, 'Reset') + ]); +}; + +module.exports = StepDirection; diff --git a/public/js/event/drag.js b/public/js/event/drag.js new file mode 100644 index 0000000..67ede95 --- /dev/null +++ b/public/js/event/drag.js @@ -0,0 +1,35 @@ +'use strict'; + +var hg = require('mercury'); +var extend = require('extend'); + +module.exports = hg.BaseEvent(function (ev, broadcast) { + var data = this.data; + var delegator = hg.Delegator(); + + var offset = ev.target.getBoundingClientRect(); + + function onmove(ev) { + broadcast(extend(data, { + down: true, + x: ev.clientX - offset.left, + y: ev.clientY - offset.top + })); + } + + function onup(ev) { + delegator.unlistenTo('mousemove'); + delegator.removeGlobalEventListener('mousemove', onmove); + delegator.removeGlobalEventListener('mouseup', onup); + + broadcast(extend(data, { + down: false + })); + } + + onmove(ev); + + delegator.listenTo('mousemove'); + delegator.addGlobalEventListener('mousemove', onmove); + delegator.addGlobalEventListener('mouseup', onup); +}); diff --git a/public/js/export.js b/public/js/export.js new file mode 100644 index 0000000..f99bc88 --- /dev/null +++ b/public/js/export.js @@ -0,0 +1,7 @@ +var window = require('global/window'); + +module.exports = function (opts) { + var blob = new Blob([opts.body], { type: opts.type }); + var url = URL.createObjectURL(blob); + window.open(url); +}; diff --git a/public/js/graphs-export.js b/public/js/graphs-export.js deleted file mode 100644 index 92122bc..0000000 --- a/public/js/graphs-export.js +++ /dev/null @@ -1,71 +0,0 @@ -var header, data; -var isRecording = false; - -var graphsExport = { - start: function () { - isRecording = true; - this._startTime = new Date().getTime(); - this.reset(); - }, - stop: function () { - isRecording = false; - }, - reset: function () { - header = ['timestamp']; - data = []; - }, - isRecording: function () { - return isRecording; - }, - append: function (name, timestamp, dataset) { - if (!isRecording) return; - - var lastline = data[data.length - 1]; - - var line = new Array(header.length); - line[0] = (timestamp - this._startTime)/1000; - - var hasPushedNewData = false; - for (var key in dataset) { - var value = dataset[key]; - if (typeof value == 'undefined') continue; - var headerKey = name+'.'+key; - var i = header.indexOf(headerKey); - if (i >= 0) { - if (lastline && !hasPushedNewData && typeof lastline[i] != 'number') { - lastline[i] = value; - } else { - line[i] = value; - hasPushedNewData = true; - } - } else { - header.push(headerKey); - if (lastline) { - lastline.push(value); - } else { - line.push(value); - hasPushedNewData = true; - } - } - } - - if (hasPushedNewData) { - data.push(line); - } - }, - toCsv: function () { - if (!data || !header) return; - - var csv = ''; - csv += header.join(','); - - for (var i = 0; i < data.length; i++) { - var line = data[i].join(','); - csv += '\n'+line; - } - - return csv; - } -}; - -module.exports = graphsExport; diff --git a/public/js/hook/attribute.js b/public/js/hook/attribute.js new file mode 100644 index 0000000..ed2c445 --- /dev/null +++ b/public/js/hook/attribute.js @@ -0,0 +1,15 @@ +'use strict'; + +function AttributeHook(value) { + if (!(this instanceof AttributeHook)) { + return new AttributeHook(value); + } + + this.value = value +} + +AttributeHook.prototype.hook = function (elem, prop) { + elem.setAttribute(prop, this.value) +} + +module.exports = AttributeHook; diff --git a/public/js/index.js b/public/js/index.js index bd72755..ae339b5 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -1,206 +1,158 @@ -var $ = require('jquery'); -var SVGInjector = require('svg-injector'); -var smoothie = require('smoothie'); -var colors = require('./colors'); -var keyBindings = require('./key-bindings'); -var graphsExport = require('./graphs-export'); -var CameraPreview = require('./camera-preview'); -var QuadcopterSchema = require('./quad-schema'); -var Quadcopter = require('./quadcopter'); - -require('./jquery/serialize-object')($); -require('./jquery/tabs')($); - -var input = { - Mouse: require('./input/mouse'), - DeviceOrientation: require('./input/device-orientation'), - Gamepad: require('./input/gamepad'), - Step: require('./input/step'), - Sine: require('./input/sine'), - Ramp: require('./input/ramp') -}; - -(function () { - var graphs = {}; - - var graphNames = [ - 'gyro_x', 'gyro_y', 'gyro_z', - 'accel_x', 'accel_y', 'accel_z', - 'rotation_x', 'rotation_y', 'rotation_z', - 'motors_speed_0', 'motors_speed_1', 'motors_speed_2', 'motors_speed_3' - ]; - for (var i = 0; i < graphNames.length; i++) { - var name = graphNames[i]; - graphs[name] = new smoothie.TimeSeries(); - } - - window.graphs = graphs; -})(); - -$(function () { - var chartStyle = { - grid: { - fillStyle: 'transparent', - borderVisible: false - } - }; - var redLine = { strokeStyle: 'rgb(255, 0, 0)' }; - var greenLine = { strokeStyle: 'rgb(0, 255, 0)' }; - var blueLine = { strokeStyle: 'rgb(0, 0, 255)' }; - var yellowLine = { strokeStyle: 'yellow' }; - - var gyro = new smoothie.SmoothieChart(chartStyle); - gyro.streamTo(document.getElementById('sensor-gyro-graph')); - gyro.addTimeSeries(graphs.gyro_x, redLine); - gyro.addTimeSeries(graphs.gyro_y, greenLine); - gyro.addTimeSeries(graphs.gyro_z, blueLine); - - var accel = new smoothie.SmoothieChart(chartStyle); - accel.streamTo(document.getElementById('sensor-accel-graph')); - accel.addTimeSeries(graphs.accel_x, redLine); - accel.addTimeSeries(graphs.accel_y, greenLine); - accel.addTimeSeries(graphs.accel_z, blueLine); - - var rotation = new smoothie.SmoothieChart(chartStyle); - rotation.streamTo(document.getElementById('sensor-rotation-graph')); - rotation.addTimeSeries(graphs.rotation_x, redLine); - rotation.addTimeSeries(graphs.rotation_y, greenLine); - rotation.addTimeSeries(graphs.rotation_z, blueLine); - - var motorsSpeed = new smoothie.SmoothieChart(chartStyle); - motorsSpeed.streamTo(document.getElementById('motors-speed-graph')); - motorsSpeed.addTimeSeries(graphs.motors_speed_0, redLine); - motorsSpeed.addTimeSeries(graphs.motors_speed_1, greenLine); - motorsSpeed.addTimeSeries(graphs.motors_speed_2, blueLine); - motorsSpeed.addTimeSeries(graphs.motors_speed_3, yellowLine); -}); - -function init(quad) { - keyBindings(quad); - - var mouseInput = new input.Mouse(quad.cmd, '#direction-input'); - - if (input.Gamepad.isSupported()) { - var devOrientation = new input.Gamepad(quad.cmd); - - $('#orientation-switch').change(function () { - if ($(this).prop('checked')) { - devOrientation.start(); - } else { - devOrientation.stop(); - } - }); - } - if (input.Gamepad.isSupported()) { - var gamepadInput = new input.Gamepad(quad.cmd); - } +'use strict'; - var lastPower; - $('#power-input').on('input', function () { - var val = parseFloat($(this).val()); - if (lastPower === val) { - return; - } - - lastPower = val; - sendCommand('power', val / 100); - }); - quad.cmd.on('power', function (val) { - if (lastPower / 100 === val) return; - $('#power-input').val(Math.round(val * 100)); - }); - - $('#power-switch').change(function () { - sendCommand('enable', $(this).prop('checked')); - }); - - $('#controller-btn').change(function () { - quad.config.controller.updater = $(this).val(); - sendCommand('config', quad.config); - }); - - $('#direction-type-tabs').tabs(); +var document = require('global/document'); +var hg = require('mercury'); +var h = require('mercury').h; - var stepInput = new input.Step(quad.cmd); - $('#direction-step').submit(function (event) { - event.preventDefault(); +var Quadcopter = require('./quadcopter'); +var Tabs = require('./component/tabs'); + +var Console = require('./widget/console'); +var Alerts = require('./widget/alerts'); +var EnableBtn = require('./widget/enable-btn'); +var ControllerBtn = require('./widget/controller-btn'); +var SystemSummary = require('./widget/system-summary'); +var Charts = require('./widget/charts'); +var PowerInput = require('./widget/power-input'); +var Outline = require('./widget/outline'); +var MotorsSummary = require('./widget/motors-summary'); +var OrientationSummary = require('./widget/orientation-summary'); +var CalibrateBtn = require('./widget/calibrate-btn'); +var ChartRecorder = require('./widget/chart-recorder'); +var Camera = require('./widget/camera'); +var Config = require('./widget/config'); + +var DirectionSender = require('./direction-sender'); +var MouseDirection = require('./direction/mouse'); +var DeviceOrientationDirection = require('./direction/device-orientation'); +var GamepadDirection = require('./direction/gamepad'); +var StepDirection = require('./direction/step'); +var SineDirection = require('./direction/sine'); +var RampDirection = require('./direction/ramp'); + +function App() { + var quad = new Quadcopter(); - var data = $(this).serializeObject(); + var state = hg.state({ + console: Console(quad), + alerts: Alerts(quad), + enableBtn: EnableBtn(quad), + controllerBtn: ControllerBtn(quad), + systemSummary: SystemSummary(quad), + directionTabs: Tabs(['mouse', 'device', 'step', 'sine', 'ramp']), + direction: hg.struct({ + mouse: MouseDirection(), + deviceOrientation: DeviceOrientationDirection(), + gamepad: GamepadDirection(), + step: StepDirection(), + sine: SineDirection(), + ramp: RampDirection() + }), + powerInput: PowerInput(quad), + outline: hg.struct({ + top: Outline.Top(quad), + front: Outline.Front(quad), + right: Outline.Right(quad) + }), + motorsSummary: MotorsSummary(quad), + orientationSummary: OrientationSummary(quad), + calibrateBtn: CalibrateBtn(quad), + chartRecorder: ChartRecorder(quad), + charts: Charts(quad), + camera: Camera(quad), + cameraAvailable: hg.value(false), + config: Config(quad) + }); + + quad.init(); - stepInput.start({ - x: parseFloat(data.x), - y: parseFloat(data.y), - z: parseFloat(data.z), - duration: parseFloat(data.duration) * 1000 - }); + quad.on('features', function (features) { + state.cameraAvailable.set(features.hardware.indexOf('camera') !== -1); }); - var sineInput = new input.Sine(quad.cmd); - $('#direction-sine').submit(function (event) { - event.preventDefault(); - - var data = $(this).serializeObject(); - - sineInput.start({ - amplitude: parseFloat(data.amplitude), - frequency: parseFloat(data.frequency), - offset: parseFloat(data.offset), - axis: data.axis - }); + var sender = DirectionSender(quad.cmd); + ['mouse', 'deviceOrientation', 'gamepad', 'step', 'sine', 'ramp'].forEach(function (key) { + sender(state.direction[key]); }); - $('#direction-sine-stop').click(function () { - sineInput.stop(); - }); - - var rampInput = new input.Ramp(quad.cmd); - $('#direction-ramp').submit(function (event) { - event.preventDefault(); - var data = $(this).serializeObject(); - data.slope = parseFloat(data.slope); - - rampInput.start(data); - }); - $('#direction-ramp-stop').click(function () { - rampInput.stop(); - }); + return state; +} - $('#calibrate-sensor-btn').click(function () { - var calibration = $.extend(true, {}, quad.config.mpu6050.calibration); - var types = ['gyro', 'accel', 'rotation']; - for (var i = 0; i < types.length; i++) { - var type = types[i]; - if (!calibration[type]) { - calibration[type] = { x: 0, y: 0, z: 0 }; - } - for (var axis in quad.orientation[type]) { - calibration[type][axis] -= quad.orientation[type][axis]; - } - } +App.render = function (state) { + return h('#app', [ + hg.partial(Console.render, state.console), + + h('hr'), + h('.container-fluid', h('.row', [ + h('.col-sm-6.col-xs-12', [ + hg.partial(EnableBtn.render, state.enableBtn), + hg.partial(ControllerBtn.render, state.controllerBtn) + ]), + h('.col-sm-6.col-xs-12', [ + hg.partial(SystemSummary.render, state.systemSummary) + ]) + ])), + + h('hr'), + h('.container-fluid', h('.row', [ + h('.col-lg-4.col-xs-9.text-center', [ + h('div', [ + h('strong', 'Direction'), + hg.partial(Tabs.render, state.directionTabs) + ]), + Tabs.renderContainer(state.directionTabs, 'mouse', [ // .hidden-xs.hidden-sm + hg.partial(MouseDirection.render, state.direction.mouse) + ]), + Tabs.renderContainer(state.directionTabs, 'device', [ + hg.partial(DeviceOrientationDirection.render, state.direction.deviceOrientation), + hg.partial(GamepadDirection.render, state.direction.gamepad) + ]), + Tabs.renderContainer(state.directionTabs, 'step', [ + hg.partial(StepDirection.render, state.direction.step) + ]), + Tabs.renderContainer(state.directionTabs, 'sine', [ + hg.partial(SineDirection.render, state.direction.sine) + ]), + Tabs.renderContainer(state.directionTabs, 'ramp', [ + hg.partial(RampDirection.render, state.direction.ramp) + ]) + ]), + h('.col-lg-2.col-xs-3.text-center', hg.partial(PowerInput.render, state.powerInput)), + h('span.clearfix.visible-sm'), + h('.col-lg-3.col-sm-6.col-xs-12.text-center', [ + hg.partial(Outline.Top.render, state.outline.top), + hg.partial(MotorsSummary.render, state.motorsSummary) + ]), + h('.col-lg-3.col-sm-6.col-xs-12.text-center', [ + hg.partial(Outline.Front.render, state.outline.front), + hg.partial(Outline.Right.render, state.outline.right), + hg.partial(OrientationSummary.render, state.orientationSummary), + hg.partial(CalibrateBtn.render, state.calibrateBtn), + ' ', + hg.partial(ChartRecorder.render, state.chartRecorder) + ]), + ])), + h('.container-fluid', hg.partial(Charts.render, state.charts)), + + h('#camera', { + style: { display: (state.cameraAvailable) ? 'block' : 'none' } + }, [ + h('hr'), + h('.container-fluid', hg.partial(Camera.render, state.camera)) + ]), + + h('hr'), + h('.container-fluid', hg.partial(Config.render, state.config)), + + hg.partial(Alerts.render, state.alerts) + ]); +}; - // z accel is 1, because of gravitation :-P - calibration.accel.z += 1; +hg.app(document.body, App(), App.render); - quad.config.mpu6050.calibration = calibration; - sendCommand('config', quad.config); - }); +/*function init(quad) { + keyBindings(quad); - var cameraPreview = new CameraPreview(quad.cmd); - cameraPreview.on('start', function () { - $('#camera-video').html(cameraPreview.player.canvas); - }); - cameraPreview.on('error', function (err) { - log(err, 'error'); - }); - $('#camera-play-btn').click(function () { - cameraPreview.play(); - }); - $('#camera-pause-btn').click(function () { - cameraPreview.pause(); - }); - $('#camera-stop-btn').click(function () { - cameraPreview.stop(); - }); $('#camera-config-preview').submit(function () { if (cameraPreview.isStarted()) { setTimeout(function () { @@ -208,418 +160,4 @@ function init(quad) { }, 500); } }); - - $('#camera-record-btn').click(function () { - $(this).toggleClass('active'); - var enabled = $(this).is('.active'); - sendCommand('camera-record', enabled); - $('#camera-status-recording').toggle(enabled); - }); - - $('#sensor-record-btn').click(function () { - if (graphsExport.isRecording()) { - graphsExport.stop(); - } else { - graphsExport.start(); - } - $('#sensor-status-recording').toggle(graphsExport.isRecording()); - }); - $('#sensor-export-btn').click(function () { - var csv = graphsExport.toCsv(); - if (!csv) return; - var blob = new Blob([csv], { type: 'text/csv' }); - var url = URL.createObjectURL(blob); - window.open(url); - }); - - $('#graph-axes-btn').change(function () { - var val = $(this).val(); - - var axes; - if (val == '*') { - axes = null; - } else { - axes = [val]; - } - - graphs.axes = axes; - }).change(); -} - -$(function () { - var schemas = {}; - - var quad = new Quadcopter(); - - // Console - var $console = $('#console pre'); - function log(msg, type) { - if (type) { - msg = ''+msg+''; - } - $console.append(msg, '\n'); - } - window.log = log; - - var $alerts = $('#alert-ctn'); - function addAlert(msg, type) { - var $alert = $('
', { 'class': 'alert alert-'+type }).html(msg); - $alerts.append($alert); - return $alert; - } - function removeAlert($alert) { - $alert.remove(); - } - - // TODO: this is deprecated, use Quadcopter methods instead - window.sendCommand = function (cmd, opts) { - quad.cmd.send(cmd, opts); - }; - - quad.on('error', function (msg) { - log(msg, 'error'); - }); - - quad.on('info', function (msg) { - log(msg, 'info'); - }); - - quad.on('enabled', function (enabled) { - $('#power-switch').prop('checked', enabled); - }); - - quad.on('power', function (power) { - $('#power-input').val(Math.round(power * 100)); - }); - - // Add target to graphs export - (function () { - var target; - quad.cmd.on('orientation', function (t) { - target = t; - }); - quad.on('motors-speed', function () { - var timestamp = new Date().getTime(); - if (graphs.axes) { - for (var axis in target) { - if (graphs.axes.indexOf(axis) == -1) { - delete target[axis]; - } - } - } - graphsExport.append('target', timestamp, target) - }); - })(); - - quad.on('motors-speed', function (speeds) { - if (quad.config) { - var range = quad.config.servos.range; - for (var i = 0; i < speeds.length; i++) { - var speed = speeds[i]; - if (speed >= range[1]) { - // Max. motor power reached - log('Motor '+quad.config.servos.pins[i]+' is at full power!', 'error'); - } - } - } - - var speedsRatio = [0, 0, 0, 0]; - if (quad.config) { - var range = quad.config.servos.range; - speedsRatio = speeds.map(function (speed) { - return (speed - range[0]) / (range[1] - range[0]); - }); - } - - schemas.top.setSpeed(speedsRatio); - schemas.sideX.setSpeed(speedsRatio.slice(0, 2)); - schemas.sideY.setSpeed(speedsRatio.slice(2, 4)); - - var speedsList = speeds; - if (quad.config) { - speedsList = speeds.map(function (speed, i) { - return quad.config.servos.pins[i] + ': ' + speed; - }); - } - $('#motors-stats .motors-speed').html(speedsList.join('
')); - - var timestamp = new Date().getTime(); - if (!graphs.axes || graphs.axes.indexOf('x') >= 0) { - graphs.motors_speed_0.append(timestamp, speeds[0]); - graphs.motors_speed_2.append(timestamp, speeds[2]); - } - if (!graphs.axes || graphs.axes.indexOf('y') >= 0) { - graphs.motors_speed_1.append(timestamp, speeds[1]); - graphs.motors_speed_3.append(timestamp, speeds[3]); - } - - var exportedSpeeds = speeds.slice(); - if (graphs.axes) { - if (graphs.axes.indexOf('x') == -1) { - exportedSpeeds[0] = undefined; - exportedSpeeds[2] = undefined; - } - if (graphs.axes.indexOf('y') == -1) { - exportedSpeeds[1] = undefined; - exportedSpeeds[3] = undefined; - } - } - graphsExport.append('motors-speed', timestamp, exportedSpeeds); - }); - - quad.on('motors-forces', function (forces) { - var forcesList = forces; - if (quad.config) { - var forcesList = forces.map(function (f, i) { - return quad.config.servos.pins[i] + ': ' + f; - }); - } - $('#motors-stats .motors-forces').html(forcesList.join('
')); - }); - - quad.on('orientation', function (orientation) { - schemas.sideX.setRotation(orientation.rotation.x); - schemas.sideY.setRotation(orientation.rotation.y); - - var objectValues = function (obj) { - var list = []; - for (var key in obj) { - var val = obj[key]; - if (typeof val == 'number') { // Only keep two digits after the comma - val = Math.round(val * 100) / 100; - } - list.push(val); - } - return list; - }; - - var $stats = $('#sensor-stats'); - $stats.find('.sensor-gyro').text(objectValues(orientation.gyro)); - $stats.find('.sensor-accel').text(objectValues(orientation.accel)); - $stats.find('.sensor-rotation').text(objectValues(orientation.rotation)); - $stats.find('.sensor-temp').text(Math.round(orientation.temp)); - - // Graphs - var timestamp = new Date().getTime(); - - function appendAxes(name, data) { - var axes = graphs.axes || ['x', 'y', 'z']; - - var exportedData = {}; - for (var i = 0; i < axes.length; i++) { - var axis = axes[i]; - if (typeof data[axis] == 'undefined') continue; - - var value = data[axis]; - graphs[name+'_'+axis].append(timestamp, value); - exportedData[axis] = value; - } - - graphsExport.append(name, timestamp, exportedData); - } - - appendAxes('gyro', orientation.gyro); - appendAxes('accel', orientation.accel); - appendAxes('rotation', orientation.rotation); - }); - - quad.on('os-stats', function (stats) { - var $stats = $('#os-stats'); - - var loadavg = []; - for (var i = 0; i < stats.loadavg.length; i++) { - var avg = stats.loadavg[i]; - var pct = Math.round(avg * 100); - loadavg.push(''+pct+'%'); - } - $stats.find('.os-loadavg').html(loadavg.join(', ')); - - var memPct = stats.mem.free / stats.mem.total; - $stats.find('.os-mem') - .text(stats.mem.free + '/' + stats.mem.total + ' ('+Math.round(memPct * 100)+'%)') - .css('color', colors.toRgb(colors.shade(colors.getForPercentage(1 - memPct), -0.5))); - }); - - // TODO: handle multiple config changes - quad.once('config', function (cfg) { - var accessor = function (prop, value) { - var path = prop.split('.'); - - var obj = cfg; - for (var i = 0; i < path.length; i++) { - var node = path[i]; - if (obj instanceof Array) { - node = parseInt(node); - } - - if (typeof obj[node] == 'undefined') { - return; - } - - if (i == path.length - 1) { // Last one - if (typeof value == 'undefined') { - return obj[node]; - } else { - obj[node] = value; - } - } else { - obj = obj[node]; - if (!obj) return; - } - } - - return obj; - }; - - var handleInput = function (input, domain) { - var name = $(input).attr('name'); - if (!name) { - return; - } - if (domain) { - name = domain+'.'+name; - } - - var val = accessor(name); - if (typeof val != 'undefined') { - if ($(input).is('input')) { - $(input).attr('value', val); - } else if ($(input).is('select')) { - $(input).find('option').each(function () { - var name = $(this).attr('value'); - if (typeof name == 'undefined') { - name = $(this).html(); - } - if (name == val) { - $(this).attr('selected', ''); - } - }); - } - $(input).val(val); - } - - $(input).change(function () { - var val = $(input).val(); - if ($(input).is('input')) { - var type = $(input).attr('type'); - switch (type) { - case 'number': - case 'range': - val = parseFloat(val); - break; - case 'checkbox': - val = $(input).prop('checked'); - break; - } - } - accessor(name, val); - }); - }; - - var $form = $('#config-form'); - $form.find('input,select').each(function (i, input) { - handleInput(input); - }); - $form.submit(function (event) { - event.preventDefault(); - - sendCommand('config', cfg); - }); - $form.find('#export-config-btn').click(function () { - var json = JSON.stringify(cfg, null, '\t'); - var blob = new Blob([json], { type: 'application/json' }); - var url = URL.createObjectURL(blob); - window.open(url); - }); - - // Camera config - function initCameraConfigForm(form, type) { - var $form = $(form); - - $form.find('input,select').each(function (i, input) { - handleInput(input, 'camera.'+type); - }); - $form.submit(function (event) { - event.preventDefault(); - - sendCommand('config', cfg); - }); - - $form.find('#ISO-switch').change(function () { - if ($(this).prop('checked')) { - var val = parseInt($form.find('[name="ISO"]').val()); - accessor('camera.preview.ISO', val); - } else { - accessor('camera.preview.ISO', null); - } - }); - $form.find('[name="ISO"]').change(function () { - $form.find('#ISO-switch').prop('checked', true); - }); - } - initCameraConfigForm('#camera-config-preview', 'preview'); - initCameraConfigForm('#camera-config-record', 'record'); - - // PID controller config - $('#controller-btn').val(cfg.controller.updater); - }); - - quad.on('features', function (features) { - var allGreen = true; - - if (features.hardware.indexOf('motors') === -1) { - log('Motors not available', 'error'); - allGreen = false; - } - if (features.hardware.indexOf('imu') === -1) { - log('Inertial Measurement Unit not available', 'error'); - allGreen = false; - } - if (features.hardware.indexOf('camera') === -1) { - $('#camera').hide(); - } - - var featuresStr = features.hardware.join(', '); - if (!featuresStr) featuresStr = '(none)'; - log('Available features: '+featuresStr); - - if (allGreen) { - log('ALL GREEN!', 'success'); - } - - // Update updaters list - $('#controller-btn').html(features.updaters.map(function (name) { - return ''; - })); - }); - - quad.client.on('disconnect', function () { - log('Connection closed.'); - addAlert('Connection to server lost.', 'danger'); - }); - - var cameraConfigHtml = $('#camera-config-inputs').html(); - $('#camera-config-preview, #camera-config-record').html(cameraConfigHtml); - $('#camera-config-tabs').tabs(); - - var connectingAlert = addAlert('Connecting to server...', 'info'); - - // Inject SVGs into HTML to be able to style and animate them - var svgs = $('img[src$=".svg"]'); - SVGInjector(svgs, null, function () { - schemas.top = new QuadcopterSchema('#quadcopter-top'); - schemas.sideX = new QuadcopterSchema('#quadcopter-side-x'); - schemas.sideY = new QuadcopterSchema('#quadcopter-side-y'); - - // Init - log('Connecting to server...'); - quad.init(function (err) { - if (err) return log(err, 'error'); - - init(quad); - - log('Connected!'); - removeAlert(connectingAlert); - }); - }); -}); +}*/ diff --git a/public/js/input/device-orientation.js b/public/js/input/device-orientation.js deleted file mode 100644 index 4948ec7..0000000 --- a/public/js/input/device-orientation.js +++ /dev/null @@ -1,19 +0,0 @@ -function DeviceOrientationInput(cmd) { - if (!DeviceOrientationInput.isSupported()) { - throw new Error('DeviceOrientation not supported'); - } - - window.addEventListener('deviceorientation', function (event) { - cmd.send('orientation', { - x: event.beta, // front-to-back tilt in degrees, where front is positive - y: event.gamma, // left-to-right tilt in degrees, where right is positive - z: event.alpha - }); - }, false); -} - -DeviceOrientationInput.isSupported = function () { - return (typeof window.DeviceOrientationEvent != 'undefined'); -}; - -module.exports = DeviceOrientationInput; diff --git a/public/js/input/gamepad.js b/public/js/input/gamepad.js deleted file mode 100644 index e8657bb..0000000 --- a/public/js/input/gamepad.js +++ /dev/null @@ -1,62 +0,0 @@ -var $ = require('jquery'); - -function GamepadInput(cmd) { - if (!GamepadInput.isSupported()) { - throw new Error('Gamepad API not supported'); - } - - var that = this; - - window.addEventListener('gamepadconnected', function (e) { - console.log("Gamepad connected at index %d: %s. %d buttons, %d axes.", - e.gamepad.index, e.gamepad.id, - e.gamepad.buttons.length, e.gamepad.axes.length); - log('Gamepad "'+e.gamepad.id+'" connected.'); - - that.gamepad = navigator.getGamepads()[e.gamepad.index]; - that.calibrate(); - that._loop(cmd); - }); - - window.addEventListener('gamepaddisconnected', function (e) { - log('Gamepad "'+e.gamepad.id+'" disconnected.'); - }); -} - -GamepadInput.isSupported = function () { - return (!!navigator.getGamepads || !!navigator.webkitGetGamepads); -}; - -GamepadInput.prototype._loop = function (cmd) { - console.log(this.gamepad); - - var that = this; - - var interval = setInterval(function () { // TODO - if (!that.gamepad.connected) { - clearInterval(interval); - return; - } - - var cal = that.calibration; - var axes = that.gamepad.axes.map(function (value, i) { - if (cal && cal[i]) { - return value - cal[i]; - } - return value; - }); - - cmd.send('orientation', { - x: axes[0] * 90, - y: axes[1] * 90, - z: axes[2] * 90 - }); - cmd.send('power', - axes[3]); - }, 500); -}; - -GamepadInput.prototype.calibrate = function () { - this.calibration = $.extend([], this.gamepad.axes); -}; - -module.exports = GamepadInput; diff --git a/public/js/input/mouse.js b/public/js/input/mouse.js deleted file mode 100644 index bcfb521..0000000 --- a/public/js/input/mouse.js +++ /dev/null @@ -1,70 +0,0 @@ -var $ = require('jquery'); - -function MouseInput(cmd, el) { - var that = this; - - this.joystick = $(el); - this.handle = this.joystick.find('.handle'); - - var joystickSize = { - width: this.joystick.width(), - height: this.joystick.height() - }; - var handleSize = { - width: this.handle.width(), - height: this.handle.height() - }; - - this.pressed = true; - - var offset; - this.joystick.on('mousedown mousemove mouseup', function (event) { - if ((event.type == 'mousemove' && event.buttons) || event.type == 'mousedown') { - if (!that.pressed) { - that.joystick.addClass('active'); - that.pressed = true; - - offset = that.joystick.offset(); - } - - var x = event.pageX - offset.left - handleSize.width/2, - y = event.pageY - offset.top - handleSize.height/2; - - // In degrees - cmd.send('orientation', { - x: (x - joystickSize.width/2) / joystickSize.width * 90, // front-to-back tilt in degrees, where front is positive - y: (y - joystickSize.height/2) / joystickSize.height * 90, // left-to-right tilt in degrees, where right is positive - z: 0 - }); - } else { - if (that.pressed) { - that.joystick.removeClass('active'); - that.handle.css({ - left: joystickSize.width/2 - handleSize.width/2, - top: joystickSize.height/2 - handleSize.height/2 - }); - that.pressed = false; - - cmd.send('orientation', { - x: 0, - y: 0, - z: 0 - }); - } - } - }); - this.joystick.trigger('mouseup'); - - cmd.on('orientation', function (data) { - that.handle.css({ - left: joystickSize.width/2 - handleSize.width/2 + data.x/90*joystickSize.width, - top: joystickSize.height/2 - handleSize.height/2 + data.y/90*joystickSize.height - }); - }); -} - -MouseInput.isSupported = function () { - return true; -}; - -module.exports = MouseInput; diff --git a/public/js/input/ramp.js b/public/js/input/ramp.js deleted file mode 100644 index 2ceea74..0000000 --- a/public/js/input/ramp.js +++ /dev/null @@ -1,23 +0,0 @@ -function RampInput(cmd) { - this._cmd = cmd; -} - -RampInput.prototype.start = function(opts) { - this.stop(); - - var cmd = this._cmd; - - var startedAt = (new Date()).getTime(); - this._interval = setInterval(function () { - var t = ((new Date()).getTime() - startedAt) / 1000; - var orientation = { x: 0, y: 0, z: 0 }; - orientation[opts.axis] = opts.slope * t; - cmd.send('orientation', orientation); - }, 200); -}; - -RampInput.prototype.stop = function () { - clearInterval(this._interval); -}; - -module.exports = RampInput; diff --git a/public/js/input/sine.js b/public/js/input/sine.js deleted file mode 100644 index 8e7e71d..0000000 --- a/public/js/input/sine.js +++ /dev/null @@ -1,27 +0,0 @@ -function SineInput(cmd) { - this._cmd = cmd; -} - -SineInput.prototype.start = function(opts) { - this.stop(); - - var cmd = this._cmd; - - var A = opts.amplitude, - f = opts.frequency, - phi = opts.offset; - - var startedAt = (new Date()).getTime(); - this._interval = setInterval(function () { - var t = ((new Date()).getTime() - startedAt) / 1000; - var orientation = { x: 0, y: 0, z: 0 }; - orientation[opts.axis] = A * Math.sin(2 * Math.PI * f * t + phi); - cmd.send('orientation', orientation); - }, 200); -}; - -SineInput.prototype.stop = function () { - clearInterval(this._interval); -}; - -module.exports = SineInput; diff --git a/public/js/input/step.js b/public/js/input/step.js deleted file mode 100644 index 16f3478..0000000 --- a/public/js/input/step.js +++ /dev/null @@ -1,31 +0,0 @@ -function StepInput(cmd) { - this._cmd = cmd; -} - -StepInput.prototype.start = function(opts) { - this.stop(); - - var cmd = this._cmd; - - cmd.send('orientation', { - x: opts.x, - y: opts.y, - z: opts.z - }); - - if (opts.duration) { - this._timeout = setTimeout(function () { - cmd.send('orientation', { - x: 0, - y: 0, - z: 0 - }); - }, opts.duration); - } -}; - -StepInput.prototype.stop = function () { - clearTimeout(this._timeout); -}; - -module.exports = StepInput; diff --git a/public/js/jquery/serialize-object.js b/public/js/jquery/serialize-object.js deleted file mode 100644 index 50db882..0000000 --- a/public/js/jquery/serialize-object.js +++ /dev/null @@ -1,13 +0,0 @@ -module.exports = function ($) { - $.fn.serializeObject = function () { - var arr = this.serializeArray(); - var obj = {}; - - for (var i = 0; i < arr.length; i++) { - var item = arr[i]; - obj[item.name] = item.value; - } - - return obj; - }; -}; diff --git a/public/js/jquery/tabs.js b/public/js/jquery/tabs.js deleted file mode 100644 index d88fb21..0000000 --- a/public/js/jquery/tabs.js +++ /dev/null @@ -1,20 +0,0 @@ -module.exports = function ($) { - $.fn.tabs = function () { - var that = this; - - var selectTab = function (tab) { - that.find('a').each(function () { - var show = $(this).is(tab); - $(this).parent().toggleClass('active', show); - $($(this).attr('href')).toggle(show); - }); - }; - - this.click(function (event) { - event.preventDefault(); - selectTab(event.target); - }); - - selectTab(this.find('a').first()); - }; -}; diff --git a/public/js/quadcopter.js b/public/js/quadcopter.js index 3409360..d4f6090 100644 --- a/public/js/quadcopter.js +++ b/public/js/quadcopter.js @@ -1,5 +1,6 @@ var util = require('util'); var EventEmitter = require('events').EventEmitter; +var extend = require('extend'); var MsgHandler = require('./msg-handler'); var Client = require('./client'); @@ -39,7 +40,7 @@ function Quadcopter() { return that._config; }, set: function (val) { - that.cmd.send('config', val); + that.setConfig(val); } }, power: { @@ -104,4 +105,9 @@ Quadcopter.prototype.init = function (cb) { }); }; -module.exports = Quadcopter; \ No newline at end of file +Quadcopter.prototype.setConfig = function (config) { + extend(this._config, config); + this.cmd.send('config', config); +}; + +module.exports = Quadcopter; diff --git a/public/js/widget/alerts.js b/public/js/widget/alerts.js new file mode 100644 index 0000000..463d77a --- /dev/null +++ b/public/js/widget/alerts.js @@ -0,0 +1,49 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; + +function Alerts(quad) { + var state = hg.state({ + alerts: hg.array([]) + }); + + function add(alert) { + return Alerts.add(state, alert); + } + + quad.client.on('disconnect', function () { + add({ type: 'danger', msg: 'Connection to server lost.' }); + }); + + quad.client.on('connecting', function () { + var remove = add({ type: 'info', msg: 'Connecting to server...' }); + quad.client.once('connected', remove); + quad.client.once('error', remove); + }); + + return state; +} + +Alerts.add = function (state, alert) { + var index = state.alerts().length; + state.alerts.push(alert); + + return function () { + var alerts = state.alerts(); + for (var i = 0; i < alerts.length; i++) { + if (alerts[i] === alert) { + state.alerts.splice(i, 1); + return; + } + } + }; +}; + +Alerts.render = function (state) { + return h('#alert-ctn', state.alerts.map(function (alert) { + return h('.alert.alert-' + alert.type, alert.msg); + })); +}; + +module.exports = Alerts; diff --git a/public/js/widget/calibrate-btn.js b/public/js/widget/calibrate-btn.js new file mode 100644 index 0000000..d88244a --- /dev/null +++ b/public/js/widget/calibrate-btn.js @@ -0,0 +1,37 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var extend = require('extend'); + +function CalibrateBtn(quad) { + return hg.state({ + channels: { + calibrate: function (state) { + var calibration = extend(true, {}, quad.config.mpu6050.calibration); + var types = ['gyro', 'accel', 'rotation']; + for (var i = 0; i < types.length; i++) { + var type = types[i]; + if (!calibration[type]) { + calibration[type] = { x: 0, y: 0, z: 0 }; + } + for (var axis in quad.orientation[type]) { + calibration[type][axis] -= quad.orientation[type][axis]; + } + } + + // z accel is 1, because of gravitation :-P + calibration.accel.z += 1; + + quad.config.mpu6050.calibration = calibration; + quad.cmd.send('config', quad.config); + } + } + }); +} + +CalibrateBtn.render = function (state) { + return h('button.btn.btn-default', { 'ev-click': state.channels.calibrate }, 'Calibrate'); +}; + +module.exports = CalibrateBtn; diff --git a/public/js/widget/camera-config.js b/public/js/widget/camera-config.js new file mode 100644 index 0000000..e45e7f0 --- /dev/null +++ b/public/js/widget/camera-config.js @@ -0,0 +1,49 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var extend = require('extend'); +var Tabs = require('../component/tabs'); +var CameraProfile = require('./camera-profile'); + +function CameraConfig(quad) { + var state = hg.state({ + tabs: Tabs(['preview', 'record']), + profiles: hg.struct({ + preview: CameraProfile(), + record: CameraProfile() + }), + channels: { + change: function (state, data) {} + } + }); + + quad.on('config', function (config) { + state.profiles.preview.config.set(config.camera.preview); + state.profiles.record.config.set(config.camera.record); + }); + quad.once('config', function (config) { + state.profiles.preview.config(function (config) { + quad.setConfig({ camera: { preview: config } }); + }); + state.profiles.record.config(function (config) { + quad.setConfig({ camera: { record: config } }); + }); + }); + + return state; +} + +CameraConfig.render = function (state) { + return h('div', [ + hg.partial(Tabs.render, state.tabs), + Tabs.renderContainer(state.tabs, 'preview', [ + hg.partial(CameraProfile.render, state.profiles.preview) + ]), + Tabs.renderContainer(state.tabs, 'record', [ + hg.partial(CameraProfile.render, state.profiles.record) + ]) + ]); +}; + +module.exports = CameraConfig; diff --git a/public/js/widget/camera-profile.js b/public/js/widget/camera-profile.js new file mode 100644 index 0000000..41eaf11 --- /dev/null +++ b/public/js/widget/camera-profile.js @@ -0,0 +1,199 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var extend = require('extend'); +var Switch = require('../component/switch'); +var formGroup = require('../component/form-group'); + +var exposures = [ + 'auto', + 'night', + 'nightpreview', + 'backlight', + 'spotlight', + 'sports', + 'snow', + 'beach', + 'verylong', + 'fixedfps', + 'antishake', + 'fireworks' +]; + +var awbs = [ + 'off', + 'auto', + 'sun', + 'cloud', + 'shade', + 'tungsten', + 'fluorescent', + 'incandescent', + 'flash', + 'horizon' +]; + +var meterings = [ + '', + 'average', + 'spot', + 'backlit', + 'matrix' +]; + +var drcs = [ + '', + 'low', + 'medium', + 'high' +]; + +var modes = [ + 'Auto', + '1920x1080, 16:9, 1-30fps', + '2592x1944, 4:3, 1-15fps', + '2592x1944, 4:3, 0.1666-1fps', + '1296x972, 4:3, 1-42fps', + '1296x730, 16:9, 1-49fps', + '640x480, 4:3, 42.1-60fps', + '640x480, 4:3, 60.1-90fps' +]; + +function CameraProfile() { + return hg.state({ + config: hg.value(), + isoSwitch: Switch(), + vstabSwitch: Switch(), + channels: { + change: change + } + }); +} + +function change(state, data) { + delete data.value; // TODO + + if (!state.isoSwitch.value()) { + delete data.ISO; + } + data.vstab = state.vstabSwitch.value(); + + var current = state.config(); + for (var key in current) { + if (typeof current[key] === 'number') { + data[key] = parseFloat(data[key]) || 0; + } + if (data[key] === '') { + data[key] = null; + } + } + + state.config.set(extend({}, current, data)); +} + +CameraProfile.render = function (state) { + if (!state.config) return h(); + + return h('form.form-horizontal', { + 'ev-submit': hg.sendSubmit(state.channels.change) + }, [ + formGroup('Sharpness', h('input', { + type: 'range', + name: 'sharpness', + value: state.config.sharpness, + min: -100, + max: 100 + })), + formGroup('Contrast', h('input', { + type: 'range', + name: 'contrast', + value: state.config.contrast, + min: -100, + max: 100 + })), + formGroup('Brightness', h('input', { + type: 'range', + name: 'brightness', + value: state.config.brightness, + min: 0, + max: 100 + })), + formGroup('Saturation', h('input', { + type: 'range', + name: 'saturation', + value: state.config.saturation, + min: -100, + max: 100 + })), + formGroup('ISO', [ + Switch.render(state.isoSwitch), + h('input', { + type: 'range', + name: 'ISO', + value: state.config.ISO, + disabled: !state.isoSwitch.value, + min: 100, + max: 800 + }) + ]), + formGroup('Stabilisation', Switch.render(state.vstabSwitch)), + formGroup('EV compensation', h('input', { + type: 'range', + name: 'ev', + value: state.config.ev, + min: -10, + max: 10 + })), + formGroup('Exposure', h('select.form-control', { + name: 'exposure' + }, exposures.map(function (item) { + return h('option', { selected: (state.config.exposure === item) }, item); + }))), + formGroup(h('abbr', { title: 'Automatic White Balance' }, 'AWB'), h('select.form-control', { + name: 'awb' + }, awbs.map(function (item) { + return h('option', { selected: (state.config.awb === item) }, item); + }))), + formGroup('Metering', h('select.form-control', { + name: 'metering' + }, meterings.map(function (item) { + return h('option', { value: item, selected: (state.config.metering === item) }, item || 'default'); + }))), + formGroup(h('abbr', { title: 'Dynamic Range Compression' }, 'DRC'), h('select.form-control', { + name: 'drc' + }, drcs.map(function (item) { + return h('option', { value: item, selected: (state.config.drc === item) }, item || 'off'); + }))), + formGroup('Mode', h('select.form-control', { + name: 'mode' + }, modes.map(function (title, i) { + return h('option', { value: i, selected: (state.config.mode === i) }, title); + }))), + formGroup('Size', [ + 'Width: ', h('input.form-control', { + type: 'number', + name: 'width', + value: state.config.width + }), 'px', + h('br'), + 'Height: ', h('input.form-control', { + type: 'number', + name: 'height', + value: state.config.height + }), 'px' + ]), + formGroup(h('abbr', { title: 'Frames Per Second' }, 'FPS'), h('input.form-control', { + type: 'number', + name: 'framerate', + value: state.config.framerate + })), + h('.form-group', h('.col-sm-offset-2.col-sm-10', [ + h('button.btn.btn-primary', { type: 'submit' }, 'Submit'), + ' ', + h('button.btn.btn-default', { type: 'reset' }, 'Reset') + ])) + ]); +}; + +module.exports = CameraProfile; diff --git a/public/js/widget/camera.js b/public/js/widget/camera.js new file mode 100644 index 0000000..3eeee57 --- /dev/null +++ b/public/js/widget/camera.js @@ -0,0 +1,76 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var CameraPreview = require('../camera-preview'); +var Canvas = require('../component/canvas'); +var CameraConfig = require('./camera-config'); + +function Camera(quad) { + var cameraPreview = new CameraPreview(quad.cmd); + + var state = hg.state({ + config: CameraConfig(quad), + video: hg.value(), + recording: hg.value(false), + channels: { + play: function () { + cameraPreview.play(); + }, + pause: function () { + cameraPreview.pause(); + }, + stop: function () { + cameraPreview.stop(); + }, + record: function () { + var recording = !state.recording(); + state.recording.set(recording); + quad.cmd.send('camera-record', recording); + } + } + }); + + cameraPreview.on('start', function (canvas) { + state.video.set(canvas); + }); + cameraPreview.on('stop', function () { + state.video.set(null); + }); + cameraPreview.on('error', function (err) { + console.error(err); // TODO: use UI console instead + }); + + return state; +} + +Camera.render = function (state) { + return h('.row', [ + h('.col-sm-6.col-xs-12', hg.partial(CameraConfig.render, state.config)), + h('.col-sm-6.col-xs-12', [ + h('p#camera-video', (state.video) ? Canvas(state.video) : null), + h('.text-center', [ + h('.btn-group', [ + h('button.btn.btn-default', { + 'ev-click': hg.sendClick(state.channels.play) + }, h('span.glyphicon.glyphicon-play')), + h('button.btn.btn-default', { + 'ev-click': hg.sendClick(state.channels.pause) + }, h('span.glyphicon.glyphicon-pause')), + h('button.btn.btn-default' + ((state.recording) ? '.active' : ''), { + 'ev-click': hg.sendClick(state.channels.stop) + }, h('span.glyphicon.glyphicon-stop')) + ]), + ' ', + h('button.btn.btn-danger', { + title: 'Record', + 'ev-click': hg.sendClick(state.channels.record) + }, h('span.glyphicon.glyphicon-record')), + h('br'), + h('p.text-danger', { style: { display: (state.recording) ? 'block' : 'none' } }, 'Recording...') + ]) + ]), + ]); +}; + +module.exports = Camera; diff --git a/public/js/widget/chart-recorder.js b/public/js/widget/chart-recorder.js new file mode 100644 index 0000000..571c986 --- /dev/null +++ b/public/js/widget/chart-recorder.js @@ -0,0 +1,52 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var Recorder = require('../chart-recorder'); + +function ChartRecorder(quad) { + var recorder = Recorder(quad); + + var state = hg.state({ + recording: hg.value(false), + channels: { + start: function (state) { + recorder.start(); + state.recording.set(true); + }, + stop: function (state) { + recorder.stop(); + state.recording.set(false); + }, + export: function (state) { + recorder.export(); + } + } + }); + + return state; +} + +ChartRecorder.render = function (state) { + var recordBtn; + if (!state.recording) { + recordBtn = h('button.btn.btn-danger', { + title: 'Record', + 'ev-click': hg.sendClick(state.channels.start) + }, h('span.glyphicon.glyphicon-record')); + } else { + recordBtn = h('button.btn.btn-danger', { + title: 'Stop recording', + 'ev-click': hg.sendClick(state.channels.stop) + }, h('span.glyphicon.glyphicon-stop')); + } + + return h('.btn-group', [ + recordBtn, + h('button.btn.btn-default', { + 'ev-click': hg.sendClick(state.channels.export) + }, 'Export') + ]); +}; + +module.exports = ChartRecorder; diff --git a/public/js/widget/charts.js b/public/js/widget/charts.js new file mode 100644 index 0000000..07901f3 --- /dev/null +++ b/public/js/widget/charts.js @@ -0,0 +1,144 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var smoothie = require('smoothie'); +var StreamingChart = require('../component/streaming-chart'); + +var lineStyle = { + red: { strokeStyle: 'rgb(255, 0, 0)' }, + green: { strokeStyle: 'rgb(0, 255, 0)' }, + blue: { strokeStyle: 'rgb(0, 0, 255)' }, + yellow: { strokeStyle: 'yellow' } +}; + +function lineStyleGenerator() { + var styles = Object.keys(lineStyle); + + var i = 0; + return function () { + return lineStyle[styles[i++]]; + }; +} + +function initChart(chart, names) { + var items = {}; + + var nextStyle = lineStyleGenerator(); + names.forEach(function (name) { + var item = new smoothie.TimeSeries(); + chart.chart.addTimeSeries(item, nextStyle()); + items[name] = item; + }); + + return items; +} + +function Charts(quad) { + var charts = { + gyro: StreamingChart(), + accel: StreamingChart(), + rotation: StreamingChart(), + motorsSpeed: StreamingChart() + }; + + var gyro = initChart(charts.gyro, ['x', 'y', 'z']); + var accel = initChart(charts.accel, ['x', 'y', 'z']); + var rotation = initChart(charts.rotation, ['x', 'y', 'z']); + var motorsSpeed = initChart(charts.motorsSpeed, [0, 1, 2, 3]); + + var state = hg.state({ + charts: hg.struct(charts), + visibleAxes: hg.array(['x', 'y', 'z']), + channels: { + changeVisibleAxes: changeVisibleAxes + } + }); + + function append(series, t, data) { + for (var name in data) { + series[name].append(t, data[name]); + } + } + + function filter(data, axes) { + var filtered = {}; + for (var i = 0; i < axes.length; i++) { + var axis = axes[i]; + filtered[axis] = data[axis]; + } + return filtered; + } + + quad.on('orientation', function (data) { + var t = new Date().getTime(); + var axes = state.visibleAxes(); + + append(gyro, t, filter(data.gyro, axes)); + append(accel, t, filter(data.accel, axes)); + append(rotation, t, filter(data.rotation, axes)); + }); + + quad.on('motors-speed', function (data) { + var t = new Date().getTime(); + var axes = state.visibleAxes(); + + append(motorsSpeed, t, data.filter(function (item, i) { + if ((i === 0 || i === 2)) { + return (axes.indexOf('x') !== -1); + } + if ((i === 1 || i === 3)) { + return (axes.indexOf('y') !== -1); + } + return false; + })); + }); + + return state; +} + +function changeVisibleAxes(state, data) { + var axes = data.axes.split(',').map(function (axis) { + return axis.trim(); + }); + + state.visibleAxes.set(axes); +} + +Charts.render = function (state) { + return h('.row', [ + h('.col-lg-4.col-sm-6.col-xs-12.graph-ctn', [ + h('strong', 'Gyro'), + state.charts.gyro + ]), + h('.col-lg-4.col-sm-6.col-xs-12.graph-ctn', [ + h('strong', 'Accel'), + state.charts.accel + ]), + h('.col-lg-4.col-sm-6.col-xs-12.graph-ctn', [ + h('strong', 'Rotation'), + state.charts.rotation + ]), + h('.col-lg-4.col-sm-6.col-xs-12.graph-ctn', [ + h('strong', 'Motors speed'), + state.charts.motorsSpeed + ]), + h('.col-lg-4.col-sm-6.col-xs-12', [ + h('strong', 'Graphs settings'), + h('.form-inline', [ + h('label', { htmlFor: 'chart-axes-btn' }, 'Show axes:'), + h('select#chart-axes-btn.form-control', { + name: 'axes', + 'ev-event': hg.sendChange(state.channels.changeVisibleAxes) + }, [ + h('option', 'x, y, z'), + h('option', 'x'), + h('option', 'y'), + h('option', 'z') + ]) + ]) + ]) + ]); +}; + +module.exports = Charts; diff --git a/public/js/widget/config.js b/public/js/widget/config.js new file mode 100644 index 0000000..7e244f3 --- /dev/null +++ b/public/js/widget/config.js @@ -0,0 +1,207 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var extend = require('extend'); +var expand = require('expand-flatten').expand; +var exportFile = require('../export'); +var formGroup = require('../component/form-group'); + +function Config(quad) { + var state = hg.state({ + config: hg.value(), + channels: { + change: function (state, data) { + data = cast(state.config(), expand(data)); + quad.cmd.send('config', data); + }, + export: function (state) { + exportFile({ + type: 'application/json', + body: JSON.stringify(state.config(), null, '\t') + }); + } + } + }); + + quad.on('config', function (config) { + state.config.set(config); + }); + + quad.cmd.on('config', function (config) { + state.config.set(extend(state.config(), config)); + }); + + return state; +} + +function cast(from, to) { + if (from instanceof Array) { + to = Object.keys(to).map(function (key, i) { + return cast(from[i], to[key]); + }); + } else if (typeof from === 'object') { + for (var key in to) { + to[key] = cast(from[key], to[key]); + } + } else if (typeof from === 'number') { + if (typeof to === 'string') { + to = parseFloat(to); + } + } + + return to; +} + +Config.render = function (state) { + if (!state.config) { + return h('div'); + } + + return h('form.form-horizontal', { 'ev-submit': hg.sendSubmit(state.channels.change) }, [ + h('.row', [ + h('.col-sm-6', [ + hg.partial(servos, state.config.servos), + hg.partial(broadcastInterval, state.config.broadcastInterval), + //hg.partial(physics, state.config.physics) + ]), + h('.col-sm-6', controller(state.config)) + ]), + h('.row', h('.col-xs-12', h('.form-group', h('.col-sm-offset-2.col-sm-10', [ + h('button.btn.btn-primary', { type: 'submit' }, 'Update'), + ' ', + h('button.btn.btn-default', { + type: 'button', + 'ev-click': hg.sendClick(state.channels.export) + }, 'Export') + ])))) + ]); +}; + +function servos(state) { + return h('div', [ + formGroup('Servos pins', state.pins.map(function (pin, i) { + return [h('input.form-control', { + type: 'number', + name: 'servos.pins.' + i, + value: pin, + size: 1 + }), ' ']; + })), + formGroup('Servos PWM output range', [ + h('input.form-control', { type: 'number', name: 'servos.range.0', value: state.range[0] }), + ' - ', + h('input.form-control', { type: 'number', name: 'servos.range.1', value: state.range[1] }), + ' (x10µs)' + ]) + ]); +} + +function broadcastInterval(state) { + return formGroup('Broadcast interval', [ + h('div', [ + 'OS status: ', + h('input.form-control', { + type: 'number', + name: 'broadcastInterval.osStatus', + value: state.osStatus, + step: 'any' + }), + ' s' + ]), + h('div', [ + 'Orientation: ', + h('input.form-control', { + type: 'number', + name: 'broadcastInterval.orientation', + value: state.orientation, + step: 'any' + }), + ' s' + ]) + ]); +} + +function physics(state) { + return formGroup('Physics', [ + h('div', [ + 'Motor mass: ', + h('input.form-control', { + type: 'number', + name: 'physics.motorMass', + value: state.motorMass, + step: 'any' + }), + ' g' + ]), + h('div', [ + 'Structure mass: ', + h('input.form-control', { + type: 'number', + name: 'physics.structureMass', + value: state.structureMass, + step: 'any' + }), + ' g' + ]), + h('div', [ + 'Diagonal length: ', + h('input.form-control', { + type: 'number', + name: 'physics.diagonalLength', + value: state.diagonalLength, + step: 'any' + }), + ' cm' + ]) + ]); +} + +function controller(state) { + return formGroup('Controller', [ + h('div', [ + 'Interval: ', + h('input.form-control', { + type: 'number', + name: 'controller.interval', + value: state.controller.interval, + step: 'any', + min: 0 + }), + ' ms' + ]), + updaters(state) + ]); +} + +function updaters(state) { + var config = state.updaters[state.controller.updater]; + switch (state.controller.updater) { + case 'stabilize-simple': + return [ + h('div', [ + 'Rate PID: ', + pid('updaters.stabilize-simple.rate', config.rate) + ]), + h('div', [ + 'Stabilize PID: ', + pid('updaters.stabilize-simple.stabilize', config.stabilize) + ]) + ]; + } + + return ''; +} + +function pid(prefix, values) { + return values.map(function (val, i) { + return [h('input.form-control', { + type: 'number', + name: prefix + '.' + i, + value: val, + step: 'any' + }), ' ']; + }); +} + +module.exports = Config; diff --git a/public/js/widget/console.js b/public/js/widget/console.js new file mode 100644 index 0000000..5de54c6 --- /dev/null +++ b/public/js/widget/console.js @@ -0,0 +1,91 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; + +function Console(quad) { + var state = hg.state({ + messages: hg.array([]) + }); + + function log(item) { + return Console.log(state, item); + } + + quad.on('error', function (msg) { + log({ type: 'error', msg: msg }); + }); + quad.on('info', function (msg) { + log({ type: 'info', msg: msg }); + }); + + quad.on('features', function (features) { + var allGreen = true; + + if (features.hardware.indexOf('motors') === -1) { + log({ type: 'error', msg: 'Motors not available' }); + allGreen = false; + } + if (features.hardware.indexOf('imu') === -1) { + log({ type: 'error', msg: 'Inertial Measurement Unit not available' }); + allGreen = false; + } + + log('Available features: ' + (features.hardware.join(', ') || '(none)')); + + if (allGreen) { + log({ type: 'success', msg: 'ALL GREEN!' }); + } + }); + + quad.on('motors-speed', function (speeds) { + if (!quad.config) { + return; + } + + var range = quad.config.servos.range; + for (var i = 0; i < speeds.length; i++) { + var speed = speeds[i]; + if (speed >= range[1]) { + // Max. motor power reached + log({ type: 'error', msg: 'Motor '+quad.config.servos.pins[i]+' is at full power!' }); + } + } + }); + + quad.client.on('connecting', function () { + log('Connecting to server...'); + }); + + quad.client.on('connected', function () { + log('Connected!'); + }); + + quad.client.on('disconnect', function () { + log('Disconnected from server.'); + }); + + quad.client.on('error', function (err) { + log({ type: 'error', msg: err }); + }); + + return state; +} + +Console.log = function (state, item) { + if (typeof item === 'string') { + item = { msg: item }; + } + + state.messages.push(item); +}; + +Console.render = function (state) { + return h('#console.container-fluid', [ + h('pre', state.messages.map(function (item) { + return h('span.' + (item.type || 'log'), item.msg + '\n'); + })) + ]); +}; + +module.exports = Console; diff --git a/public/js/widget/controller-btn.js b/public/js/widget/controller-btn.js new file mode 100644 index 0000000..97365b6 --- /dev/null +++ b/public/js/widget/controller-btn.js @@ -0,0 +1,41 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; + +function ControllerBtn(quad) { + var state = hg.state({ + value: hg.value('dummy'), + updaters: hg.array([]), + channels: { + change: function (state, data) { + state.value.set(data.controller); + quad.setConfig({ controller: { updater: data.controller } }); + } + } + }); + + quad.on('features', function (data) { + state.updaters.set(data.updaters); + }); + + quad.on('config', function (config) { + state.value.set(config.controller.updater); + }); + + return state; +} + +ControllerBtn.render = function (state) { + return h('.form-inline', [ + h('label.control-label', { htmlFor: 'controller-btn' }, 'Controller:'), + h('select#controller-btn.form-control', { + name: 'controller', + 'ev-event': hg.sendChange(state.channels.change) + }, state.updaters.map(function (name) { + return h('option', { selected: (state.value === name) }, name); + })) + ]); +}; + +module.exports = ControllerBtn; diff --git a/public/js/widget/enable-btn.js b/public/js/widget/enable-btn.js new file mode 100644 index 0000000..51c1f6c --- /dev/null +++ b/public/js/widget/enable-btn.js @@ -0,0 +1,34 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var Switch = require('../component/switch'); + +function EnableBtn(quad) { + var sw = Switch('enable-switch'); + + var state = hg.state({ + switch: sw, + value: sw.value + }); + + hg.watch(state.value, function (val) { + if (quad.enabled == val) return; + quad.cmd.send('enable', val); // TODO: move this in change() ? + }); + + quad.on('enabled', function (val) { + state.value.set(val); + }); + + return state; +} + +EnableBtn.render = function (state) { + return h('div', { title: 'Alt+S to start/stop, Esc to stop' }, [ + Switch.render(state.switch), + h('label', { htmlFor: state.switch.id }, 'ENABLE') + ]); +}; + +module.exports = EnableBtn; diff --git a/public/js/widget/motors-summary.js b/public/js/widget/motors-summary.js new file mode 100644 index 0000000..f567aa1 --- /dev/null +++ b/public/js/widget/motors-summary.js @@ -0,0 +1,46 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var roundTo = require('round-to'); + +function MotorsSummary(quad) { + var state = hg.state({ + speed: hg.value([]), + force: hg.value([]) + }); + + quad.on('motors-speed', function (speed) { + state.speed.set(speed); + }); + + quad.on('motors-forces', function (force) { + state.force.set(force); + }); + + return state; +} + +MotorsSummary.render = function (state) { + return h('.row', [ + h('.col-sm-6', [ + h('p', [ + 'Motors speed (x10µs):' + ].concat(renderMotors(state.speed))) + ]), + h('.col-sm-6', [ + h('p', [ + 'Motors force (N):' + ].concat(renderMotors(state.force))) + ]), + ]); +}; + +function renderMotors(values) { + // TODO: use quad.config.servos.pins[i] to index motors + return values.map(function (val, i) { + return [h('br'), i + ': ' + roundTo(val, 2)]; + }); +} + +module.exports = MotorsSummary; diff --git a/public/js/widget/orientation-summary.js b/public/js/widget/orientation-summary.js new file mode 100644 index 0000000..78dbe12 --- /dev/null +++ b/public/js/widget/orientation-summary.js @@ -0,0 +1,40 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var roundTo = require('round-to'); + +function OrientationSummary(quad) { + var state = hg.state({ + gyro: hg.value({}), + accel: hg.value({}), + rotation: hg.value({}), + temp: hg.value(0) + }); + + quad.on('orientation', function (data) { + state.gyro.set(data.gyro); + state.accel.set(data.accel); + state.rotation.set(data.rotation); + state.temp.set(data.temp); + }); + + return state; +} + +OrientationSummary.render = function (state) { + return h('p', [ + 'Gyro (°/s): ' + renderValues(state.gyro), h('br'), + 'Accel (g): ' + renderValues(state.accel), h('br'), + 'Rotation (°): ' + renderValues(state.rotation), h('br'), + 'Temperature (°C): ' + Math.round(state.temp) + ]); +}; + +function renderValues(values) { + return Object.keys(values).map(function (name) { + return roundTo(values[name], 2); + }).join(', '); +} + +module.exports = OrientationSummary; diff --git a/public/js/widget/outline.js b/public/js/widget/outline.js new file mode 100644 index 0000000..7a9e046 --- /dev/null +++ b/public/js/widget/outline.js @@ -0,0 +1,76 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var colors = require('../colors'); +var InlineSvg = require('../component/inline-svg'); + +function speedRatio(quad) { + if (!quad.config) return function () { return 0; }; + + var range = quad.config.servos.range; + return function (speed) { + return (speed - range[0]) / (range[1] - range[0]); + }; +} + +function Outline(quad, url, motors, axis) { + var state = hg.state({ + svg: InlineSvg(url), + rotation: hg.value(0) + }); + + if (motors) { + quad.on('motors-speed', function (speed) { + var propellers = state.svg.element.querySelector('#propellers'); + if (!propellers) return; + + speed.filter(function (s, i) { + return (motors.indexOf(i) !== -1); + }).map(speedRatio(quad)).forEach(function (s, i) { + var color = colors.shade(colors.getForPercentage(1 - s), -0.5); + propellers.children[i].style.fill = colors.toRgb(color); + }); + }); + } + + if (axis) { + quad.on('orientation', function (data) { + state.rotation.set(data.rotation[axis]); + }); + } + + return state; +} + +Outline.renderer = function (name) { + return function (state) { + return h(name+'.quadcopter-outline', { + style: { transform: 'rotate(' + state.rotation + 'deg)' } + }, state.svg); + }; +}; + +Outline.Top = function (quad) { + return Outline(quad, 'assets/quadcopter-top.svg', [0, 1, 2, 3]); +}; + +Outline.Top.render = Outline.renderer('#quadcopter-outline-top'); + +Outline.Front = function (quad) { + return Outline(quad, 'assets/quadcopter-side.svg', [0, 2], 'x'); +}; + +Outline.Front.render = Outline.renderer('#quadcopter-outline-front.quadcopter-outline-side'); + +Outline.Right = function (quad) { + return Outline(quad, 'assets/quadcopter-side.svg', [1, 3], 'y'); +}; + +Outline.Right.render = Outline.renderer('#quadcopter-outline-right.quadcopter-outline-side'); + +module.exports = { + Top: Outline.Top, + Front: Outline.Front, + Right: Outline.Right +}; diff --git a/public/js/widget/power-input.js b/public/js/widget/power-input.js new file mode 100644 index 0000000..ff8ea2d --- /dev/null +++ b/public/js/widget/power-input.js @@ -0,0 +1,49 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var AttributeHook = require('../hook/attribute'); + +function PowerInput(quad) { + var state = hg.state({ + value: hg.value(0), + channels: { + change: change + } + }); + + hg.watch(state.value, function (val) { + if (quad.power === val) return; + quad.power = val; + }); + + // TODO: debounce bug + quad.on('power', function (val) { + if (state.value() === val) return; + state.value.set(val); + }); + + return state; +} + +function change(state, data) { + state.value.set(parseFloat(data.power) / 100); +} + +PowerInput.render = function (state) { + return h('div', { title: 'Arrow up/down' }, [ + h('strong', 'Power'), + h('input.power-input', { + type: 'range', + name: 'power', + value: state.value * 100, + min: 0, + max: 100, + step: 5, + orient: AttributeHook('vertical'), + 'ev-event': hg.sendChange(state.channels.change) + }) + ]); +}; + +module.exports = PowerInput; diff --git a/public/js/widget/system-summary.js b/public/js/widget/system-summary.js new file mode 100644 index 0000000..bbef2e7 --- /dev/null +++ b/public/js/widget/system-summary.js @@ -0,0 +1,45 @@ +'use strict'; + +var hg = require('mercury'); +var h = require('mercury').h; +var colors = require('../colors'); + +function SystemSummary(quad) { + var state = hg.state({ + loadavg: hg.value([0, 0, 0]), + mem: hg.struct({ free: 0, total: 0 }) + }); + + quad.on('os-stats', function (data) { + state.loadavg.set(data.loadavg); + state.mem.set(data.mem); + }); + + return state; +} + +SystemSummary.render = function (state) { + function ratioColor(ratio) { + return colors.toRgb(colors.shade(colors.getForPercentage(1 - ratio), -0.5)); + } + + var memRatio = state.mem.free / state.mem.total || 0; + + return h('p', [ + h('span', ['Load average: '].concat(state.loadavg.map(function (avg) { + var pct = Math.round(avg * 100); + return h('span', { + style: { color: ratioColor(avg) } + }, pct + '% '); + }))), + h('br'), + h('span', [ + 'Memory: ', + h('span', { + style: { color: ratioColor(memRatio) } + }, state.mem.free + '/' + state.mem.total + ' ('+Math.round(memRatio * 100)+'%)') + ]) + ]); +}; + +module.exports = SystemSummary;