homebridge-yeelighter
Version:
Yeelight support for Homebridge with particular support of ceiling lights
442 lines • 20.1 kB
JavaScript
"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