diff --git a/README.md b/README.md index 0ee07236f..dae78093d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ nodecg-io is the successor of [ChatOverflow](https://github.com/codeoverflow-org - [x] IRC (Internet Relay Chat) - [x] MIDI Input - [x] MIDI Output +- [x] Nanoleafs - [x] OBS - [x] Philips Hue - [x] RCON diff --git a/nodecg-io-nanoleaf/extension/index.ts b/nodecg-io-nanoleaf/extension/index.ts new file mode 100644 index 000000000..4660edc68 --- /dev/null +++ b/nodecg-io-nanoleaf/extension/index.ts @@ -0,0 +1,64 @@ +import { NodeCG } from "nodecg/types/server"; +import { Result, emptySuccess, success, ServiceBundle, error } from "nodecg-io-core"; +import { NanoleafClient } from "./nanoleafClient"; +import { NanoleafUtils } from "./nanoleafUtils"; + +export interface NanoleafServiceConfig { + authKey?: string; + ipAddress: string; +} + +// Reexporting all important classes +export { NanoleafClient as NanoleafServiceClient } from "./nanoleafClient"; +export { NanoleafUtils } from "./nanoleafUtils"; +export { Color, ColoredPanel, PanelEffect } from "./interfaces"; +export { NanoleafQueue } from "./nanoleafQueue"; + +module.exports = (nodecg: NodeCG) => { + new NanoleafService(nodecg, "nanoleaf", __dirname, "../nanoleaf-schema.json").register(); +}; + +class NanoleafService extends ServiceBundle { + async validateConfig(config: NanoleafServiceConfig): Promise> { + // checks for valid IP Adress or valid IP Adress + Auth Key separately + if (!config.authKey) { + if (await NanoleafUtils.verifyIpAddress(config.ipAddress)) { + this.nodecg.log.info("Successfully verified ip address. Now trying to retrieve an auth key for you..."); + + // Automatically retrieves and saves the auth key for user's convenience + const authKey = await NanoleafUtils.retrieveAuthKey(config.ipAddress, this.nodecg); + if (authKey !== "") { + config.authKey = authKey; + return emptySuccess(); + } else { + return error("Unable to retrieve auth key!"); + } + } else { + return error("Unable to call the specified ip address!"); + } + } else { + if (await NanoleafUtils.verifyAuthKey(config.ipAddress, config.authKey)) { + this.nodecg.log.info("Successfully verified auth key."); + return emptySuccess(); + } else { + return error("Unable to verify auth key! Invalid key?"); + } + } + } + + async createClient(config: NanoleafServiceConfig): Promise> { + this.nodecg.log.info("Connecting to nanoleaf controller..."); + if (await NanoleafUtils.verifyAuthKey(config.ipAddress, config.authKey || "")) { + const client = new NanoleafClient(config.ipAddress, config.authKey || ""); + this.nodecg.log.info("Connected to Nanoleafs successfully."); + return success(client); + } else { + return error("Unable to connect to Nanoleafs! Please check your credentials!"); + } + } + + stopClient(): void { + // There is really nothing to do here + this.nodecg.log.info("Successfully stopped nanoleaf client."); + } +} diff --git a/nodecg-io-nanoleaf/extension/interfaces.ts b/nodecg-io-nanoleaf/extension/interfaces.ts new file mode 100644 index 000000000..359406f9f --- /dev/null +++ b/nodecg-io-nanoleaf/extension/interfaces.ts @@ -0,0 +1,13 @@ +export interface Color { + red: number; + green: number; + blue: number; +} +export interface ColoredPanel { + panelId: number; + color: Color; +} +export interface PanelEffect { + panelId: number; + frames: { color: Color; transitionTime: number }[]; +} diff --git a/nodecg-io-nanoleaf/extension/nanoleafClient.ts b/nodecg-io-nanoleaf/extension/nanoleafClient.ts new file mode 100644 index 000000000..eaeba14da --- /dev/null +++ b/nodecg-io-nanoleaf/extension/nanoleafClient.ts @@ -0,0 +1,204 @@ +import { ServiceClient } from "nodecg-io-core"; +import fetch from "node-fetch"; +import { Response } from "node-fetch"; +import { Color, ColoredPanel, PanelEffect } from "./interfaces"; +import { NanoleafQueue } from "./nanoleafQueue"; +import { NanoleafUtils } from "./nanoleafUtils"; + +export class NanoleafClient implements ServiceClient { + // Important: Does only remember colors which were directly set by using setPanelColor(s) + private colors: Map = new Map(); + + // This queue is used to queue effects + private queue: NanoleafQueue = new NanoleafQueue(); + + /** + * Returns the client-specific effect queue. + */ + getQueue(): NanoleafQueue { + return this.queue; + } + + getNativeClient(): NanoleafClient { + return this; // yolo + } + + constructor(private ipAddress: string, private authToken: string) {} + + private async callGET(relativePath: string) { + return fetch(NanoleafUtils.buildBaseRequestAddress(this.ipAddress, this.authToken) + relativePath, { + method: "GET", + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async callPUT(relativePath: string, body: any) { + return fetch(NanoleafUtils.buildBaseRequestAddress(this.ipAddress, this.authToken) + relativePath, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } + + /** + * Returns information about all panels, e.g. available effects, position data, ... + */ + async getAllPanelInfo(): Promise { + return this.callGET(""); + } + + /** + * Returns the IDs of all panels which are connected to the nanoleaf controller + * @param sortedByY the IDs are sorted by y level if true, otherwise sorted by x level + */ + async getAllPanelIDs(sortedByY: boolean): Promise> { + const response = await this.getAllPanelInfo(); + + if (response.status !== 200) { + return []; + } + + const json = await response.json(); + const positionData: Array<{ x: number; y: number; panelId: number }> = json.panelLayout?.layout?.positionData; + const panels = sortedByY ? positionData.sort((a, b) => a.y - b.y) : positionData.sort((a, b) => a.x - b.x); + const panelIDs = panels?.map((entry: { panelId: number }) => entry.panelId); + const panelIDsWithoutController = panelIDs.filter((entry: number) => entry !== 0); + + return panelIDsWithoutController; + } + + /** + * Sets the color of the specified panel directly using a raw effect write call. Not compatible with global effects. + * @param panelId the panel ID. Use getAllPanelIDs() to retrieve all possible IDs. + * @param color the color to send + */ + async setPanelColor(panelId: number, color: Color): Promise { + await this.setPanelColors([{ panelId: panelId, color: color }]); + } + + /** + * Sets the colors of all specified panels directly using a raw effect write call. + * @param data An array of ColoredPanel objects which hold information about panel IDs and colors. + */ + async setPanelColors(data: ColoredPanel[]): Promise { + data.forEach((panel) => this.colors.set(panel.panelId, panel.color)); + + if (data.length >= 1) { + // This creates an simple short transition effect to the specified colors + const panelData: PanelEffect[] = data.map((entry) => ({ + panelId: entry.panelId, + frames: [{ color: entry.color, transitionTime: 1 }], + })); + + await this.writeRawEffect("display", "static", false, panelData); + } + } + + /** + * This bad boy function does more than every nanoleaf documentaion ever delivered. This is the pure decoding of awesomeness. + * The raw effect write call is used to generate custom effects at runtime. Everything you ever dreamed of is possible. + * @param command 'add' overlays the effect, 'display' overwrites the effect, 'displayTemp' overrides for a specified duration + * @param animType 'static' for single colors, 'custom' for advanced animations + * @param loop 'true' if the effect shall be looped after every frame was played + * @param panelData an array of PanelEffect objects consisting of a panel id and an array of frames + * @param duration optional, only used if command is set to 'displayTemp' + */ + async writeRawEffect( + command: "add" | "display" | "displayTemp", + animType: "static" | "custom", + loop: boolean, + panelData: PanelEffect[], + duration = 0, + ): Promise { + if (panelData.every((panel) => panel.frames.length >= 1)) { + // Create animData by mapping the PanelEffect objects to a data stream which is compliant to the nanoleaf documentation ยง3.2.6.1. + const animData = + `${panelData.length}` + panelData.map((entry) => this.mapPanelEffectToAnimData(entry)).join(""); + + const json = { + write: { + command: command, + duration: duration, + animType: animType, + animData: animData, + loop: loop, + palette: [], + }, + }; + + await this.callPUT("/effects", json); + } + } + + private mapPanelEffectToAnimData(panelEffect: PanelEffect): string { + return ` ${panelEffect.panelId} ${panelEffect.frames.length}${panelEffect.frames + .map((frame) => this.mapFrameToAnimData(frame.color, frame.transitionTime)) + .join("")}`; + } + + private mapFrameToAnimData(color: Color, transitionTime: number): string { + return ` ${color.red} ${color.green} ${color.blue} 0 ${transitionTime}`; + } + + /** + * Returns the cached color of the specified panel. Please note, this returns only colors which have been set by using setPanelColor(s). + * @param panelId a valid panel id + */ + getPanelColor(panelId: number): Color { + return this.colors.get(panelId) || { red: 0, blue: 0, green: 0 }; + } + + /** + * Returns the cached color of all panels. Please note, this returns only colors which have been set by using setPanelColor(s). + */ + getAllPanelColors(): Map { + return this.colors; + } + + /** + * Sets the brightness of all panels. + * @param level a number between 0 - 100 + */ + async setBrightness(level: number): Promise { + const data = { brightness: { value: level } }; + await this.callPUT("/state", data); + } + + /** + * Sets the state of all panels. + * @param on true, if the nanoleaf shall shine. false, if you're sad and boring + */ + async setState(on: boolean): Promise { + const data = { on: { value: on } }; + await this.callPUT("/state", data); + } + + /** + * Sets the hue of all panels. + * @param hue a number between 0 - 360 + */ + async setHue(hue: number): Promise { + const data = { hue: { value: hue } }; + await this.callPUT("/state", data); + } + + /** + * Sets the saturation of all panels. + * @param sat a number between 0 - 100 + */ + async setSaturation(sat: number): Promise { + const data = { sat: { value: sat } }; + await this.callPUT("/state", data); + } + + /** + * Sets the color temperature of all panels. + * @param temperature a number between 1200 - 6500 + */ + async setColorTemperature(temperature: number): Promise { + const data = { ct: { value: temperature } }; + await this.callPUT("/state", data); + } +} diff --git a/nodecg-io-nanoleaf/extension/nanoleafQueue.ts b/nodecg-io-nanoleaf/extension/nanoleafQueue.ts new file mode 100644 index 000000000..e910e77ed --- /dev/null +++ b/nodecg-io-nanoleaf/extension/nanoleafQueue.ts @@ -0,0 +1,40 @@ +export class NanoleafQueue { + private eventQueue: { functionCall: () => void; durationInSeconds: number }[] = []; + private isQueueWorkerRunning = false; + private isQueuePaused = false; + + queueEvent(functionCall: () => void, durationInSeconds: number): void { + this.eventQueue.push({ functionCall, durationInSeconds }); + if (!this.isQueueWorkerRunning) { + this.isQueueWorkerRunning = true; + this.showNextQueueEffect(); + } + } + + private showNextQueueEffect() { + if (this.eventQueue.length >= 1) { + if (!this.isQueuePaused) { + const nextEffect = this.eventQueue.shift(); + nextEffect?.functionCall(); + setTimeout(() => this.showNextQueueEffect(), (nextEffect?.durationInSeconds || 1) * 1000); + } + } else { + this.isQueueWorkerRunning = false; + } + } + + public pauseQueue(): void { + this.isQueuePaused = true; + } + + public resumeQueue(): void { + if (this.isQueuePaused) { + this.isQueuePaused = false; + this.showNextQueueEffect(); + } + } + + isEffectActive(): boolean { + return this.isQueueWorkerRunning; + } +} diff --git a/nodecg-io-nanoleaf/extension/nanoleafUtils.ts b/nodecg-io-nanoleaf/extension/nanoleafUtils.ts new file mode 100644 index 000000000..636002aeb --- /dev/null +++ b/nodecg-io-nanoleaf/extension/nanoleafUtils.ts @@ -0,0 +1,115 @@ +import fetch from "node-fetch"; +import { NodeCG } from "nodecg/types/server"; +import { Color } from "./interfaces"; + +/** + * This class contains static helper methods, mostly used to verify the connection to your nanoleafs. + */ + +export class NanoleafUtils { + /** + * This port seems to be default for all nanoleaf controllers + */ + public static readonly defaultPort = 16021; + + /** + * Checks whether the provided ip address returns anything other than 404. + * @param ipAddress the ip address to test + */ + static async verifyIpAddress(ipAddress: string): Promise { + const code = await this.checkConnection(ipAddress, ""); + return code !== 404; + } + + /** + * Checks whether the provided auth token is valid based on the provided ip address. + * @param ipAddress the ip address of the nanoleaf controller + * @param authToken an auth token, lol + */ + static async verifyAuthKey(ipAddress: string, authToken: string): Promise { + const code = await this.checkConnection(ipAddress, authToken); + return code === 200; + } + + static buildBaseRequestAddress(ipAddress: string, authToken: string): string { + return `http://${ipAddress}:${NanoleafUtils.defaultPort}/api/v1/${authToken}`; + } + + /** + * Tries to retrieve an auth key / token from the nanoleaf controller. Fails if the controller is not in pairing mode. + * @param ipAddress the ip address of the nanoleaf controller + * @param nodecg the current nodecg instance + */ + static async retrieveAuthKey(ipAddress: string, nodecg: NodeCG): Promise { + const errorMessage = + "Received error while requesting nanoleaf auth token. Make sure to press the 'on' button for 5 seconds before executing this command."; + + try { + const response = await fetch(`http://${ipAddress}:${this.defaultPort}/api/v1/new`, { method: "POST" }); + + const json = await response.json(); + return json.authToken || ""; + } catch (error) { + nodecg.log.warn(errorMessage); + return ""; + } + } + + private static async checkConnection(ipAddress: string, authToken: string) { + try { + const response = await fetch(NanoleafUtils.buildBaseRequestAddress(ipAddress, authToken), { + method: "GET", + }); + + return response.status; + } catch { + // Nothing to do here + } + return 404; + } + + /** + * Converts the specified color from the HSV (Hue-Saturation-Value) color space to the RGB (Red-Green-Blue) color space. + * @param color a color in the HSV color space + */ + static convertHSVtoRGB(color: { hue: number; saturation: number; value: number }): Color { + // based on: https://stackoverflow.com/questions/17242144/javascript-convert-hsb-hsv-color-to-rgb-accurately + const h = color.hue; + const s = color.saturation; + const v = color.value; + let r, g, b; + const i = Math.floor(h * 6); + const f = h * 6 - i; + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + switch (i % 6) { + case 0: + (r = v), (g = t), (b = p); + break; + case 1: + (r = q), (g = v), (b = p); + break; + case 2: + (r = p), (g = v), (b = t); + break; + case 3: + (r = p), (g = q), (b = v); + break; + case 4: + (r = t), (g = p), (b = v); + break; + case 5: + (r = v), (g = p), (b = q); + break; + default: + (r = 0), (g = 0), (b = 0); + break; + } + return { + red: Math.round(r * 255), + green: Math.round(g * 255), + blue: Math.round(b * 255), + }; + } +} diff --git a/nodecg-io-nanoleaf/nanoleaf-schema.json b/nodecg-io-nanoleaf/nanoleaf-schema.json new file mode 100644 index 000000000..deac49db1 --- /dev/null +++ b/nodecg-io-nanoleaf/nanoleaf-schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "authKey": { + "type": "string", + "description": "An auth key. If not specified, the service will automatically try to acquire one for you." + }, + "ipAddress": { + "type": "string", + "description": "The IP-Address of a nanoleaf controller in your local network." + } + }, + "required": ["ipAddress"] +} diff --git a/nodecg-io-nanoleaf/package.json b/nodecg-io-nanoleaf/package.json new file mode 100644 index 000000000..d6954470c --- /dev/null +++ b/nodecg-io-nanoleaf/package.json @@ -0,0 +1,42 @@ +{ + "name": "nodecg-io-nanoleaf", + "version": "0.1.0", + "description": "Allows to connect to a nanoleaf controller and trigger custom lighting effects.", + "homepage": "https://nodecg.io/samples/nanoleaf", + "author": { + "name": "sebinside", + "url": "https://github.com/sebinside" + }, + "repository": { + "type": "git", + "url": "https://github.com/codeoverflow-org/nodecg-io.git", + "directory": "nodecg-io-nanoleaf" + }, + "main": "extension", + "scripts": { + "build": "tsc -b", + "watch": "tsc -b -w", + "clean": "tsc -b --clean" + }, + "keywords": [ + "nodecg-io", + "nodecg-bundle" + ], + "nodecg": { + "compatibleRange": "^1.1.1", + "bundleDependencies": { + "nodecg-io-core": "^0.1.0" + } + }, + "license": "MIT", + "devDependencies": { + "@types/node": "^14.14.13", + "@types/node-fetch": "^2.5.8", + "nodecg": "^1.7.4", + "typescript": "^4.1.3" + }, + "dependencies": { + "nodecg-io-core": "^0.1.0", + "node-fetch": "^2.6.1" + } +} diff --git a/nodecg-io-nanoleaf/tsconfig.json b/nodecg-io-nanoleaf/tsconfig.json new file mode 100644 index 000000000..1c8405620 --- /dev/null +++ b/nodecg-io-nanoleaf/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.common.json" +} diff --git a/samples/nanoleaf/extension/index.ts b/samples/nanoleaf/extension/index.ts new file mode 100644 index 000000000..e70e513dd --- /dev/null +++ b/samples/nanoleaf/extension/index.ts @@ -0,0 +1,21 @@ +import { NodeCG } from "nodecg/types/server"; +import { NanoleafServiceClient } from "nodecg-io-nanoleaf"; +import { requireService } from "nodecg-io-core"; + +module.exports = function (nodecg: NodeCG) { + nodecg.log.info("Sample bundle for nanoleafs started."); + + // Require the nanoleaf service + const nanoleafClient = requireService(nodecg, "nanoleaf"); + + nanoleafClient?.onAvailable(async (client) => { + nodecg.log.info("Nanoleaf client has been updated."); + + // Sets the color of all nanoleaf panels to the very best orange + await client.setSaturation(100); + await client.setBrightness(25); + await client.setHue(40); + }); + + nanoleafClient?.onUnavailable(() => nodecg.log.info("Nanoleaf client has been unset.")); +}; diff --git a/samples/nanoleaf/package.json b/samples/nanoleaf/package.json new file mode 100644 index 000000000..2442c6014 --- /dev/null +++ b/samples/nanoleaf/package.json @@ -0,0 +1,24 @@ +{ + "name": "nanoleaf", + "version": "0.1.0", + "private": true, + "nodecg": { + "compatibleRange": "^1.1.1", + "bundleDependencies": { + "nodecg-io-nanoleaf": "^0.1.0" + } + }, + "scripts": { + "build": "tsc -b", + "watch": "tsc -b -w", + "clean": "tsc -b --clean" + }, + "license": "MIT", + "dependencies": { + "@types/node": "^14.14.13", + "nodecg": "^1.7.4", + "nodecg-io-core": "^0.1.0", + "nodecg-io-nanoleaf": "^0.1.0", + "typescript": "^4.1.3" + } +} diff --git a/samples/nanoleaf/tsconfig.json b/samples/nanoleaf/tsconfig.json new file mode 100644 index 000000000..c8bb01bee --- /dev/null +++ b/samples/nanoleaf/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.common.json" +}