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

Commit f4be4cf

Browse files
authored
Merge pull request #180 from codeoverflow-org/feature/145-nanoleaf
Add nanoleaf service and sample bundle.
2 parents fc20200 + a6ae982 commit f4be4cf

File tree

12 files changed

+546
-0
lines changed

12 files changed

+546
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ nodecg-io is the successor of [ChatOverflow](https://github.com/codeoverflow-org
2525
- [x] IRC (Internet Relay Chat)
2626
- [x] MIDI Input
2727
- [x] MIDI Output
28+
- [x] Nanoleafs
2829
- [x] OBS
2930
- [x] Philips Hue
3031
- [x] RCON

nodecg-io-nanoleaf/extension/index.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { NodeCG } from "nodecg/types/server";
2+
import { Result, emptySuccess, success, ServiceBundle, error } from "nodecg-io-core";
3+
import { NanoleafClient } from "./nanoleafClient";
4+
import { NanoleafUtils } from "./nanoleafUtils";
5+
6+
export interface NanoleafServiceConfig {
7+
authKey?: string;
8+
ipAddress: string;
9+
}
10+
11+
// Reexporting all important classes
12+
export { NanoleafClient as NanoleafServiceClient } from "./nanoleafClient";
13+
export { NanoleafUtils } from "./nanoleafUtils";
14+
export { Color, ColoredPanel, PanelEffect } from "./interfaces";
15+
export { NanoleafQueue } from "./nanoleafQueue";
16+
17+
module.exports = (nodecg: NodeCG) => {
18+
new NanoleafService(nodecg, "nanoleaf", __dirname, "../nanoleaf-schema.json").register();
19+
};
20+
21+
class NanoleafService extends ServiceBundle<NanoleafServiceConfig, NanoleafClient> {
22+
async validateConfig(config: NanoleafServiceConfig): Promise<Result<void>> {
23+
// checks for valid IP Adress or valid IP Adress + Auth Key separately
24+
if (!config.authKey) {
25+
if (await NanoleafUtils.verifyIpAddress(config.ipAddress)) {
26+
this.nodecg.log.info("Successfully verified ip address. Now trying to retrieve an auth key for you...");
27+
28+
// Automatically retrieves and saves the auth key for user's convenience
29+
const authKey = await NanoleafUtils.retrieveAuthKey(config.ipAddress, this.nodecg);
30+
if (authKey !== "") {
31+
config.authKey = authKey;
32+
return emptySuccess();
33+
} else {
34+
return error("Unable to retrieve auth key!");
35+
}
36+
} else {
37+
return error("Unable to call the specified ip address!");
38+
}
39+
} else {
40+
if (await NanoleafUtils.verifyAuthKey(config.ipAddress, config.authKey)) {
41+
this.nodecg.log.info("Successfully verified auth key.");
42+
return emptySuccess();
43+
} else {
44+
return error("Unable to verify auth key! Invalid key?");
45+
}
46+
}
47+
}
48+
49+
async createClient(config: NanoleafServiceConfig): Promise<Result<NanoleafClient>> {
50+
this.nodecg.log.info("Connecting to nanoleaf controller...");
51+
if (await NanoleafUtils.verifyAuthKey(config.ipAddress, config.authKey || "")) {
52+
const client = new NanoleafClient(config.ipAddress, config.authKey || "");
53+
this.nodecg.log.info("Connected to Nanoleafs successfully.");
54+
return success(client);
55+
} else {
56+
return error("Unable to connect to Nanoleafs! Please check your credentials!");
57+
}
58+
}
59+
60+
stopClient(): void {
61+
// There is really nothing to do here
62+
this.nodecg.log.info("Successfully stopped nanoleaf client.");
63+
}
64+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export interface Color {
2+
red: number;
3+
green: number;
4+
blue: number;
5+
}
6+
export interface ColoredPanel {
7+
panelId: number;
8+
color: Color;
9+
}
10+
export interface PanelEffect {
11+
panelId: number;
12+
frames: { color: Color; transitionTime: number }[];
13+
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { ServiceClient } from "nodecg-io-core";
2+
import fetch from "node-fetch";
3+
import { Response } from "node-fetch";
4+
import { Color, ColoredPanel, PanelEffect } from "./interfaces";
5+
import { NanoleafQueue } from "./nanoleafQueue";
6+
import { NanoleafUtils } from "./nanoleafUtils";
7+
8+
export class NanoleafClient implements ServiceClient<NanoleafClient> {
9+
// Important: Does only remember colors which were directly set by using setPanelColor(s)
10+
private colors: Map<number, Color> = new Map<number, Color>();
11+
12+
// This queue is used to queue effects
13+
private queue: NanoleafQueue = new NanoleafQueue();
14+
15+
/**
16+
* Returns the client-specific effect queue.
17+
*/
18+
getQueue(): NanoleafQueue {
19+
return this.queue;
20+
}
21+
22+
getNativeClient(): NanoleafClient {
23+
return this; // yolo
24+
}
25+
26+
constructor(private ipAddress: string, private authToken: string) {}
27+
28+
private async callGET(relativePath: string) {
29+
return fetch(NanoleafUtils.buildBaseRequestAddress(this.ipAddress, this.authToken) + relativePath, {
30+
method: "GET",
31+
});
32+
}
33+
34+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
35+
private async callPUT(relativePath: string, body: any) {
36+
return fetch(NanoleafUtils.buildBaseRequestAddress(this.ipAddress, this.authToken) + relativePath, {
37+
method: "PUT",
38+
headers: {
39+
"Content-Type": "application/json",
40+
},
41+
body: JSON.stringify(body),
42+
});
43+
}
44+
45+
/**
46+
* Returns information about all panels, e.g. available effects, position data, ...
47+
*/
48+
async getAllPanelInfo(): Promise<Response> {
49+
return this.callGET("");
50+
}
51+
52+
/**
53+
* Returns the IDs of all panels which are connected to the nanoleaf controller
54+
* @param sortedByY the IDs are sorted by y level if true, otherwise sorted by x level
55+
*/
56+
async getAllPanelIDs(sortedByY: boolean): Promise<Array<number>> {
57+
const response = await this.getAllPanelInfo();
58+
59+
if (response.status !== 200) {
60+
return [];
61+
}
62+
63+
const json = await response.json();
64+
const positionData: Array<{ x: number; y: number; panelId: number }> = json.panelLayout?.layout?.positionData;
65+
const panels = sortedByY ? positionData.sort((a, b) => a.y - b.y) : positionData.sort((a, b) => a.x - b.x);
66+
const panelIDs = panels?.map((entry: { panelId: number }) => entry.panelId);
67+
const panelIDsWithoutController = panelIDs.filter((entry: number) => entry !== 0);
68+
69+
return panelIDsWithoutController;
70+
}
71+
72+
/**
73+
* Sets the color of the specified panel directly using a raw effect write call. Not compatible with global effects.
74+
* @param panelId the panel ID. Use getAllPanelIDs() to retrieve all possible IDs.
75+
* @param color the color to send
76+
*/
77+
async setPanelColor(panelId: number, color: Color): Promise<void> {
78+
await this.setPanelColors([{ panelId: panelId, color: color }]);
79+
}
80+
81+
/**
82+
* Sets the colors of all specified panels directly using a raw effect write call.
83+
* @param data An array of ColoredPanel objects which hold information about panel IDs and colors.
84+
*/
85+
async setPanelColors(data: ColoredPanel[]): Promise<void> {
86+
data.forEach((panel) => this.colors.set(panel.panelId, panel.color));
87+
88+
if (data.length >= 1) {
89+
// This creates an simple short transition effect to the specified colors
90+
const panelData: PanelEffect[] = data.map((entry) => ({
91+
panelId: entry.panelId,
92+
frames: [{ color: entry.color, transitionTime: 1 }],
93+
}));
94+
95+
await this.writeRawEffect("display", "static", false, panelData);
96+
}
97+
}
98+
99+
/**
100+
* This bad boy function does more than every nanoleaf documentaion ever delivered. This is the pure decoding of awesomeness.
101+
* The raw effect write call is used to generate custom effects at runtime. Everything you ever dreamed of is possible.
102+
* @param command 'add' overlays the effect, 'display' overwrites the effect, 'displayTemp' overrides for a specified duration
103+
* @param animType 'static' for single colors, 'custom' for advanced animations
104+
* @param loop 'true' if the effect shall be looped after every frame was played
105+
* @param panelData an array of PanelEffect objects consisting of a panel id and an array of frames
106+
* @param duration optional, only used if command is set to 'displayTemp'
107+
*/
108+
async writeRawEffect(
109+
command: "add" | "display" | "displayTemp",
110+
animType: "static" | "custom",
111+
loop: boolean,
112+
panelData: PanelEffect[],
113+
duration = 0,
114+
): Promise<void> {
115+
if (panelData.every((panel) => panel.frames.length >= 1)) {
116+
// Create animData by mapping the PanelEffect objects to a data stream which is compliant to the nanoleaf documentation §3.2.6.1.
117+
const animData =
118+
`${panelData.length}` + panelData.map((entry) => this.mapPanelEffectToAnimData(entry)).join("");
119+
120+
const json = {
121+
write: {
122+
command: command,
123+
duration: duration,
124+
animType: animType,
125+
animData: animData,
126+
loop: loop,
127+
palette: [],
128+
},
129+
};
130+
131+
await this.callPUT("/effects", json);
132+
}
133+
}
134+
135+
private mapPanelEffectToAnimData(panelEffect: PanelEffect): string {
136+
return ` ${panelEffect.panelId} ${panelEffect.frames.length}${panelEffect.frames
137+
.map((frame) => this.mapFrameToAnimData(frame.color, frame.transitionTime))
138+
.join("")}`;
139+
}
140+
141+
private mapFrameToAnimData(color: Color, transitionTime: number): string {
142+
return ` ${color.red} ${color.green} ${color.blue} 0 ${transitionTime}`;
143+
}
144+
145+
/**
146+
* Returns the cached color of the specified panel. Please note, this returns only colors which have been set by using setPanelColor(s).
147+
* @param panelId a valid panel id
148+
*/
149+
getPanelColor(panelId: number): Color {
150+
return this.colors.get(panelId) || { red: 0, blue: 0, green: 0 };
151+
}
152+
153+
/**
154+
* Returns the cached color of all panels. Please note, this returns only colors which have been set by using setPanelColor(s).
155+
*/
156+
getAllPanelColors(): Map<number, Color> {
157+
return this.colors;
158+
}
159+
160+
/**
161+
* Sets the brightness of all panels.
162+
* @param level a number between 0 - 100
163+
*/
164+
async setBrightness(level: number): Promise<void> {
165+
const data = { brightness: { value: level } };
166+
await this.callPUT("/state", data);
167+
}
168+
169+
/**
170+
* Sets the state of all panels.
171+
* @param on true, if the nanoleaf shall shine. false, if you're sad and boring
172+
*/
173+
async setState(on: boolean): Promise<void> {
174+
const data = { on: { value: on } };
175+
await this.callPUT("/state", data);
176+
}
177+
178+
/**
179+
* Sets the hue of all panels.
180+
* @param hue a number between 0 - 360
181+
*/
182+
async setHue(hue: number): Promise<void> {
183+
const data = { hue: { value: hue } };
184+
await this.callPUT("/state", data);
185+
}
186+
187+
/**
188+
* Sets the saturation of all panels.
189+
* @param sat a number between 0 - 100
190+
*/
191+
async setSaturation(sat: number): Promise<void> {
192+
const data = { sat: { value: sat } };
193+
await this.callPUT("/state", data);
194+
}
195+
196+
/**
197+
* Sets the color temperature of all panels.
198+
* @param temperature a number between 1200 - 6500
199+
*/
200+
async setColorTemperature(temperature: number): Promise<void> {
201+
const data = { ct: { value: temperature } };
202+
await this.callPUT("/state", data);
203+
}
204+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export class NanoleafQueue {
2+
private eventQueue: { functionCall: () => void; durationInSeconds: number }[] = [];
3+
private isQueueWorkerRunning = false;
4+
private isQueuePaused = false;
5+
6+
queueEvent(functionCall: () => void, durationInSeconds: number): void {
7+
this.eventQueue.push({ functionCall, durationInSeconds });
8+
if (!this.isQueueWorkerRunning) {
9+
this.isQueueWorkerRunning = true;
10+
this.showNextQueueEffect();
11+
}
12+
}
13+
14+
private showNextQueueEffect() {
15+
if (this.eventQueue.length >= 1) {
16+
if (!this.isQueuePaused) {
17+
const nextEffect = this.eventQueue.shift();
18+
nextEffect?.functionCall();
19+
setTimeout(() => this.showNextQueueEffect(), (nextEffect?.durationInSeconds || 1) * 1000);
20+
}
21+
} else {
22+
this.isQueueWorkerRunning = false;
23+
}
24+
}
25+
26+
public pauseQueue(): void {
27+
this.isQueuePaused = true;
28+
}
29+
30+
public resumeQueue(): void {
31+
if (this.isQueuePaused) {
32+
this.isQueuePaused = false;
33+
this.showNextQueueEffect();
34+
}
35+
}
36+
37+
isEffectActive(): boolean {
38+
return this.isQueueWorkerRunning;
39+
}
40+
}

0 commit comments

Comments
 (0)