commit 7be704eeb99e14ee28d1122d2c4a5833ef9b6d3d Author: velvettear Date: Tue Apr 6 16:41:49 2021 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c11b9d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package-lock.json +npm-debug.log \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3404042 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Launch Program", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder}/blinky.js" + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..894dfe0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +MIT License Copyright (c) + +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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ae7ca3 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# blinky + +control your blinkstick via http GET and POST requests + +## requirements + +- node v8.17.0 diff --git a/blinky.js b/blinky.js new file mode 100644 index 0000000..d9b6d74 --- /dev/null +++ b/blinky.js @@ -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); +} diff --git a/config.json b/config.json new file mode 100644 index 0000000..fda54cc --- /dev/null +++ b/config.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/libs/blinkstick.js b/libs/blinkstick.js new file mode 100644 index 0000000..964be6d --- /dev/null +++ b/libs/blinkstick.js @@ -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 +}; \ No newline at end of file diff --git a/libs/logger.js b/libs/logger.js new file mode 100644 index 0000000..9e17daf --- /dev/null +++ b/libs/logger.js @@ -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 +}; \ No newline at end of file diff --git a/libs/server.js b/libs/server.js new file mode 100644 index 0000000..83852f3 --- /dev/null +++ b/libs/server.js @@ -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 = + "" + + "" + + "" + packageJSON.name + " " + packageJSON.version + "" + + "" + + "" + + "" + + "
" + + "

" + packageJSON.name + " " + packageJSON.version + "

" + + "
" + + "
"; + welcomeMessage += + "
" + + "

get:

" + + "

" + config.api.get.description + "

" + + "
"; + welcomeMessage += + "
" + + "

post:

" + + "" + + "" + + "" + + "" + + ""; + Object.keys(config.api.post).forEach(function (argument) { + welcomeMessage += + "" + + "" + + "" + + "" + + "" + + ""; + }); + welcomeMessage += + "
argumentavailable valuesdefaultdescription
" + argument + "" + config.api.post[argument].available + "" + config.api.post[argument].default + "" + config.api.post[argument].description + "
" + + "
" + + "" + + "" + 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 +}; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..355548b --- /dev/null +++ b/package.json @@ -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 ", + "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" + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..36ca239 Binary files /dev/null and b/public/favicon.ico differ diff --git a/scripts/blinky-feed.sh b/scripts/blinky-feed.sh new file mode 100755 index 0000000..df50c1e --- /dev/null +++ b/scripts/blinky-feed.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +# author: Daniel Sommer +# 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