initial commit

This commit is contained in:
Daniel Sommer 2021-04-06 16:41:49 +02:00
commit 7be704eeb9
12 changed files with 719 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
package-lock.json
npm-debug.log

14
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,14 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/blinky.js"
}
]
}

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
MIT License Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice (including the next
paragraph) shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# blinky
control your blinkstick via http GET and POST requests
## requirements
- node v8.17.0

28
blinky.js Normal file
View file

@ -0,0 +1,28 @@
// requirements
const blinkstick = require('./libs/blinkstick');
const server = require('./libs/server');
const logger = require('./libs/logger');
const packageJSON = require('./package');
// start the application
main();
// main - let's get this party started
function main() {
server.start()
.then(logger.info)
.then(server.handleRequests)
.catch(exit);
}
// ... and it all comes crashing down
function exit(err) {
let code = 0;
if (err) {
logger.error(err);
logger.error(packageJSON.name + " ended due to an error");
} else {
logger.info(packageJSON.name + " shutting down gracefully")
}
process.exit(code);
}

43
config.json Normal file
View file

@ -0,0 +1,43 @@
{
"server": {
"listen": "0.0.0.0",
"port": 3000,
"timestamp": "DD.MM.YYYY HH:mm:ss:SS"
},
"log": {
"level": "info"
},
"api": {
"get": {
"description": "show this page"
},
"post": {
"mode": {
"available": "set, morph, pulse",
"default": "set",
"description": "specifies the color change mode"
},
"color": {
"available": "random, hex color codes (#ffffff), rgb color codes (255, 255, 255)",
"default": "random",
"description": "specifies the color to change to"
},
"duration": {
"available": "number values",
"default": 1000,
"description": "specifies the duration of the color change animation in milliseconds"
},
"steps": {
"available": "number values",
"default": "[duration] / 10",
"description": "specifies the amount of steps for the color change"
},
"pulses": {
"restrictions": "pulse",
"available": "number values",
"default": "0 (infinite)",
"description": "specifies the number of pulses"
}
}
}
}

259
libs/blinkstick.js Normal file
View file

@ -0,0 +1,259 @@
// requirements
const logger = require('./logger');
// third party requirements
const blinkstick = require('blinkstick');
const async = require('async');
const hexcolor = require('hex-color-regex');
// constants
const COLORTYPE_RGB = "rgb";
const COLORTYPE_HEX = "hex";
const ANIMATION_STATE_INFINITE = 2;
const ANIMATION_STATE_INPROGRESS = 1;
const ANIMATION_STATE_FINISH = 0;
// variables
let led;
let animation = {};
function findBlinkstick() {
return new Promise(function (resolve, reject) {
led = blinkstick.findFirst();
if (led !== undefined && led !== null) {
return resolve(led);
}
return reject("could not find any blinkstick");
});
}
// turn the blinkstick off
function powerOff() {
led.turnOff();
logger.info("blinkstick powered off");
}
// parse a color object from given request arguments
function parseColor(blinkstickConfig) {
return new Promise(function (resolve, reject) {
if (blinkstickConfig.color == "random") {
resolve(color);
}
if (blinkstickConfig.color == undefined || blinkstickConfig.color.length === 0) {
reject("no hex color code given, request will be ignored");
}
let parsedColor = parseRGBColor(blinkstickConfig.color);
if (!parsedColor) {
parsedColor = parseHexColor(blinkstickConfig.color);
}
if (!parsedColor) {
return reject("could not parse given color code '" + color + "'");
}
blinkstickConfig.color = parsedColor;
resolve(blinkstickConfig);
});
}
// parse rgb color values
function parseRGBColor(colorValues) {
let color = {};
if (colorValues.indexOf(",") == -1) {
if (isRGBValue(colorValues)) {
color.type = COLORTYPE_RGB;
color.red = parseInt(colorValues);
return color;
}
} else {
const splittedValues = colorValues.split(",");
for (let index = 0; index < splittedValues.length; index++) {
const value = splittedValues[index];
if (!isRGBValue(value)) {
continue;
}
if (index == 0) {
color.red = parseInt(value);
} else if (index === 1) {
color.green = parseInt(value);
} else if (index === 2) {
color.blue = parseInt(value);
}
}
if (color && (color.red || color.green || color.blue)) {
color.type = COLORTYPE_RGB;
return color;
}
}
if (isRGBValue(colorValues.red) || isRGBValue(colorValues.green) || isRGBValue(colorValues.blue)) {
color.type = COLORTYPE_RGB;
return colorValues;
}
return undefined;
}
// check a single rgb value
function isRGBValue(value) {
return value != undefined && value.length > 0 && !isNaN(value) && value >= 0 && value <= 255;
}
// parse hex color values
function parseHexColor(colorValues) {
if (colorValues[0] !== '#') {
colorValues = '#' + colorValues;
}
if (colorValues.length === 4) {
colorValues.type = COLORTYPE_HEX;
return (colorValues[0] + colorValues[1] + colorValues[1] + colorValues[2] + colorValues[2] + colorValues[3] + colorValues[3]);
}
if (colorValues.length === 7 && hexcolor({ strict: true }).test(colorValues)) {
colorValues.type = COLORTYPE_HEX;
return colorValues;
}
return undefined;
}
// pass the options to the blinkstick accordingly
function illuminate(blinkstickConfig) {
return new Promise(function (resolve, reject) {
waitForAnimation
let color;
findBlinkstick().then(function () {
if (blinkstickConfig.color.type === COLORTYPE_HEX) {
color = blinkstickConfig.color.red;
} else {
color = blinkstickConfig.color.red + ", " + blinkstickConfig.color.green + ", " + blinkstickConfig.color.blue;
}
switch (blinkstickConfig.mode) {
case "morph":
morph(blinkstickConfig, color, resolve, reject);
break;
case "pulse":
startPulsing(blinkstickConfig, color, resolve, reject);
break;
default:
setColor(blinkstickConfig, color, resolve, reject);
break;
}
})
.catch(reject);
});
}
// set a static color
function setColor(blinkstickConfig, color, resolve, reject) {
stopAnimation()
.then(function () {
setAnimationProperties(blinkstickConfig);
led.setColor(blinkstickConfig.color.red, blinkstickConfig.color.green, blinkstickConfig.color.blue, function (err) {
clearAnimationProperties();
logger.debug("setting color to '" + color + "'...");
if (err) {
return reject("error setting color '" + color + "' (" + err + ")");
}
return resolve("set color to '" + color + "'");
});
});
}
// morph to a color
function morph(blinkstickConfig, color, resolve, reject) {
stopAnimation()
.then(function () {
setAnimationProperties(blinkstickConfig);
led.morph(blinkstickConfig.color.red, blinkstickConfig.color.green, blinkstickConfig.color.blue, blinkstickConfig.options, function (err) {
clearAnimationProperties();
logger.debug("morphing color to '" + color + "'...");
if (err) {
return reject("error morphing color to '" + color + "' (" + err + ")");
}
return resolve("morphed color to '" + color + "'");
});
});
}
// start pulsing
function startPulsing(blinkstickConfig, color, resolve, reject) {
if (animation.state == ANIMATION_STATE_FINISH || (blinkstickConfig.options.pulse.max > 0 && (blinkstickConfig.options.pulse.done && blinkstickConfig.options.pulse.done < blinkstickConfig.options.pulse.max))) {
clearAnimationProperties();
return resolve("finished pulsing color '" + color + "'");
}
if (animation.id && animation.id != blinkstickConfig.id) {
stopAnimation()
.then(logger.info)
.then(function () {
startPulsing(blinkstickConfig, color, resolve, reject)
})
.catch(logger.error);
return;
}
setAnimationProperties(blinkstickConfig);
led.pulse(blinkstickConfig.color.red, blinkstickConfig.color.green, blinkstickConfig.color.blue, blinkstickConfig.options, function (err) {
if (err) {
clearAnimationProperties();
return reject("error pulsing color '" + color + "' (" + err + ")");
}
blinkstickConfig.options.pulse.done++;
logger.debug("pulsed color '" + color + "' " + blinkstickConfig.options.pulse.done + "/" + blinkstickConfig.options.pulse.max + " times");
startPulsing(blinkstickConfig, color, resolve, reject);
});
}
// set properties for the current animation
function setAnimationProperties(blinkstickConfig) {
if (animation.id == blinkstickConfig.id) {
return;
}
led.animationsEnabled = true;
animation.id = blinkstickConfig.id;
if (blinkstickConfig.options.pulse && blinkstickConfig.options.pulse.max === 0) {
animation.state = ANIMATION_STATE_INFINITE;
} else {
animation.state = ANIMATION_STATE_INPROGRESS;
}
}
// clear properties for the current animation
function clearAnimationProperties() {
led.stop();
animation = {};
}
// stop the current animation
function stopAnimation() {
return new Promise(function (resolve, reject) {
if (!isAnimationInProgress()) {
return resolve();
}
animation.state = ANIMATION_STATE_FINISH;
waitForAnimation(Date.now(), resolve, reject);
});
}
// wait for current animation
function waitForAnimation(timestamp, resolve, reject) {
if (!isAnimationInProgress()) {
return resolve();
}
setTimeout(function () {
waitForAnimation(timestamp, resolve, reject);
}, 100);
}
// is currently an animation in progress
function isAnimationInProgress() {
return animation != undefined && animation.state != undefined;
}
// is currently an infinite animation in progress
function isInfiniteAnimationInProgress() {
return isAnimationInProgress() && animation.state == ANIMATION_STATE_INFINITE;
}
// exports
module.exports = {
parseColor,
illuminate,
isAnimationInProgress,
isInfiniteAnimationInProgress,
stopAnimation,
powerOff
};

112
libs/logger.js Normal file
View file

@ -0,0 +1,112 @@
// requirements
const config = require("../config.json");
// third party requirements
const moment = require("moment");
// constants
const LOG_PREFIX_DEBUG = "debug";
const LOG_PREFIX_INFO = "info";
const LOG_PREFIX_WARNING = "warning";
const LOG_PREFIX_ERROR = "error";
const LOGLEVEL_DEBUG = 0;
const LOGLEVEL_INFO = 1;
const LOGLEVEL_WARNING = 2;
const LOGLEVEL_ERROR = 3;
// set loglevel on "require"
const loglevel = function() {
switch (config.log.level) {
case LOG_PREFIX_DEBUG:
case LOGLEVEL_DEBUG:
return LOGLEVEL_DEBUG;
case LOG_PREFIX_INFO:
case LOGLEVEL_INFO:
return LOGLEVEL_INFO;
case LOG_PREFIX_WARNING:
case LOGLEVEL_WARNING:
return LOGLEVEL_WARNING;
case LOG_PREFIX_ERROR:
case LOGLEVEL_ERROR:
return LOGLEVEL_ERROR;
default:
return LOGLEVEL_INFO;
}
}();
// log a http request
function logRequest(request) {
let message = "[" + request.method + "] url: \"" + request.url + "\"";
let counter = 1;
for (let param in request.body) {
message += ", parameter " + counter + ": \"" + param + "=" + request.body[param] + "\"";
counter++;
}
debug(message.trim());
}
// prefix log with "info"
function info(message) {
if (loglevel > LOGLEVEL_INFO) {
return;
}
trace(message);
}
// prefix log with "info"
function warn(message) {
if (loglevel > LOGLEVEL_WARNING) {
return;
}
trace(message, "warning");
}
// prefix log with "debug"
function debug(message) {
if (loglevel > LOGLEVEL_DEBUG) {
return;
}
trace(message, "debug");
}
// prefix log with "error"
function error(message) {
if (loglevel > LOGLEVEL_ERROR) {
return;
}
trace(message, "error");
}
// default logging function
function trace(message, prefix) {
if (message === undefined || message === null || message.length === 0) {
return;
}
if (prefix === undefined || prefix === null || prefix.length === 0) {
prefix = "info";
}
let print;
switch (prefix) {
case "error":
print = console.error;
break;
case "debug":
print = console.debug;
break;
case "warning":
print = console.warn;
break;
default:
print = console.log;
}
message = moment().format(config.server.timestamp) + " | " + prefix + " > " + message;
print(message);
}
// exports
module.exports = {
info,
warn,
debug,
error,
logRequest
};

125
libs/server.js Normal file
View file

@ -0,0 +1,125 @@
// requirements
const path = require('path');
const config = require('../config');
const packageJSON = require('../package.json');
const logger = require('./logger');
const blinkstick = require('./blinkstick');
// third party requirements
const express = require('express');
const favicon = require('serve-favicon')
const parser = require('body-parser');
// setup express, blinkstick and other stuff
const app = express();
app.use(favicon(path.join(path.dirname(__dirname), "public", "favicon.ico")));
app.use(parser.json());
app.use(parser.urlencoded({ extended: true }));
// get the html content for get requests
// TODO: replace with template engine (vue.js)
function getHTML() {
let welcomeMessage =
"<html>" +
"<head>" +
"<title>" + packageJSON.name + " " + packageJSON.version + "</title>" +
"<style>" +
"body { background: #2a2a2a; color: #f1f1f1; margin: 1.25% }" +
"th, td { border: 1px solid #919191; padding: 6px; text-align: left }" +
"</style>" +
"</head>" +
"<body>" +
"<div>" +
"<h1>" + packageJSON.name + " " + packageJSON.version + "</h1>" +
"</div>" +
"<hr>";
welcomeMessage +=
"<div>" +
"<h2>get:</h2>" +
"<p>" + config.api.get.description + "</p>" +
"</div>";
welcomeMessage +=
"<div>" +
"<h2>post:</h2>" +
"<table style=\"width:100%\">" +
"<th>argument</th>" +
"<th>available values</th>" +
"<th>default</th>" +
"<th>description</th>";
Object.keys(config.api.post).forEach(function (argument) {
welcomeMessage +=
"<tr>" +
"<td>" + argument + "</td>" +
"<td>" + config.api.post[argument].available + "</td>" +
"<td>" + config.api.post[argument].default + "</td>" +
"<td>" + config.api.post[argument].description + "</td>" +
"</tr>";
});
welcomeMessage +=
"</table>" +
"</div>" +
"</body>" +
"</html>"
return welcomeMessage;
}
// run the express http server and handle gets/posts
function start() {
return new Promise(function (resolve, reject) {
app.listen(config.server.port, config.server.listen)
.on("listening", function () {
return resolve("server listening on " + config.server.listen + ":" + config.server.port + "...")
})
.on("error", function (err) {
return reject("error starting server (" + err + ")");
});
});
}
function handleRequests() {
// GET methods
app.get('*', function (request, response) {
logger.logRequest(request);
response.send(getHTML());
response.end();
});
// POST methods
app.post('*', function (request, response) {
logger.logRequest(request);
if (!blinkstick.isAnimationInProgress() || blinkstick.isInfiniteAnimationInProgress()) {
response.end();
blinkstick.parseColor(parseRequest(request.body))
.then(blinkstick.illuminate)
.then(logger.info)
.catch(logger.error);
return;
}
response.sendStatus(503);
});
}
// parse the request and return an object with sane defaults
function parseRequest(data) {
const blinkstickConfig = {
"id": Math.random(),
"mode": data["mode"] || config.api.post.mode.default,
"color": data["color"] || config.api.post.color.default,
"options": {
"duration": data["duration"] || config.api.post.duration.default,
"pulse": {
"max": data["pulses"] || 0,
"done": 0
}
}
};
blinkstickConfig.options.steps = blinkstickConfig.options.duration / 10;
if (blinkstickConfig.options.duration < 100) {
blinkstickConfig.options.duration = 100;
}
return blinkstickConfig;
}
// exports
module.exports = {
start,
handleRequests
};

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "blinky",
"version": "0.0.1",
"description": "control a blinkstick via http requests",
"main": "blinky.js",
"scripts": {
"test": "echo \"error: no test specified\" && exit 1"
},
"keywords": [
"blinkstick",
"http"
],
"author": "Daniel Sommer <daniel.sommer@velvettear.de>",
"license": "MIT",
"dependencies": {
"blinkstick": "^1.2.0",
"body-parser": "^1.19.0",
"express": "^4.17.1",
"hex-color-regex": "^1.1.0",
"moment": "^2.29.1",
"serve-favicon": "^2.5.0"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

86
scripts/blinky-feed.sh Executable file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# author: Daniel Sommer <daniel.sommer@velvettear.de>
# license: MIT
# blinky
blinky_url="127.0.0.1:3000"
blinky_mode="morph"
blinky_duration="2500"
# temperature
max_temp="85000"
crit_temp="90000"
threshold_upper="66"
threshold_lower="33"
thermal_zones=(
"*"
)
# check for root permissions
[[ "$EUID" != 0 ]] && printf "error: permission denied!\n" && exit 1
# argument to lowercase
arg="${1,,}"
# functions
# convert a temperature value to a rgb color value
function temperatureToRGB() {
[[ ! "$1" ]] && printf "error: did not receive a temperature value to convert to a rgb value\n" && exit 1
printf "converting temperature to rgb value... "
percentage="$(bc <<< "$1 / ( $max_temp / 100 )")"
[[ "$percentage" -gt "100" ]] && percentage="100"
color_main="$(bc <<< "$percentage * 2.55")"
color_supplement="$(bc <<< "255 - $color_main")"
color_unused="0"
if [[ "$percentage" -ge "$threshold_upper" ]]; then
result="$color_main, $color_supplement, $color_unused"
elif [[ "$percentage" -ge "33" ]]; then
result="$color_supplement, $color_main, $color_unused"
else
result="$color_unused, $color_supplement, $color_main"
fi
printf "result: '$result'\n"
}
# send http post request to blinky
function sendPOST() {
[[ ! "$1" ]] && printf "error: did not receive any arguments for post request\n" && exit 1
cmd="curl -X POST"
for arg in "$@"; do
cmd="$cmd -d \"$arg\""
done
cmd="$cmd \"$blinky_url\""
printf "sending post request '$cmd'...\n"
eval "$cmd"
}
# get (average) temperature from defined thermal zones
function getTemperature() {
counter="0"
printf "getting temperature value from thermal zone(s) "
for zone in "${thermal_zones[@]}"; do
printf "'"$zone"'... "
for value in $(cat "/sys/devices/virtual/thermal/thermal_zone"$zone"/temp"); do
[[ ! "$temp" ]] && temp="$value" || temp=$(( $temp + $value ))
(( counter++ ))
done
done
result="$(( $temp / $counter ))"
printf "result: '$result'\n"
[[ "$result" -ge "$crit_temp" ]] && printf "warning: critical temperature reached\n" && blinky_mode="pulse" && blinky_duration="500" && return 0
[[ "$result" -ge "$max_temp" ]] && printf "warning: maximum temperature reached\n" && blinky_mode="pulse" && blinky_duration="1500" && return 0
}
# main part
case "$arg" in
-t|--temp|--temperature)
getTemperature
temperatureToRGB "$result"
sendPOST "color=$result" "mode=$blinky_mode" "duration=$blinky_duration"
;;
*)
printf "error: no argument given or argument unknown!\n" && exit 1
;;
esac