diff --git a/.gitignore b/.gitignore index 425213f..ca51fc1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .piolibdeps src/credentials.h *.sublime-workspace +web/node_modules/ diff --git a/case/StairCase.scad b/case/StairCase.scad new file mode 100644 index 0000000..74bcce5 --- /dev/null +++ b/case/StairCase.scad @@ -0,0 +1,97 @@ +caseBottom(); + +$fa = 1; +$fs = 1; + +module caseBottom() +{ + innerX = 80; + innerY = 70; + innerZ = 35; + wallThickness = 2; + + totalX = innerX + (2 * wallThickness); + totalY = innerY + (2 * wallThickness); + totalZ = innerZ + wallThickness; + + difference() + { + cube([totalX, totalY, totalZ]); + translate([wallThickness, wallThickness, wallThickness]) + cube([innerX, innerY, totalZ]); + + // Hole for the LED cables + translate([-1, totalY / 2, 25]) + rotate([0, 90, 0]) + cylinder(d = 15, h = wallThickness + 2); + + // Hole for the power cable + translate([62, totalY + 1, wallThickness + 2.5]) + rotate([90, 0, 0]) + cylinder(d = 5, h = wallThickness + 2); + } + + translate([wallThickness, wallThickness, wallThickness]) + { + translate([4, 4, 0]) + PCA9685Mount(); + + translate([40, 20, 0]) + ESP8266PlusPowerMount(); + } +} + + +module PCA9685Mount() +{ + totalX = 25.5; + totalY = 62.5; + pinDistanceX = 19; + pinDistanceY = 56; + pinDiameter = 2.3; + pinHeight = 3; + supportDiameter = 4; + supportHeight = 4; + + offsetX = (totalX - pinDistanceX) / 2; + offsetY = (totalY - pinDistanceY) / 2; + + for (x = [offsetX, offsetX + pinDistanceX]) + for (y = [offsetY, offsetY + pinDistanceY]) + translate([x, y, 0]) + { + cylinder(d = supportDiameter, h = supportHeight); + translate([0, 0, supportHeight]) + cylinder(d = pinDiameter, h = pinHeight); + } +} + + +// I didn't put any actual mount points in the perfboard, +// so I'll settle for some corner pieces to align it and +// use the ultimate maker's friend: hot glue. +module ESP8266PlusPowerMount() +{ + innerX = 34; + innerY = 35; + cornerSize = 6; + cornerThickness = 2; + cornerHeight = 2; + + totalX = innerX + (2 * cornerThickness); + totalY = innerY + (2 * cornerThickness); + + difference() + { + cube([totalX, totalY, cornerHeight]); + + translate([cornerThickness, cornerThickness, -1]) + cube([innerX, innerY, cornerHeight + 2]); + + translate([-1, cornerSize, -1]) + cube([totalX + 2, totalY - (2 * cornerSize), cornerHeight + 2]); + + translate([cornerSize, -1, -1]) + cube([totalX - (2 * cornerSize), totalY + 2, cornerHeight + 2]); + } +} \ No newline at end of file diff --git a/docs/protocol.md b/docs/protocol.md index dba5acd..a544416 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -110,5 +110,6 @@ Lights one step at a time, moving up or down. Parameters:
**interval** (word): How long each step is lit before moving to the next.
+**brightness** (word): value in range 0 (off) to 4095 (fully on). **direction** (byte): Determines the starting step / direction. Either Bottom/Up (0) or Top/Down (1).
**fadeOutTime** (word): If greater than 0 each step will fade out instead of turning off instantly after moving to the next. Specified in milliseconds. \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index 96837d1..b550923 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -134,11 +134,13 @@ void handleRequest(uint8_t* packet) { case Command::Ping: udpServer.write(Command::Ping); + udpServer.write(StepCount); break; case Command::GetMode: udpServer.write(Command::GetMode); udpServer.write(currentModeIdentifier); + currentMode->write(&udpServer); break; case Command::SetMode: @@ -163,6 +165,7 @@ void handleRequest(uint8_t* packet) { udpServer.write(Command::Error); udpServer.write(Command::SetMode); + udpServer.write(newIdentifier); } break; @@ -186,7 +189,7 @@ IMode* createMode(uint8_t identifier) switch (identifier) { case Mode::Static: return new StaticMode(); - //case Mode::Custom: return new CustomMode(); + case Mode::Custom: return new CustomMode(); case Mode::Alternate: return new AlternateMode(); //case Mode::Slide: return new SlideMode(); //case Mode::ADC: return new ADCInputMode(); diff --git a/web/app.js b/web/app.js index d110d35..cbdadb3 100644 --- a/web/app.js +++ b/web/app.js @@ -1,50 +1,55 @@ -var protocol = require('./protocol'); -var dgram = require('dgram'); +var express = require('express'); +var client = require('./client'); -var on = 0; -var speed = 256; +var httpPort = 3127; + +var stairsHost = '10.138.2.12'; +var stairsUdpPort = 3126; -function lsb(value) { return value & 0xFF; } -function msb(value) { return (value >> 8) & 0xFF; } +client.init(stairsHost, stairsUdpPort); +var app = express(); - -/* -Alternating - -var message = new Buffer([protocol.Command.SetMode, protocol.Mode.Alternate, lsb(500), msb(500), lsb(128), msb(128)]); -client.send(message, 0, message.length, 3126, '10.138.2.12', function(err, bytes) { - if (err) throw err; - console.log('UDP message sent'); -}); -*/ - - -var client = dgram.createSocket('udp4'); -client.on('listening', function() +app.get('/ping', function(req, res) { - var address = client.address(); - console.log('UDP client listening on ' + address.address + ":" + address.port); -}); + client.ping(function(data, error) + { + if (error) + res.status(500); -client.on('message', function (message, remote) -{ - console.log('< ' + remote.address + ':' + remote.port +' - ' + message.toString('hex')); -}); - - -setInterval(function() -{ - // 0x00, 0x10 = 4096 - on += speed; - if (on <= 0 || on >= 4096) - speed = -speed; - - var message = new Buffer([protocol.Command.SetMode, protocol.Mode.Static, lsb(on), msb(on)]); - client.send(message, 0, message.length, 3126, '10.138.2.12', function(err, bytes) { - if (err) throw err; - console.log('> ' + '10.138.2.12' + ':' + '3126' + ' - ' + message.toString('hex')); + res.send(data); }); -}, 200); \ No newline at end of file +}); + +app.get('/getMode', function(req, res) +{ + client.getMode(function(data, error) + { + if (error) + res.status(500); + + res.send(data); + }); +}); + +app.get('/setMode/:mode', function(req, res) +{ + client.setMode(req.params.mode, req.query, function(data, error) + { + if (error) + res.status(500); + + res.send(data); + }); +}); + +app.use(express.static('static')); + + + +app.listen(httpPort, function () +{ + console.log('Stairs ReST service running on port ' + httpPort); +}); \ No newline at end of file diff --git a/web/client.js b/web/client.js new file mode 100644 index 0000000..c314f22 --- /dev/null +++ b/web/client.js @@ -0,0 +1,233 @@ +var dgram = require('dgram'); +var protocol = require('./protocol'); +var BufferReader = require('buffer-reader'); + + +var responseHandlers = {}; + +function registerResponseHandler(command, callback) +{ + if (!responseHandlers.hasOwnProperty(command)) + responseHandlers[command] = [callback]; + else + responseHandlers[command].push(callback); +} + + +function callResponseHandlers(command, reader, error) +{ + if (!responseHandlers.hasOwnProperty(command)) + return; + + newHandlers = []; + responseHandlers[command].forEach(function(callback) + { + if (!callback(reader, error)) + newHandlers.push(callback); + }); + + responseHandlers[command] = newHandlers; +} + + +var serverHost = ''; +var serverPort = 0; + +var client = dgram.createSocket('udp4'); +client.on('message', function (message, remote) +{ + console.log(message.toString('hex')); + if (message.length < 2) + return; + + var reader = new BufferReader(message); + + if (reader.nextInt8() !== protocol.Command.Reply) + return; + + var command = reader.nextInt8(); + if (command === protocol.Command.Error) + callResponseHandlers(reader.nextInt8(), reader, true) + else + callResponseHandlers(command, reader, false); +}); + + +function requestResponse(buffer, callback) +{ + if (buffer === null || buffer.length == 0) return; + console.log('> ' + buffer.toString('hex')); + + var command = buffer.readInt8(0); + var cancelled = false; + + var timeout = setTimeout(function() + { + cancelled = true; + callback(null, true); + clearTimeout(timeout); + }, 2000); + + registerResponseHandler(command, function(reader, error) + { + if (cancelled) return; + clearTimeout(timeout); + + callback(reader, error); + return true; + }); + + client.send(buffer, 0, buffer.length, serverPort, serverHost, function(err, bytes) + { + if (err) + onError(); + }); +} + + +function readModeData(mode, reader) +{ + switch (mode) + { + case protocol.Mode.Static: + return { + brightness: reader.nextInt16LE() + }; + + case protocol.Mode.Custom: + var values = []; + while (reader.tell() < reader.buf.length) + values.push(reader.nextInt16LE()); + + return { + brightness: values + }; + + case protocol.Mode.Alternate: + return { + interval: reader.nextInt16LE(), + brightness: reader.nextInt16LE() + }; + + case protocol.Mode.Slide: + return { + interval: reader.nextInt16LE(), + brightness: reader.nextInt16LE(), + direction: reader.nextInt8(), + fadeOutTime: reader.nextInt16LE() + }; + } + + return null; +} + + +function lsb(value) { return value & 0xFF; } +function msb(value) { return (value >> 8) & 0xFF; } + +function writeModeData(mode, data) +{ + switch (mode) + { + case protocol.Mode.Static: + if (typeof(data.brightness) == 'undefined') data.brightness = 0; + + return new Buffer([protocol.Command.SetMode, mode, lsb(data.brightness), msb(data.brightness)]); + + case protocol.Mode.Custom: + var brightness = typeof(data.brightness) !== 'undefined' ? data.brightness.split(',') : []; + + var valueCount = Math.min(16, brightness.length); + var buffer = Buffer.alloc(2 + (valueCount * 2)); + buffer.writeInt8(protocol.Command.SetMode, 0); + buffer.writeInt8(mode, 1); + + for (var index = 0; index < valueCount; index++) + buffer.writeInt16LE(brightness[index], 2 + (index * 2)); + + return buffer; + + case protocol.Mode.Alternate: + if (typeof(data.brightness) == 'undefined') data.brightness = 0; + if (typeof(data.interval) == 'undefined') data.interval = 500; + + return new Buffer([protocol.Command.SetMode, mode, + lsb(data.interval), msb(data.interval), + lsb(data.brightness), msb(data.brightness)]); + + case protocol.Mode.Slide: + if (typeof(data.brightness) == 'undefined') data.brightness = 0; + if (typeof(data.interval) == 'undefined') data.interval = 500; + if (typeof(data.direction) == 'undefined') data.direction = 0; + if (typeof(data.fadeOutTime) == 'undefined') data.fadeOutTime = 0; + + return new Buffer([protocol.Command.SetMode, mode, + lsb(data.interval), msb(data.interval), + lsb(data.brightness), msb(data.brightness), + data.direction, + lsb(data.fadeOutTime), msb(data.fadeOutTime)]); + } +} + + +module.exports = +{ + init: function(host, port) + { + serverHost = host; + serverPort = port; + }, + + + ping: function(callback) + { + requestResponse(new Buffer([protocol.Command.Ping]), + function(reader, error) + { + if (!error) + { + callback( + { + stepCount: reader.nextInt8() + }, false); + } + else + callback(null, true); + }); + }, + + getMode: function(callback) + { + requestResponse(new Buffer([protocol.Command.GetMode]), + function(reader, error) + { + if (!error) + { + var data = { mode: reader.nextInt8() }; + data.data = readModeData(data.mode, reader); + callback(data, false); + } + else + callback(null, true); + }); + }, + + setMode: function(mode, data, callback) + { + if (!protocol.Mode.hasOwnProperty(mode)) + return; + + requestResponse(writeModeData(protocol.Mode[mode], data), + function(reader, error) + { + if (!error) + { + var data = { mode: reader.nextInt8() }; + data.data = readModeData(data.mode, reader); + callback(data, false); + } + else + callback(null, true); + }); + } +} \ No newline at end of file diff --git a/web/package.json b/web/package.json index 2056609..77a8089 100644 --- a/web/package.json +++ b/web/package.json @@ -4,5 +4,9 @@ "description": "Stairs lighting project", "main": "app.js", "author": "Mark van Renswoude", - "license": "ISC" + "license": "ISC", + "dependencies": { + "buffer-reader": "^0.1.0", + "express": "^4.15.2" + } } diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..4e5c0fa --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,66 @@ + + + + + + +Stairs demo + + + + + + + +
+ + +
The Amazing Stairs Lighting Frontend
+
"Much web technology. Wow." - No one, ever
+
+ +
+
Mode
+
+
+
+
+
+
+ + + + + + + + +
+ + + \ No newline at end of file diff --git a/web/static/loader.gif b/web/static/loader.gif new file mode 100644 index 0000000..13e91a6 Binary files /dev/null and b/web/static/loader.gif differ diff --git a/web/static/script.js b/web/static/script.js new file mode 100644 index 0000000..24d1df7 --- /dev/null +++ b/web/static/script.js @@ -0,0 +1,132 @@ +var StairsViewModel = function() +{ + var self = this; + + self.mode = ko.observable('Static'); + self.static = + { + brightness: ko.observable(0) + }; + + self.custom = + { + brightness: ko.observableArray([]) + }; + + self.alternate = + { + interval: ko.observable(500), + brightness: ko.observable(0) + } + + self.slide = + { + interval: ko.observable(500), + brightness: ko.observable(4096), + direction: ko.observable(0), + fadeOutTime: ko.observable(0) + } + + + self.loading = ko.observable(true); + self.updatingFromServer = false; + + self.autoSetTimeout = null; + self.autoSetMode = ko.computed(function() + { + if (self.loading()) return; + + var url = '/setMode/' + encodeURIComponent(self.mode()); + switch (self.mode()) + { + case 'Static': + url += '?brightness=' + encodeURIComponent(self.static.brightness()); + break; + + case 'Custom': + url += '?brightness=' + encodeURIComponent(self.custom.brightness().map(function(value) { return value(); }).join()); + break; + + case 'Alternate': + url += '?interval=' + encodeURIComponent(self.alternate.interval()) + + '&brightness=' + encodeURIComponent(self.alternate.brightness()); + break; + + case 'Slide': + url += '?interval=' + encodeURIComponent(self.slide.interval()) + + '&brightness=' + encodeURIComponent(self.slide.brightness()) + + '&direction=' + encodeURIComponent(self.slide.direction()) + + '&fadeOutTime=' + encodeURIComponent(self.slide.fadeOutTime()); + break; + } + + + // Exit after checking all the parameters, so the observers + // are properly subscribed + if (self.updatingFromServer) return; + + + if (self.autoSetTimeout !== null) + { + clearTimeout(self.autoSetTimeout); + self.autoSetTimeout = null; + } + + self.autoSetTimeout = setTimeout(function() + { + // TODO retry on failure + $.ajax( + { + url: url, + dataType: 'json', + cache: false + }); + + clearTimeout(self.autoSetTimeout); + self.autoSetTimeout = null; + }, 200); + + return true; + }); + + + self.ping = function() + { + self.loading(true); + + $.ajax( + { + url: '/ping', + dataType: 'json', + cache: false + }) + .done(function(data) + { + self.updatingFromServer = true; + + // Initialize the 'Custom' values based on the step count + var values = []; + for (var index = 0; index < data.stepCount; index++) + values.push(ko.observable(0)); + + self.custom.brightness(values); + self.loading(false); + + self.updatingFromServer = false; + }) + .fail(function() + { + setTimeout(self.ping, 1000); + }); + }; +}; + + + +$(function() +{ + var viewModel = new StairsViewModel(); + ko.applyBindings(viewModel); + + viewModel.ping(); +}); \ No newline at end of file diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..a0a0704 --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,78 @@ +body +{ + background-color: white; + color: black; + + font-family: 'Verdana', 'Arial', sans-serif; + font-size: 12pt; + + margin: 0; + padding: 0; +} + + +.loader +{ + float: right; +} + + +.title +{ + background-color: black; + padding: 8px; +} + +.title .main +{ + font-size: 14pt; + font-weight: bold; +} + +.title .sub +{ + color: white; + font-size: 8pt; +} + +.quote +{ + font-style: italic; +} + + +.container +{ + padding: 8px; +} + + +.header +{ + font-size: 14pt; + font-weight: bold; + + margin-bottom: 8px; +} + + +.mode +{ + margin-bottom: 2em; +} + +.mode .selection +{ + margin: 8px; +} + +.mode .selection > input +{ + margin-right: 8px; +} + + +.parameter +{ + margin-bottom: 8px; +} \ No newline at end of file