diff --git a/nodecg-io-elgato-light/extension/elgatoLight.ts b/nodecg-io-elgato-light/extension/elgatoLight.ts new file mode 100644 index 000000000..993212948 --- /dev/null +++ b/nodecg-io-elgato-light/extension/elgatoLight.ts @@ -0,0 +1,190 @@ +import fetch from "node-fetch"; +import { LightData, LightValues } from "./lightData"; +import { Response } from "node-fetch"; + +export type LightType = "KeyLight" | "LightStrip"; + +/** + * Represents an elgato light. Is never directly created but has subclasses for the different light types. + */ +export abstract class ElgatoLight { + constructor(public readonly ipAddress: string, public readonly name?: string) {} + + /** + * Tests if the elgato light is reachable. + * @returns true if the test call returned success. false, otherwise + */ + public async validate(): Promise { + const response = await this.callGET(); + return response.status === 200; + } + + private buildPath(): string { + return `http://${this.ipAddress}:9123/elgato/lights`; + } + + private async callGET() { + return fetch(this.buildPath(), { + method: "GET", + }); + } + + /** + * Helper method to call HTTP PUT on the elgato light. + * @param body json data to send to the elgato light + * @returns the response of the elgato light + */ + protected async callPUT(body: LightData): Promise { + return fetch(this.buildPath(), { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } + + /** + * Helper method to call HTTP GET on the elgato light and ease the interpretation of the response. + * @returns the response of the elgato light or undefined + */ + protected async getLightData(): Promise { + const response = await this.callGET(); + + if (response.status !== 200) { + return undefined; + } + + return ((await response.json()) as LightData).lights[0]; + } + + /** + * + * @returns Returns true if the light is switched on. + */ + async isLightOn(): Promise { + return (await this.getLightData())?.on === 1; + } + + /** + * Switches the elgato light on. + */ + async turnLightOn(): Promise { + const lightData = ElgatoLight.createLightData({ on: 1 }); + await this.callPUT(lightData); + } + + /** + * Switches the elgato light off. + */ + async turnLightOff(): Promise { + const lightData = ElgatoLight.createLightData({ on: 0 }); + await this.callPUT(lightData); + } + + /** + * Toggles the on/off state of the elgato light. + */ + async toggleLight(): Promise { + const state = await this.isLightOn(); + const lightData = ElgatoLight.createLightData({ on: state ? 0 : 1 }); + await this.callPUT(lightData); + } + + /** + * Sets the brightness of the elgato light. + * @param brightness a value between 0.0 and 100.0 + */ + async setBrightness(brightness: number): Promise { + const sanitizedValue = Math.max(0, Math.min(100, brightness)); + const lightData = ElgatoLight.createLightData({ brightness: sanitizedValue }); + await this.callPUT(lightData); + } + + /** + * Returns the brightness of the elgato light. + * @returns a value between 0.0 and 100.0 or -1 if an error occurred + */ + async getBrightness(): Promise { + return (await this.getLightData())?.brightness ?? -1; + } + + protected static createLightData(data: LightValues): LightData { + return { + numberOfLights: 1, + lights: [data], + }; + } +} + +/** + * Represents an elgato key light, e.g., the key light or key light air. + */ +export class ElgatoKeyLight extends ElgatoLight { + private static readonly temperatureFactor = 1000000; + + /** + * Sets the temperature of the elgato key light. + * @param temperature a value between 2900 and 7000 kelvin + */ + async setTemperature(temperature: number): Promise { + const sanitizedValue = Math.max(143, Math.min(344, ElgatoKeyLight.temperatureFactor / temperature)); + const lightData = ElgatoLight.createLightData({ temperature: sanitizedValue }); + await this.callPUT(lightData); + } + + /** + * Returns the temperature of the elgato key light. + * @returns a value between 2900 and 7000 or -1 if an error occurred + */ + async getTemperature(): Promise { + const temperature = (await this.getLightData())?.temperature; + + if (temperature !== undefined) { + return ElgatoKeyLight.temperatureFactor / temperature; + } else { + return -1; + } + } +} + +/** + * Represents an elgato light stripe of any length. + */ +export class ElgatoLightStrip extends ElgatoLight { + /** + * Sets the hue of the elgato light stripe. + * @param hue a value between 0.0 and 360.0 + */ + async setHue(hue: number): Promise { + const sanitizedValue = Math.max(0, Math.min(360, hue)); + const lightData = ElgatoLight.createLightData({ hue: sanitizedValue }); + await this.callPUT(lightData); + } + + /** + * Returns the hue of the elgato light stripe. + * @returns a value between 0.0 and 360.0 or -1 if an error occurred + */ + async getHue(): Promise { + return (await this.getLightData())?.hue ?? -1; + } + + /** + * Sets the saturation of the elgato light stripe. + * @param saturation a value between 0.0 and 100.0 + */ + async setSaturation(saturation: number): Promise { + const sanitizedValue = Math.max(0, Math.min(100, saturation)); + const lightData = ElgatoLight.createLightData({ saturation: sanitizedValue }); + await this.callPUT(lightData); + } + + /** + * Returns the saturation of the elgato light stripe. + * @returns a value between 0.0 and 100.0 or -1 if an error occurred + */ + async getSaturation(): Promise { + return (await this.getLightData())?.saturation ?? -1; + } +} diff --git a/nodecg-io-elgato-light/extension/elgatoLightClient.ts b/nodecg-io-elgato-light/extension/elgatoLightClient.ts new file mode 100644 index 000000000..376152f83 --- /dev/null +++ b/nodecg-io-elgato-light/extension/elgatoLightClient.ts @@ -0,0 +1,73 @@ +import { ElgatoLight } from "."; +import { ElgatoKeyLight, ElgatoLightStrip, LightType } from "./elgatoLight"; + +export interface ElgatoLightConfig { + lights: [ + { + ipAddress: string; + lightType: LightType; + name?: string; + }, + ]; +} + +/** + * The elgato light client is used to access all configured elgato lights. Just use the get methods. + */ +export class ElgatoLightClient { + private lights: ElgatoLight[] = []; + + constructor(private config: ElgatoLightConfig) { + this.lights = this.config.lights.map((light) => this.createLight(light.ipAddress, light.lightType, light.name)); + } + + private createLight(ipAddress: string, lightType: LightType, name?: string) { + if (lightType === "KeyLight") { + return new ElgatoKeyLight(ipAddress, name); + } else { + return new ElgatoLightStrip(ipAddress, name); + } + } + + /** + * Tries to reach all elgato lights contained in the config provided in the constructor. + * @returns an array of IP addresses of elgato lights that where configured but not reachable + */ + async identifyNotReachableLights(): Promise> { + const notReachableLights = []; + + for (const light of this.lights) { + if (!(await light.validate())) { + notReachableLights.push(light.ipAddress); + } + } + + return notReachableLights; + } + + /** + * Returns all configured elgato lights. + * @returns an array of elgato lights (elgato key lights or light stripes) + */ + getAllLights(): ElgatoLight[] { + return [...this.lights]; + } + + /** + * Returns the specified elgato light (elgato key light or light stripe) + * @param name the name of the elgato light specified in the nodecg-io config + * @returns the specified elgato light instance or undefined if the name was not found + */ + getLightByName(name: string): ElgatoLight | undefined { + return this.lights.find((light) => light.name === name); + } + + /** + * Returns the specified elgato light (elgato key light or light stripe) + * @param ipAddress the ip address of the elgato light as specified in the nodecg-io config + * @returns the specified elgato light instance or undefined if the address was not found + */ + getLightByAddress(ipAddress: string): ElgatoLight | undefined { + return this.lights.find((light) => light.ipAddress === ipAddress); + } +} diff --git a/nodecg-io-elgato-light/extension/index.ts b/nodecg-io-elgato-light/extension/index.ts new file mode 100644 index 000000000..8a1b2e660 --- /dev/null +++ b/nodecg-io-elgato-light/extension/index.ts @@ -0,0 +1,31 @@ +import { NodeCG } from "nodecg/types/server"; +import { Result, emptySuccess, success, ServiceBundle, error } from "nodecg-io-core"; +import { ElgatoLightClient, ElgatoLightConfig } from "./elgatoLightClient"; + +export { ElgatoLight, ElgatoKeyLight, ElgatoLightStrip, LightType } from "./elgatoLight"; +export { ElgatoLightClient, ElgatoLightConfig } from "./elgatoLightClient"; + +module.exports = (nodecg: NodeCG) => { + new ElgatoLightService(nodecg, "elgato-light", __dirname, "../schema.json").register(); +}; + +class ElgatoLightService extends ServiceBundle { + async validateConfig(config: ElgatoLightConfig): Promise> { + const notReachableLights = await new ElgatoLightClient(config).identifyNotReachableLights(); + if (notReachableLights.length === 0) { + return emptySuccess(); + } + + return error(`Unable to connect to the lights with the following IPs: ${notReachableLights.join(", ")}`); + } + + async createClient(config: ElgatoLightConfig): Promise> { + const client = new ElgatoLightClient(config); + this.nodecg.log.info("Successfully created Elgato light clients."); + return success(client); + } + + stopClient(_: ElgatoLightClient): void { + this.nodecg.log.info("Successfully stopped Elgato light clients."); + } +} diff --git a/nodecg-io-elgato-light/extension/lightData.ts b/nodecg-io-elgato-light/extension/lightData.ts new file mode 100644 index 000000000..2bb373a2b --- /dev/null +++ b/nodecg-io-elgato-light/extension/lightData.ts @@ -0,0 +1,15 @@ +/** + * DTO for elgato light http communication. + */ +export interface LightData { + numberOfLights: 1; + lights: LightValues[]; +} + +export interface LightValues { + on?: number; + hue?: number; + saturation?: number; + brightness?: number; + temperature?: number; +} diff --git a/nodecg-io-elgato-light/package.json b/nodecg-io-elgato-light/package.json new file mode 100644 index 000000000..de49ad7d4 --- /dev/null +++ b/nodecg-io-elgato-light/package.json @@ -0,0 +1,48 @@ +{ + "name": "nodecg-io-elgato-light", + "version": "0.2.0", + "description": "Control your Elgato lights, e.g. key lights and light stripes.", + "homepage": "https://nodecg.io/RELEASE/samples/elgato-light", + "author": { + "name": "CodeOverflow team", + "url": "https://github.com/codeoverflow-org" + }, + "repository": { + "type": "git", + "url": "https://github.com/codeoverflow-org/nodecg-io.git", + "directory": "nodecg-io-elgato-light" + }, + "files": [ + "**/*.js", + "**/*.js.map", + "**/*.d.ts", + "*.json" + ], + "main": "extension/index", + "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.2.0" + } + }, + "license": "MIT", + "devDependencies": { + "@types/node": "^15.0.2", + "nodecg": "^1.8.1", + "typescript": "^4.2.4" + }, + "dependencies": { + "@types/node-fetch": "^2.5.10", + "node-fetch": "^2.6.1", + "nodecg-io-core": "^0.2.0" + } +} diff --git a/nodecg-io-elgato-light/schema.json b/nodecg-io-elgato-light/schema.json new file mode 100644 index 000000000..e85fc580d --- /dev/null +++ b/nodecg-io-elgato-light/schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "lights": { + "type": "array", + "description": "A list of elgato lights you want to control.", + "items": { + "type": "object", + "properties": { + "ipAddress": { + "type": "string", + "description": "The IP-Address of a elgato light in your local network." + }, + "lightType": { + "type": "string", + "enum": ["KeyLight", "LightStrip"], + "description": "The type of light. Available types: 'KeyLight', 'LightStrip'." + }, + "name": { + "type": "string", + "description": "An optional name for the light that can be used to identify it." + } + }, + "required": ["ipAddress", "lightType"] + } + } + }, + "required": ["lights"] +} diff --git a/nodecg-io-elgato-light/tsconfig.json b/nodecg-io-elgato-light/tsconfig.json new file mode 100644 index 000000000..1c8405620 --- /dev/null +++ b/nodecg-io-elgato-light/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.common.json" +} diff --git a/samples/elgato-light/extension/index.ts b/samples/elgato-light/extension/index.ts new file mode 100644 index 000000000..2c3a22ba2 --- /dev/null +++ b/samples/elgato-light/extension/index.ts @@ -0,0 +1,27 @@ +import { NodeCG } from "nodecg/types/server"; +import { ElgatoLightClient } from "nodecg-io-elgato-light"; +import { requireService } from "nodecg-io-core"; + +module.exports = function (nodecg: NodeCG) { + nodecg.log.info("Sample bundle for the Elgato light service started."); + + const elgatoLightClient = requireService(nodecg, "elgato-light"); + + elgatoLightClient?.onAvailable(async (client) => { + nodecg.log.info("Elgato light service available."); + + // Blinky Blinky + const interval = setInterval(() => client.getAllLights().forEach((light) => light.toggleLight()), 500); + setTimeout(() => clearInterval(interval), 3100); + + // Get some data + client.getAllLights().forEach(async (light) => { + const brightness = await light.getBrightness(); + nodecg.log.info(`Elgato light (${light.ipAddress}), brightness: ${brightness}`); + }); + }); + + elgatoLightClient?.onUnavailable(() => { + nodecg.log.info("Elgato light service unavailable."); + }); +}; diff --git a/samples/elgato-light/package.json b/samples/elgato-light/package.json new file mode 100644 index 000000000..3eb209f4b --- /dev/null +++ b/samples/elgato-light/package.json @@ -0,0 +1,24 @@ +{ + "name": "elgato-light", + "version": "0.2.0", + "private": true, + "nodecg": { + "compatibleRange": "^1.1.1", + "bundleDependencies": { + "nodecg-io-elgato-light": "^0.2.0" + } + }, + "scripts": { + "build": "tsc -b", + "watch": "tsc -b -w", + "clean": "tsc -b --clean" + }, + "license": "MIT", + "dependencies": { + "@types/node": "^15.0.2", + "nodecg": "^1.8.1", + "nodecg-io-core": "^0.2.0", + "nodecg-io-elgato-light": "^0.2.0", + "typescript": "^4.2.4" + } +} diff --git a/samples/elgato-light/tsconfig.json b/samples/elgato-light/tsconfig.json new file mode 100644 index 000000000..c8bb01bee --- /dev/null +++ b/samples/elgato-light/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.common.json" +}