Skip to content
This repository was archived by the owner on Apr 13, 2025. It is now read-only.

Add nanoleaf service and sample bundle. #180

Merged
merged 5 commits into from
Feb 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions nodecg-io-nanoleaf/extension/index.ts
Original file line number Diff line number Diff line change
@@ -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<NanoleafServiceConfig, NanoleafClient> {
async validateConfig(config: NanoleafServiceConfig): Promise<Result<void>> {
// 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<Result<NanoleafClient>> {
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.");
}
}
13 changes: 13 additions & 0 deletions nodecg-io-nanoleaf/extension/interfaces.ts
Original file line number Diff line number Diff line change
@@ -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 }[];
}
204 changes: 204 additions & 0 deletions nodecg-io-nanoleaf/extension/nanoleafClient.ts
Original file line number Diff line number Diff line change
@@ -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<NanoleafClient> {
// Important: Does only remember colors which were directly set by using setPanelColor(s)
private colors: Map<number, Color> = new Map<number, Color>();

// 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<Response> {
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<Array<number>> {
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<void> {
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<void> {
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<void> {
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<number, Color> {
return this.colors;
}

/**
* Sets the brightness of all panels.
* @param level a number between 0 - 100
*/
async setBrightness(level: number): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const data = { ct: { value: temperature } };
await this.callPUT("/state", data);
}
}
40 changes: 40 additions & 0 deletions nodecg-io-nanoleaf/extension/nanoleafQueue.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading