import { RemoteInfo, Socket } from 'dgram'; import { fade, setBrightness, setColor, updateValues } from './commands/setColors'; import { setOff, setOn } from './commands/setOnOff'; import { getGoveeDeviceSocket } from './commands/createSocket'; import { EventEmitter } from 'events'; import * as ct from 'color-temperature'; export interface DeviceState { isOn: number, brightness: number, color: { "r": number, "g": number, "b": number; }, colorKelvin: number, hasReceivedUpdates: boolean; } export class Device extends EventEmitter { constructor(data: Record, GoveeInstance: Govee, socket: Socket) { super(); deviceList.set(data.ip, this); this.model = data.sku; this.deviceID = data.device; this.ip = data.ip; this.versions = { BLEhardware: data.bleVersionHard, BLEsoftware: data.bleVersionSoft, WiFiHardware: data.wifiVersionHard, WiFiSoftware: data.wifiVersionSoft }; this.state = { isOn: 0, brightness: 0, color: { "r": 0, "g": 0, "b": 0 }, colorKelvin: 0, hasReceivedUpdates: false }; this.socket = socket; this.updateTimer = setInterval(() => { this.updateValues(); }, 6000); // When the status gets changes, emit it on the main class aswell this.on("updatedStatus", (data, stateChanged) => { GoveeInstance.emit("updatedStatus", this, data, stateChanged); }); this.goveeInstance = GoveeInstance; } readonly ip: string; readonly deviceID: string; readonly model: string; readonly socket: Socket; readonly goveeInstance: Govee; readonly versions: { BLEhardware: string; BLEsoftware: string; WiFiHardware: string; WiFiSoftware: string; }; public state: DeviceState; readonly actions = new actions(this); readonly updateValues = async () => await updateValues(this); private updateTimer: NodeJS.Timer; public destroy = () => { this.emit("destroyed"); clearTimeout(this.updateTimer); }; } class actions { constructor(device: Device) { this.device = device; } private device: Device; setColor = (color: colorOptions): Promise => setColor.call(this.device, color); /** * @description * Pass a 0-100 value to set the brightness of the device. */ setBrightness = (brightness: string | number): Promise => setBrightness.call(this.device, brightness); /** * @description * #### Fade the color and brightness of your device. * **Warning**: This works by sending many many commands (At least every 10ms). * * Before the code gets run for sending values, the state of the device gets updated. * *** * Usage: * ```js * fadeColor({ time: 2000, // In milliseconds color: { hex: "#282c34" // Other options possible }, brightness: 20 // 0-100 }); * ``` */ fadeColor = (options: fadeOptions): Promise => { this.cancelFade(); return fade.bind(this.device)(options) }; /** * @description Cancels the current fade action * * @param rejectPromises Reject active fade promise */ cancelFade = (rejectPromises: boolean = false) => this.device.emit("fadeCancel", rejectPromises); /** * @description * Turn off a device. */ setOff = (): Promise => setOff.call(this.device); /** * @description * Turn on a device. */ setOn = (): Promise => setOn.call(this.device); } class GoveeConfig { /** * Automatically start searching for devices when the UDP socket is made. * @default true */ startDiscover?: boolean = true; /** * The interval (in ms) at which new devices will be scanned for. * @default 60000 (1 minute) */ discoverInterval?: number = 60000; logger: (message: string) => void = console.log; errorLogger: (message: string) => void = console.error; } // TODO: I have no idea why i have to define the variables outside the class. But when they're inside the class, they're always undefined outside of the constructor. //? Edit: I do see it now (anonymous functions), but i haven't changed it yet. var deviceList = new Map(); let udpSocket: Socket | undefined; class Govee extends EventEmitter { private config?: GoveeConfig; private isReady = false; constructor(config?: GoveeConfig) { super(); this.config = config; this.getSocket().then(() => { this.emit("ready"); this.isReady = true; }); let discoverInterval = 60_000; if (config && config.discoverInterval) { discoverInterval = config.discoverInterval; } this.once("ready", () => { this.discoverInterval = setInterval(() => { this.discover(); }, discoverInterval); }); } private discoverInterval: NodeJS.Timer | null = null; private getSocket(): Promise { return new Promise((resolve, _reject) => { getGoveeDeviceSocket().then(async (socket?: Socket) => { if (!socket) { this.config.errorLogger("UDP Socket was not estabilished whilst trying to discover new devices.\n\nIs the server able to access UDP port 4001 and 4002 on address 239.255.255.250?"); let whileSocket = undefined; while (whileSocket == undefined) { whileSocket = await this.getSocket(); if (whileSocket == undefined) { this.config.errorLogger("UDP Socket was not estabilished whilst trying to discover new devices.\n\nIs the server able to access UDP port 4001 and 4002 on address 239.255.255.250?"); } } udpSocket = whileSocket; } else { udpSocket = socket; } udpSocket.on("message", this.receiveMessage.bind(this)); //? Now that we have a socket, we can scan (again) //TODO: Creating the socket and scanning can probably combined into 1, but i don't want to risk it, seeing as i have 1 govee device if (!this.config || this.config.startDiscover) { this.discover(); } if (!this.isReady) { this.emit("ready"); this.isReady = true; } resolve(); }); }); }; /** * @description * Use this function to re-send the command to scan for devices. * * Note that you typically don't have to run this command yourself. */ public discover() { if (!udpSocket) { this.config.errorLogger("UDP Socket was not estabilished whilst trying to discover new devices.\n\nIs the server able to access UDP port 4001 and 4002 on address 239.255.255.250?"); return; } let message = JSON.stringify( { "msg": { "cmd": "scan", "data": { "account_topic": "reserve", } } } ); udpSocket.send(message, 0, message.length, 4001, "239.255.255.250"); deviceList.forEach((dev) => { if(!udpSocket) return; udpSocket.send(message, 0, message.length, 4003, dev.ip); const oldCount = this.discoverTimes.get(dev.ip) ?? 0; this.discoverTimes.set(dev.ip, oldCount + 1); if (oldCount >= 4) { this.emit("deviceRemoved", dev); dev.destroy(); deviceList.delete(dev.ip); } }); }; private discoverTimes: Map = new Map(); private async receiveMessage(msg: Buffer, rinfo: RemoteInfo) { const msgRes: messageResponse = JSON.parse(msg.toString()); if (!udpSocket) { return; } const data = msgRes.msg.data; switch (msgRes.msg.cmd) { case "scan": this.onScanMessage(data); break; case "devStatus": this.onDevStatusMessage(rinfo, data); break; default: break; } }; private onDevStatusMessage(rinfo: RemoteInfo, data: Record): void { const device = deviceList.get(rinfo.address); if (!device) { return; } const oldState = JSON.parse(JSON.stringify(device.state)); device.state.brightness = data.brightness; device.state.isOn = data.onOff; device.state.color = data.color; if (!data.color.colorTemInKelvin) { device.state.colorKelvin = ct.rgb2colorTemperature({red: data.color.r, green: data.color.g, blue: data.color.b}); } else { device.state.colorKelvin = data.color.colorTemInKelvin; } const stateChanged: stateChangedOptions = []; const colorChanged = oldState.color.r !== data.color.r || oldState.color.g !== data.color.g || oldState.color.b !== data.color.b; const brightnessChanged = oldState.brightness !== data.brightness; const onOffChanged = oldState.isOn !== data.onOff; //* This may seem like a weird way of doing things, but i want to first get the full state of the device, then i can say it has been added if (!device.state.hasReceivedUpdates) { device.state.hasReceivedUpdates = true; this.emit('deviceAdded', device); } if (brightnessChanged) { stateChanged.push('brightness'); } if (colorChanged) { stateChanged.push('color'); } if (onOffChanged) { stateChanged.push('onOff'); } device.emit('updatedStatus', device.state, stateChanged as stateChangedOptions); // eventEmitter.emit("updatedStatus", device, device.state, stateChanged); } private onScanMessage(data: Record): void { const oldList = Array.from(deviceList.values()); if (!deviceList.has(data.ip)) { if(!udpSocket) return; var device = new Device(data, this, udpSocket); device.updateValues(); } this.discoverTimes.set(data.ip, 0); oldList.forEach((device) => { if (!deviceList.has(device.ip)) { this.emit('deviceRemoved', device); device.destroy(); deviceList.delete(device.ip); } }); } /** * A map of devices where the devices' IP is the key, and the Device object is the value. */ public get devicesMap (): Map { return deviceList; } /** * An array of all devices. */ public get devicesArray (): Device[] { return Array.from(deviceList.values()); } /** * Retrieve the values of all devices. */ public async updateAllDevices () { const updatePromises = this.devicesArray.map((device) => device.updateValues); await Promise.all(updatePromises); return; } public destroy () { this.removeAllListeners(); deviceList = new Map(); udpSocket?.close(); udpSocket = undefined; if(this.discoverInterval !== null) { clearInterval(this.discoverInterval); this.discoverInterval = null; } //? Loop over all devices and clear their timeouts deviceList.forEach((device) => { device.destroy(); }); } } export default Govee; interface messageResponse { msg: { cmd: "devStatus" | "scan", data: Record; }; } export interface DataResponseStatus { onOff: 0 | 1; brightness: number; color: { r: number; g: number; b: number; }; colorTemInKelvin: number; } export type stateChangedOptions = ("onOff" | "brightness" | "color" | undefined)[]; export interface fadeOptions { time: number; color?: colorOptions; brightness?: number; } interface colorOptionsHex { hex: string; rgb?: never; hsl?: never; kelvin?: never; } interface colorOptionsRGB { hex?: never; rgb: [number, number, number]; hsl?: never; kelvin?: never; } interface colorOptionsHSL { hex?: never; rgb?: never; hsl: [number, number, number]; kelvin?: never; } interface colorOptionsKelvin { hex?: never; rgb?: never; hsl?: never; kelvin: string | number; } export type colorOptions = colorOptionsHex | colorOptionsRGB | colorOptionsHSL | colorOptionsKelvin; type DeviceEventTypes = { updatedStatus: (data: DeviceState, stateChanged: stateChangedOptions) => void; fadeCancel: (rejectPromises: boolean) => void; destroyed: () => void; }; export declare interface Device { addListener (event: E, listener: DeviceEventTypes[E]): this; on (event: E, listener: DeviceEventTypes[E]): this; once (event: E, listener: DeviceEventTypes[E]): this; prependListener (event: E, listener: DeviceEventTypes[E]): this; prependOnceListener (event: E, listener: DeviceEventTypes[E]): this; off (event: E, listener: DeviceEventTypes[E]): this; removeAllListeners (event?: E): this; removeListener (event: E, listener: DeviceEventTypes[E]): this; emit (event: E, ...args: Parameters): boolean; // The sloppy `eventNames()` return type is to mitigate type incompatibilities - see #5 eventNames (): (keyof DeviceEventTypes | string | symbol)[]; rawListeners (event: E): DeviceEventTypes[E][]; listeners (event: E): DeviceEventTypes[E][]; listenerCount (event: E): number; getMaxListeners (): number; setMaxListeners (maxListeners: number): this; } type GoveeEventTypes = { ready: () => void; deviceAdded: (device: Device) => void; deviceRemoved: (device: Device) => void; updatedStatus: (device: Device, data: DeviceState, stateChanged: stateChangedOptions) => void; }; interface Govee { addListener (event: E, listener: GoveeEventTypes[E]): this; on (event: E, listener: GoveeEventTypes[E]): this; once (event: E, listener: GoveeEventTypes[E]): this; prependListener (event: E, listener: GoveeEventTypes[E]): this; prependOnceListener (event: E, listener: GoveeEventTypes[E]): this; off (event: E, listener: GoveeEventTypes[E]): this; removeAllListeners (event?: E): this; removeListener (event: E, listener: GoveeEventTypes[E]): this; emit (event: E, ...args: Parameters): boolean; // The sloppy `eventNames()` return type is to mitigate type incompatibilities - see #5 eventNames (): (keyof GoveeEventTypes | string | symbol)[]; rawListeners (event: E): GoveeEventTypes[E][]; listeners (event: E): GoveeEventTypes[E][]; listenerCount (event: E): number; getMaxListeners (): number; setMaxListeners (maxListeners: number): this; }