Added ReST API and a simple web application

Added OpenSCAD file for the project case
This commit is contained in:
Mark van Renswoude 2017-03-25 16:36:04 +01:00
parent b3d980c2ac
commit 4fbbee8f5b
11 changed files with 663 additions and 43 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
.piolibdeps .piolibdeps
src/credentials.h src/credentials.h
*.sublime-workspace *.sublime-workspace
web/node_modules/

97
case/StairCase.scad Normal file
View File

@ -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]);
}
}

View File

@ -110,5 +110,6 @@ Lights one step at a time, moving up or down.
Parameters:<br> Parameters:<br>
**interval** (word): How long each step is lit before moving to the next.<br> **interval** (word): How long each step is lit before moving to the next.<br>
**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).<br> **direction** (byte): Determines the starting step / direction. Either Bottom/Up (0) or Top/Down (1).<br>
**fadeOutTime** (word): If greater than 0 each step will fade out instead of turning off instantly after moving to the next. Specified in milliseconds. **fadeOutTime** (word): If greater than 0 each step will fade out instead of turning off instantly after moving to the next. Specified in milliseconds.

View File

@ -134,11 +134,13 @@ void handleRequest(uint8_t* packet)
{ {
case Command::Ping: case Command::Ping:
udpServer.write(Command::Ping); udpServer.write(Command::Ping);
udpServer.write(StepCount);
break; break;
case Command::GetMode: case Command::GetMode:
udpServer.write(Command::GetMode); udpServer.write(Command::GetMode);
udpServer.write(currentModeIdentifier); udpServer.write(currentModeIdentifier);
currentMode->write(&udpServer);
break; break;
case Command::SetMode: case Command::SetMode:
@ -163,6 +165,7 @@ void handleRequest(uint8_t* packet)
{ {
udpServer.write(Command::Error); udpServer.write(Command::Error);
udpServer.write(Command::SetMode); udpServer.write(Command::SetMode);
udpServer.write(newIdentifier);
} }
break; break;
@ -186,7 +189,7 @@ IMode* createMode(uint8_t identifier)
switch (identifier) switch (identifier)
{ {
case Mode::Static: return new StaticMode(); 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::Alternate: return new AlternateMode();
//case Mode::Slide: return new SlideMode(); //case Mode::Slide: return new SlideMode();
//case Mode::ADC: return new ADCInputMode(); //case Mode::ADC: return new ADCInputMode();

View File

@ -1,50 +1,55 @@
var protocol = require('./protocol'); var express = require('express');
var dgram = require('dgram'); var client = require('./client');
var on = 0; var httpPort = 3127;
var speed = 256;
var stairsHost = '10.138.2.12';
var stairsUdpPort = 3126;
function lsb(value) { return value & 0xFF; } client.init(stairsHost, stairsUdpPort);
function msb(value) { return (value >> 8) & 0xFF; }
var app = express();
app.get('/ping', function(req, res)
/*
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()
{ {
var address = client.address(); client.ping(function(data, error)
console.log('UDP client listening on ' + address.address + ":" + address.port); {
}); if (error)
res.status(500);
client.on('message', function (message, remote) res.send(data);
{
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'));
}); });
}, 200); });
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);
});

233
web/client.js Normal file
View File

@ -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);
});
}
}

View File

@ -4,5 +4,9 @@
"description": "Stairs lighting project", "description": "Stairs lighting project",
"main": "app.js", "main": "app.js",
"author": "Mark van Renswoude", "author": "Mark van Renswoude",
"license": "ISC" "license": "ISC",
"dependencies": {
"buffer-reader": "^0.1.0",
"express": "^4.15.2"
}
} }

66
web/static/index.html Normal file
View File

@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#000000" />
<title>Stairs demo</title>
<script src="https://code.jquery.com/jquery-3.2.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.1/knockout-min.js"></script>
<script src="script.js"></script>
<link rel="stylesheet" type="text/css" href="style.css" />
</head>
<body>
<div class="title">
<img src="loader.gif" class="loader" data-bind="attr: { 'style': 'visibility: ' + (loading() ? 'visible' : 'hidden') }" />
<div class="main"><span style="color:#ff0000;">T</span><span style="color:#ff1200;">h</span><span style="color:#ff2400;">e</span><span style="color:#ff3600;"> </span><span style="color:#ff4900;">A</span><span style="color:#ff5b00;">m</span><span style="color:#ff6d00;">a</span><span style="color:#ff7f00;">z</span><span style="color:#ff9100;">i</span><span style="color:#ffa400;">n</span><span style="color:#ffb600;">g</span><span style="color:#ffc800;"> </span><span style="color:#ffda00;">S</span><span style="color:#ffed00;">t</span><span style="color:#ffff00;">a</span><span style="color:#dbff00;">i</span><span style="color:#b6ff00;">r</span><span style="color:#92ff00;">s</span><span style="color:#6dff00;"> </span><span style="color:#49ff00;">L</span><span style="color:#24ff00;">i</span><span style="color:#00ff00;">g</span><span style="color:#00ff24;">h</span><span style="color:#00ff49;">t</span><span style="color:#00ff6d;">i</span><span style="color:#00ff92;">n</span><span style="color:#00ffb6;">g</span><span style="color:#00ffdb;"> </span><span style="color:#00ffff;">F</span><span style="color:#00dbff;">r</span><span style="color:#00b6ff;">o</span><span style="color:#0092ff;">n</span><span style="color:#006dff;">t</span><span style="color:#0049ff;">e</span><span style="color:#0024ff;">n</span><span style="color:#0000ff;">d</span></div>
<div class="sub">"Much web technology. Wow." <span class="quote">- No one, ever</span></div>
</div>
<div class="container">
<div class="header">Mode</div>
<div class="mode">
<div class="selection"><input type="radio" name="mode" value="Static" id="Static" data-bind="checked: mode" /><label for="Static">Static</label></div>
<div class="selection"><input type="radio" name="mode" value="Custom" id="Custom" data-bind="checked: mode" /><label for="Custom">Custom</label></div>
<div class="selection"><input type="radio" name="mode" value="Alternate" id="Alternate" data-bind="checked: mode" /><label for="Alternate">Alternating</label></div>
<div class="selection"><input type="radio" name="mode" value="Slide" id="Slide" data-bind="checked: mode" /><label for="Slide">Sliding</label></div>
</div>
<div class="parameters" data-bind="visible: mode() == 'Static'" style="display: none">
<div class="header">Static parameters</div>
<div class="parameter">
Brightness: <input type="range" min="0" max="4095" data-bind="value: static.brightness, valueUpdate: 'input'" />
</div>
</div>
<div class="parameters" data-bind="visible: mode() == 'Custom'" style="display: none">
<div class="header">Custom parameters</div>
<!-- ko foreach: custom.brightness -->
<div class="parameter">
Step <span data-bind="text: $root.custom.brightness().length - $index()"></span>: <input type="range" min="0" max="4095" data-bind="value: $data, valueUpdate: 'input'" />
</div>
<!-- /ko -->
</div>
<div class="parameters" data-bind="visible: mode() == 'Alternate'" style="display: none">
<div class="header">Alternating parameters</div>
<div class="parameter">
Interval: <input type="number" data-bind="value: alternate.interval" />
</div>
<div class="parameter">
Brightness: <input type="range" min="0" max="4095" data-bind="value: alternate.brightness, valueUpdate: 'input'" />
</div>
</div>
<div class="parameters" data-bind="visible: mode() == 'Slide'" style="display: none">
<div class="header">Sliding parameters</div>
<div class="parameter">
Brightness: <input type="range" min="0" max="4095" data-bind="value: slide.brightness, valueUpdate: 'input'" />
</div>
</div>
</div>
</body>
</html>

BIN
web/static/loader.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 847 B

132
web/static/script.js Normal file
View File

@ -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();
});

78
web/static/style.css Normal file
View File

@ -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;
}