homebridge-yeelight-wifi
Version:
Homebridge plugin for YeeLight bulbs & lamps.
231 lines (201 loc) • 6.19 kB
JavaScript
const net = require('net');
const { id, handle } = require('../utils');
class YeeBulb {
constructor(props, platform) {
const { id, model, endpoint, accessory } = props;
this.did = id;
this.name = accessory.displayName;
this.model = model;
this.log = platform.log;
this.cmds = {};
this.sock = null;
this.accessory = accessory;
this.config = platform.config || {};
this.endpoint = endpoint;
const { retries = 5, timeout = 100 } = this.config.connection || {};
this.retries = retries;
this.timeout = timeout;
this.accessory
.getService(global.Service.AccessoryInformation)
.setCharacteristic(global.Characteristic.Manufacturer, 'YeeLight')
.setCharacteristic(global.Characteristic.Model, this.model)
.setCharacteristic(global.Characteristic.SerialNumber, this.did);
this.service =
this.accessory.getService(global.Service.Lightbulb) ||
this.accessory.addService(new global.Service.Lightbulb(this.name));
this.service.setPrimaryService();
this.accessory.on('identify', async (_, callback) => {
await this.identify();
callback();
});
this.service
.getCharacteristic(global.Characteristic.On)
.on('set', async (value, callback) => {
try {
await this.setPower(value);
callback(null);
} catch (err) {
callback(err);
}
})
.on('get', async (callback) => {
try {
const [value] = await this.getProperty(['power']);
this.power = value;
callback(null, this.power);
} catch (err) {
callback(err, this.power);
}
})
.updateValue(this.power);
this.accessory.initialized = true;
this.log(`Initialized device ${this.name} (${this.endpoint}).`);
}
get endpoint() {
return `${this.host}:${this.port}`;
}
set endpoint(endpoint) {
const [host, port] = endpoint.split(':');
this.host = host;
this.port = Number(port);
}
get power() {
return !!this._power;
}
set power(state) {
this._power = state === 'on' ? true : false;
}
updateStateFromProp(prop, value) {
if (prop !== 'power') {
this.log.debug(`${prop} is not supported in Homekit, skipping.`);
return;
}
this.power = value;
this.service
.getCharacteristic(global.Characteristic.On)
.updateValue(this.power);
}
async setPower(power) {
if (this.power === power) {
return power;
}
const { power: transition = 400 } = this.config.transitions || {};
const state = power ? 'on' : 'off';
const req = {
method: 'set_power',
params: [state, 'smooth', transition],
};
await this.sendCmd(req);
this.power = state;
}
connect() {
return new Promise((resolve, reject) => {
if (this.sock && !this.sock.destroyed) {
resolve();
return;
}
this.sock = net.connect(this.port, this.host, () => {
this.log.debug(`connected to ${this.host}.`);
resolve();
});
this.sock.on(
'data',
handle([this.responseHandler.bind(this), this.stateHandler.bind(this)])
);
this.sock.on('error', (error) => {
this.log.error(`${this.host}: ${error.message}.`);
reject(error.code);
});
this.sock.on('close', (hadError) => {
this.log.warn(`${this.host} closed. error? ${hadError}.`);
this.cmds = {};
reject(new Error(`close: error? ${hadError}`));
});
});
}
getProperty(properties) {
const req = { method: 'get_prop', params: properties };
return this.sendCmd(req);
}
identify() {
// Use flash notify effect when supported
// TODO: Check support for `start_cf`
const req = {
method: 'start_cf',
params: [10, 0, '500,2,0,10,500,2,0,100'],
};
return this.sendCmd(req);
}
async sendCmd(cmd) {
this.log.info(`Sending command: ${JSON.stringify(cmd)}`);
const { retries, timeout } = this;
cmd.id = id.next().value;
for (let i = 0; i <= retries; i += 1) {
const t = timeout << i;
try {
// await in loop intentionally used to implement exponential backoff
// eslint-disable-next-line no-await-in-loop
return await this._sendCmd(cmd, t);
} catch (err) {
this.log.debug(
`${this.did}: failed communication attempt ${i} after ${t}ms.`
);
if (err === 'EHOSTUNREACH') break;
}
}
this.sock.destroy();
this.log.error(
`${this.did}: failed to send cmd ${cmd.id} after ${retries} retries.`
);
return Promise.reject(new Error(`${cmd.id}`));
}
_sendCmd(cmd, duration) {
const _connect = async () => {
if (!this.sock || this.sock.destroyed) {
await this.connect();
}
};
return new Promise((resolve, reject) => {
_connect()
.catch(reject)
.then(() => {
const msg = JSON.stringify(cmd);
const timeout = setTimeout(() => {
reject(new Error(`${cmd.id}`));
delete this.cmds[cmd.id];
}, duration);
this.sock.write(msg + global.EOL);
this.cmds[cmd.id] = { resolve, reject, timeout };
this.log.debug(msg);
});
});
}
responseHandler(message) {
if (!('id' in message)) return false;
if (!(message.id in this.cmds)) return true;
const cmd = this.cmds[message.id];
clearTimeout(cmd.timeout);
if ('result' in message) {
this.log.debug(message);
cmd.resolve(message.result);
} else if ('error' in message) {
this.log.error(message);
cmd.reject(message.error.message);
} else {
this.log.error(`unexpected result from ${this.host}: ${message}`);
cmd.reject(message.error.message);
}
delete this.cmds[message.id];
return true;
}
stateHandler(message) {
if (!('method' in message && message.method === 'props')) {
return false;
}
Object.keys(message.params).forEach((param) => {
this.updateStateFromProp(param, message.params[param]);
});
return true;
}
}
module.exports = YeeBulb;