UNPKG

homebridge-yeelighter

Version:

Yeelight support for Homebridge with particular support of ceiling lights

442 lines 20.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.YeeAccessory = exports.TRACKED_ATTRIBUTES = void 0; const specs_1 = require("./specs"); const lightservice_1 = require("./lightservice"); const colorlightservice_1 = require("./colorlightservice"); const whitelightservice_1 = require("./whitelightservice"); const temperaturelightservice_1 = require("./temperaturelightservice"); const backgroundlightservice_1 = require("./backgroundlightservice"); exports.TRACKED_ATTRIBUTES = Object.keys(lightservice_1.EMPTY_ATTRIBUTES); const nameCount = new Map(); function withTimeout(promise, ms, timeoutError = new Error("__timeout__")) { // create a promise that rejects in milliseconds const timeout = new Promise((_resolve, reject) => { setTimeout(() => { reject(timeoutError); }, ms); }); // returns a race between timeout and the passed promise return Promise.race([promise, timeout]); } /** * Platform Accessory * An instance of this class is created for each accessory your platform registers * Each accessory may expose multiple services of different service types. */ class YeeAccessory { static instance(device, platform, accessory, ambientAccessory) { const cache = YeeAccessory.handledAccessories.get(device.info.id); if (cache) { cache.debug("cache hit"); cache.device.reconnect(); return cache; } const a = new YeeAccessory(platform, device, accessory, ambientAccessory); YeeAccessory.handledAccessories.set(device.info.id, a); return a; } get detailedLogging() { return this._detailedLogging; } constructor(platform, device, accessory, ambientAccessory) { this.platform = platform; this.device = device; this.accessory = accessory; this.ambientAccessory = ambientAccessory; this.services = []; this._detailedLogging = false; this.displayName = "unset"; this.attributes = { ...lightservice_1.EMPTY_ATTRIBUTES }; this.lastCommandId = 1; this.heartbeatTimestamp = 0; this.transactions = new Map(); this.keepAlives = new Set(); this.debug = (message, ...optionalParameters) => { this.platform.log.debug(`[${this.name}] ${message}`, optionalParameters); }; this.log = (message, ...optionalParameters) => { this.platform.log.info(`[${this.name}] ${message}`, optionalParameters); }; this.warn = (message, ...optionalParameters) => { this.platform.log.warn(`[${this.name}] ${message}`, optionalParameters); }; this.error = (message, ...optionalParameters) => { this.platform.log.error(`[${this.name}] ${message}`, optionalParameters); }; this.getAttributes = async () => { const now = Date.now(); // Check if we have a cached response and if it's less than a second old if (this.lastFetchTime && now - this.lastFetchTime < 1000) { return this.attributes; } if (this.fetchInProgress) { return this.fetchInProgress; } // Start a new fetch this.fetchInProgress = (async () => { try { await withTimeout(this.sendCommandPromise("get_prop", this.device.info.trackedAttributes), this.platform.config.timeout || 1000); // Cache the response with the current timestamp this.lastFetchTime = now; return this.attributes; } catch (error) { if (error instanceof Error && error.message === "__timeout__") { if (this.attributes.name == "unknown") { this.warn("Retrieving attributes timed out. Using last attributes."); } else { this.error("Retrieving attributes timed out. Returning EMPTY attributes."); // can't throw here - it would take down homebridge // throw new Error("timeout"); } // If the request times out, return the cached response return this.attributes; } this.warn("Retrieving attributes failed. Using last attributes.", error); // If there's an error and we have a cached response, return it return this.attributes; } finally { // Clear the fetchInProgress flag this.fetchInProgress = undefined; } })(); return this.attributes; }; this.onDeviceUpdate = (update) => { const { id, result, error } = update; this.updateTimestamp = Date.now(); if (!id) { // this is some strange unknown message this.warn("unknown response", update); return; } // the the promise for the transaction const transaction = this.transactions.get(id); const keepAlive = this.keepAlives.delete(id); if (!transaction && !keepAlive) { this.warn(`no transactions found for ${id}`); } if (transaction) { const seconds = (Date.now() - transaction.timestamp) / 1000; this.debug(`transaction ${id} took ${seconds}s`, update); this.transactions.delete(id); } if (result && result.length === 1 && result[0] === "ok") { this.connected = true; this.debug(`received ${id}: OK`); transaction === null || transaction === void 0 ? void 0 : transaction.resolve(); // simple ok } else if (result && result.length > 3) { this.connected = true; if (this.lastCommandId != id) { this.warn(`update with unexpected id: ${id}, expected: ${this.lastCommandId}`); this.lastCommandId = id; } const seconds = (Date.now() - this.heartbeatTimestamp) / 1000; this.debug(`received update ${id} after ${seconds}s: ${JSON.stringify(result)}`); const newAttributes = { ...lightservice_1.EMPTY_ATTRIBUTES }; for (const key of Object.keys(this.attributes)) { const index = exports.TRACKED_ATTRIBUTES.indexOf(key); switch (typeof lightservice_1.EMPTY_ATTRIBUTES[key]) { case "number": { if (!Number.isNaN(Number(result[index]))) { newAttributes[key] = Number(result[index]); } break; } case "boolean": { newAttributes[key] = result[index] === "on"; break; } default: { newAttributes[key] = result[index]; break; } } } this.onUpdateAttributes(newAttributes); transaction === null || transaction === void 0 ? void 0 : transaction.resolve(); } else if (error) { if (error.message.includes("quota")) { this.warn(`quota exceeded for request [${id}]`); this.floodAlarm = Date.now(); // this.onDeviceDisconnected(); } else { this.error(`Error returned for request [${id}]: ${JSON.stringify(error)}`); } transaction === null || transaction === void 0 ? void 0 : transaction.reject(error); } else { this.warn(`received unhandled ${id}:`, update); transaction === null || transaction === void 0 ? void 0 : transaction.resolve(); } }; this.onUpdateAttributes = (newAttributes) => { var _a; if (JSON.stringify(this.attributes) !== JSON.stringify(newAttributes)) { if (!((_a = this.config) === null || _a === void 0 ? void 0 : _a.blocking)) { for (const service of this.services) { service.onAttributesUpdated(newAttributes); } } this.attributes = { ...newAttributes }; } }; this.onDeviceConnected = async () => { this.connected = true; this.log(`${this.info.model} Connected`); try { // dont await. We're in an interval handler. this.sendHeartbeat(); } catch (error) { this.error("Failed to retrieve attributes", error); } if (this.platform.config.interval !== 0) { this.interval = setInterval(this.onInterval, this.platform.config.interval || 60000); } }; this.onDeviceDisconnected = () => { var _a; if (this.connected) { this.connected = false; this.log("Disconnected"); if ((_a = this.config) === null || _a === void 0 ? void 0 : _a.offOnDisconnect) { this.attributes.power = false; this.attributes.bg_power = false; this.log("configured to mark as powered-off when disconnected"); for (const service of this.services) service.onPowerOff(); } } if (this.interval) { clearInterval(this.interval); delete this.interval; } }; this.onDeviceError = (error) => { this.log("Device Error", error); }; this.onInterval = () => { if (this.connected) { // if flooded wait for 5 minutes if (this.floodAlarm && Date.now() - this.floodAlarm > 180000000) { this.log(`flooded. waiting ${(Date.now() - this.floodAlarm) / 60000}s`); } else { // seconds since last update const updateSince = (Date.now() - this.updateTimestamp) / 1000; const updateThreshold = ((this.platform.config.timeout || 5000) + (this.platform.config.interval || 60000)) / 1000; if (this.updateTimestamp !== 0 && updateSince > updateThreshold) { this.log(`No update received within ${updateSince}s (Threshold: ${updateThreshold} (${this.platform.config.timeout}+${this.platform.config.interval}) => switching to unreachable`); this.onDeviceDisconnected(); } else { this.sendHeartbeat(); } } // } else { if (this.interval) { clearInterval(this.interval); delete this.interval; } } this.clearOldTransactions(); }; const deviceInfo = device.info; if (!deviceInfo || !deviceInfo.support || !deviceInfo.model) { this.error(`deviceInfo is corrupt or emtpy: ${JSON.stringify(deviceInfo)}`); } const support = deviceInfo.support.split(" "); let specs = specs_1.MODEL_SPECS[deviceInfo.model]; let name = deviceInfo.id; this.connected = false; const override = platform.config.override || []; if (!specs) { specs = { ...specs_1.EMPTY_SPECS }; this.warn(`no specs for light ${deviceInfo.id} ${deviceInfo.model}. It supports: ${deviceInfo.support}. Using fallback. This will not give you nightLight support.`); specs.name = deviceInfo.model; specs.color = support.includes("set_hsv"); specs.backgroundLight = support.includes("bg_set_hsv"); specs.nightLight = false; if (!support.includes("set_ct_abx")) { specs.colorTemperature.min = 0; specs.colorTemperature.max = 0; } } const overrideConfig = override === null || override === void 0 ? void 0 : override.find((item) => item.id === deviceInfo.id); if (overrideConfig === null || overrideConfig === void 0 ? void 0 : overrideConfig.backgroundLight) { specs.backgroundLight = overrideConfig.backgroundLight; } if (overrideConfig === null || overrideConfig === void 0 ? void 0 : overrideConfig.color) { specs.color = overrideConfig.color; } if (overrideConfig === null || overrideConfig === void 0 ? void 0 : overrideConfig.name) { name = overrideConfig.name; } this.name = name; if (overrideConfig === null || overrideConfig === void 0 ? void 0 : overrideConfig.nightLight) { specs.nightLight = overrideConfig.nightLight; } this.specs = specs; this._detailedLogging = !!(overrideConfig === null || overrideConfig === void 0 ? void 0 : overrideConfig.log); this.connectDevice(this.device); let typeString = "UNKNOWN"; const parameters = { accessory, platform, light: this }; if (specs.color) { this.services.push(new colorlightservice_1.ColorLightService(parameters)); typeString = "Color light"; } else { if (specs.colorTemperature.min === 0 && specs.colorTemperature.max === 0) { this.services.push(new whitelightservice_1.WhiteLightService(parameters)); typeString = "White light"; } else { this.services.push(new temperaturelightservice_1.TemperatureLightService(parameters)); typeString = "Color temperature light"; } } if (support.includes("bg_set_power")) { if (this.config.separateAmbient && ambientAccessory) { this.services.push(new backgroundlightservice_1.BackgroundLightService({ ...parameters, accessory: ambientAccessory })); } else { this.services.push(new backgroundlightservice_1.BackgroundLightService(parameters)); } typeString = `${typeString} with ambience light`; } this.support = support; this.updateTimestamp = 0; this.setInfoService(overrideConfig, accessory); if (ambientAccessory) { this.setInfoService(overrideConfig, ambientAccessory); } // name handling this.log(`installed as ${typeString}`); } get info() { return this.device.info; } get config() { const override = (this.platform.config.override || []); const { device } = this.accessory.context; const overrideConfig = override.find((item) => item.id === device.id); return overrideConfig || { id: device.id }; } setAttributes(attributes) { this.attributes = { ...this.attributes, ...attributes }; } connectDevice(device) { device.connect(); device.on("deviceUpdate", this.onDeviceUpdate); device.on("connected", this.onDeviceConnected); device.on("disconnected", this.onDeviceDisconnected); device.on("deviceError", this.onDeviceError); } // Respond to identify request identify(callback) { this.log(`Hi ${this.info.model}`); callback(); } setNameService(service) { service.getCharacteristic(this.platform.Characteristic.ConfiguredName).on("set", (value, callback) => { this.log(`setting name to "${value}".`); service.displayName = value.toString(); this.displayName = value.toString(); service.setCharacteristic(this.platform.Characteristic.Name, value); for (const service of this.services) { service.updateName(value.toString()); } this.platform.api.updatePlatformAccessories([this.accessory]); callback(); }); } setInfoService(override, accessory) { const { platform, specs, info } = this; // set accessory information let infoService = accessory.getService(platform.Service.AccessoryInformation); let name = (override === null || override === void 0 ? void 0 : override.name) || specs.name; let count = nameCount.get(name) || 0; count = count + 1; nameCount.set(name, count); if (count > 1) { name = `${name} ${count}`; } if (infoService) { // re-use service from cache infoService .updateCharacteristic(platform.Characteristic.Manufacturer, "Yeelighter") .updateCharacteristic(platform.Characteristic.Model, specs.name) .updateCharacteristic(platform.Characteristic.Name, name) .updateCharacteristic(platform.Characteristic.SerialNumber, info.id) .updateCharacteristic(platform.Characteristic.FirmwareRevision, info.fw_ver); } else { infoService = new platform.Service.AccessoryInformation(); infoService .updateCharacteristic(platform.Characteristic.Manufacturer, "Yeelighter") .updateCharacteristic(platform.Characteristic.Model, specs.name) .updateCharacteristic(platform.Characteristic.Name, name) .updateCharacteristic(platform.Characteristic.SerialNumber, info.id) .updateCharacteristic(platform.Characteristic.FirmwareRevision, info.fw_ver); accessory.addService(infoService); } this.setNameService(infoService); return infoService; } sendCommand(method, parameters) { if (!this.connected) { this.warn("send command but device doesn't seem connected"); } const supportedCommands = this.device.info.support.split(","); if (!supportedCommands.includes) { this.warn(`sending ${method} although unsupported.`); } const id = this.lastCommandId + 1; this.debug(`sendCommand(${id}, ${method}, ${JSON.stringify(parameters)})`); this.device.sendCommand({ id, method, params: parameters }); this.lastCommandId = id; return id; } sendHeartbeat() { this.debug("sending heartbeat"); this.heartbeatTimestamp = Date.now(); const id = this.sendCommand("get_prop", this.device.info.trackedAttributes); this.keepAlives.add(id); } async sendCommandPromise(method, parameters) { return new Promise((resolve, reject) => { const timestamp = Date.now(); const id = this.sendCommand(method, parameters); this.debug(`sent command ${id}: ${method}`, parameters); this.transactions.set(id, { resolve, reject, timestamp }); }); } clearOldTransactions() { for (const [key, item] of this.transactions.entries()) { // clear transactions older than 60s if (item.timestamp > Date.now() + 60000) { this.log(`error: timeout for request ${key}`); item.reject(new Error("timeout")); this.transactions.delete(key); } } } } exports.YeeAccessory = YeeAccessory; YeeAccessory.handledAccessories = new Map(); //# sourceMappingURL=yeeaccessory.js.map