homebridge-lutron-caseta-leap-fast
Version:
Support for the Lutron Caseta Smart Bridge 2
290 lines • 14.5 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.LutronCasetaLeap = exports.DeviceWireResultType = void 0;
const events_1 = require("events");
const lutron_leap_1 = require("lutron-leap");
const settings_1 = require("./settings");
const SerenaTiltOnlyWoodBlinds_1 = require("./SerenaTiltOnlyWoodBlinds");
const PicoRemote_1 = require("./PicoRemote");
const OccupancySensor_1 = require("./OccupancySensor");
const fs_1 = __importDefault(require("fs"));
const v8_1 = __importDefault(require("v8"));
const process_1 = __importDefault(require("process"));
var DeviceWireResultType;
(function (DeviceWireResultType) {
DeviceWireResultType[DeviceWireResultType["Success"] = 0] = "Success";
DeviceWireResultType[DeviceWireResultType["Skipped"] = 1] = "Skipped";
DeviceWireResultType[DeviceWireResultType["Error"] = 2] = "Error";
})(DeviceWireResultType || (exports.DeviceWireResultType = DeviceWireResultType = {}));
class LutronCasetaLeap extends events_1.EventEmitter {
constructor(log, config, api) {
super();
this.log = log;
this.config = config;
this.api = api;
this.accessories = new Map();
this.finder = null;
this.bridgeMgr = new Map();
log.info('LutronCasetaLeap starting up...');
process_1.default.on('warning', (e) => this.log.warn(`Got ${e.name} process warning: ${e.message}:\n${e.stack}`));
this.options = this.optionsFromConfig(config);
this.secrets = this.secretsFromConfig(config);
if (this.secrets.size === 0) {
log.warn('No bridge auth configured. Retiring.');
return;
}
// Each device will subscribe to 'unsolicited', which means we very
// quickly hit the limit for EventEmitters. Set this limit to the
// number of bridges times the per-bridge device limit, plus some fudge factor.
this.setMaxListeners(125 * this.secrets.size);
/*
* When this event is fired, homebridge restored all cached accessories from disk and did call their respective
* `configureAccessory` method for all of them. Dynamic Platform plugins should only register new accessories
* after this event was fired, in order to ensure they weren't added to homebridge already.
* This event can also be used to start discovery of new accessories.
*/
api.on("didFinishLaunching" /* APIEvent.DID_FINISH_LAUNCHING */, () => {
log.info('Finished launching; starting up automatic discovery');
this.finder = new lutron_leap_1.BridgeFinder();
this.finder.on('discovered', this.handleBridgeDiscovery.bind(this));
this.finder.on('failed', (error) => {
log.error('Could not connect to discovered hub:', error);
});
this.finder.beginSearching();
});
process_1.default.on('SIGUSR2', () => {
const fileName = `/tmp/lutron.${Date.now()}.heapsnapshot`;
const usage = process_1.default.memoryUsage();
this.log.warn(`Current memory usage:
rss=${usage.rss},
heapTotal=${usage.heapTotal},
heapUsed=${usage.heapUsed},
external=${usage.external},
arrayBuffers=${usage.arrayBuffers}`);
this.log.warn(`Got request to dump heap. Dumping to ${fileName}`);
const snapshotStream = v8_1.default.getHeapSnapshot();
const fileStream = fs_1.default.createWriteStream(fileName);
snapshotStream.pipe(fileStream);
this.log.info(`Heap dump to ${fileName} finished.`);
});
log.info('LutronCasetaLeap plugin finished early initialization');
}
optionsFromConfig(config) {
return Object.assign({
filterPico: false,
filterBlinds: false,
clickSpeedDouble: 'default',
clickSpeedLong: 'default',
logSSLKeyDangerous: false,
}, config.options);
}
secretsFromConfig(config) {
const out = new Map();
for (const entry of config.secrets) {
out.set(entry.bridgeid.toLowerCase(), {
ca: entry.ca,
key: entry.key,
cert: entry.cert,
bridgeid: entry.bridgeid,
});
}
return out;
}
configureAccessory(accessory) {
this.accessories.set(accessory.UUID, accessory);
}
// ----- CUSTOM METHODS
handleBridgeDiscovery(bridgeInfo) {
let replaceClient = false;
const bridgeID = bridgeInfo.bridgeid.toLowerCase();
if (this.bridgeMgr.has(bridgeID)) {
// this is an existing bridge re-announcing itself, so we'll recycle the connection to it
this.log.info('Bridge', bridgeInfo.bridgeid, 'already known, will skip setup.');
replaceClient = true;
}
if (this.secrets.has(bridgeID)) {
const these = this.secrets.get(bridgeID);
this.log.debug('bridge', bridgeInfo.bridgeid, 'has secrets', JSON.stringify(these));
let logfile = undefined;
if (this.options.logSSLKeyDangerous) {
logfile = fs_1.default.createWriteStream(`/tmp/${bridgeInfo.bridgeid}-tlskey.log`, { flags: 'a' });
}
const client = new lutron_leap_1.LeapClient(bridgeInfo.ipAddr, lutron_leap_1.LEAP_PORT, these.ca, these.key, these.cert, logfile);
if (replaceClient) {
// when we close the client connection, it disconnects, which
// causes it to emit a disconnection event. this event will
// propagate to the bridge that owns it, which will emit its
// own disconnect event, triggering re-subscriptions (at the
// LEAP layer) by buttons and occupancy sensors.
//
// I think there's a race here, in that the re-subscription
// will trigger the client reconnect, possibly before the
// client object in the bridge is replaced. As such, we need to
// replace the client object with the new client *before* we
// tell the old client to disconnect. because the bridge
// doesn't tie disconnect events to the client that emitted
// them (why would it? bridges never have more than one
// connection), we should then be able to rely on the
// disconnect event machinery to set things back up for us.
// convenient!
// this should, then, look like this:
// - store new client in bridge
// - close old client
// - old client emits disconnect
// - bridge gets disconnect, emits disconnect
// - devices ask bridge to re-subscribe
// - bridge uses new client to re-subscribe
// - old client goes out of scope
// get a handle to the old client
const old_client = this.bridgeMgr.get(bridgeID);
// replace the old client with the new
this.bridgeMgr.get(bridgeID).client = client;
// close the old client's connections and remove its references to the bridge so it can be GC'd
old_client.close();
}
else {
const bridge = new lutron_leap_1.SmartBridge(bridgeID, client);
// every pico and occupancy sensor needs to subscribe to
// 'disconnected', and that may be a lot of devices, so set the max
// according to Caseta's 75-device limit plus some fudge factor
bridge.setMaxListeners(125);
this.bridgeMgr.set(bridge.bridgeID, bridge);
this.processAllDevices(bridge);
}
}
else {
this.log.info('no credentials from bridge ID', bridgeInfo.bridgeid);
}
}
processAllDevices(bridge) {
bridge.getDeviceInfo().then(async (devices) => {
const results = await Promise.allSettled(devices.map((device) => this.processDevice(bridge, device)));
for (const result of results) {
switch (result.status) {
case 'fulfilled': {
this.log.info(`Device setup finished: ${result.value}`);
break;
}
case 'rejected': {
this.log.error(`Failed to process device: ${result.reason}`);
break;
}
}
}
});
bridge.on('unsolicited', this.handleUnsolicitedMessage.bind(this));
}
async processDevice(bridge, d) {
const fullName = d.FullyQualifiedName.join(' ');
const uuid = this.api.hap.uuid.generate(d.SerialNumber.toString());
let accessory = this.accessories.get(uuid);
let is_from_cache = true;
if (accessory === undefined) {
is_from_cache = false;
// new device, create an accessory
accessory = new this.api.platformAccessory(fullName, uuid);
this.log.debug(`Device ${fullName} not found in accessory cache`);
}
const result = await this.wireAccessory(accessory, bridge, d);
accessory.displayName = fullName;
switch (result.kind) {
case DeviceWireResultType.Error: {
if (is_from_cache) {
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
this.log.debug(`un-registered cached device ${fullName} due to an error: ${result.reason}`);
}
return Promise.reject(new Error(`Failed to wire device ${fullName}: ${result.reason}`));
}
case DeviceWireResultType.Skipped: {
if (is_from_cache) {
this.log.debug(`un-registered cached device ${fullName} because it was skipped`);
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
}
return Promise.resolve(`Skipped setting up device: ${result.reason}`);
}
case DeviceWireResultType.Success: {
if (!is_from_cache) {
this.accessories.set(accessory.UUID, accessory);
this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
this.log.debug(`registered new device ${fullName} because it was new`);
}
return Promise.resolve(`Set up device ${fullName}`);
}
}
}
async wireAccessory(accessory, bridge, device) {
const fullName = device.FullyQualifiedName.join(' ');
accessory.context.device = device;
accessory.context.bridgeID = bridge.bridgeID;
switch (device.DeviceType) {
// serena blinds
case 'SerenaTiltOnlyWoodBlind': {
this.log.info('Found a Serena blind:', fullName);
if (this.options.filterBlinds) {
return {
kind: DeviceWireResultType.Skipped,
reason: 'Serena wood blinds support disabled.',
};
}
// SIDE EFFECT: this constructor mutates the accessory object
new SerenaTiltOnlyWoodBlinds_1.SerenaTiltOnlyWoodBlinds(this, accessory, bridge);
return {
kind: DeviceWireResultType.Success,
name: fullName,
};
}
// supported Pico remotes
case 'Pico2Button':
case 'Pico2ButtonRaiseLower':
case 'Pico3Button':
case 'Pico3ButtonRaiseLower':
case 'Pico4Button2Group':
case 'Pico4ButtonScene':
case 'Pico4ButtonZone': {
this.log.info(`Found a ${device.DeviceType} remote ${fullName}`);
// SIDE EFFECT: this constructor mutates the accessory object
const remote = new PicoRemote_1.PicoRemote(this, accessory, bridge, this.options);
return remote.initialize();
}
// occupancy sensors
case 'RPSOccupancySensor': {
this.log.info(`Found a ${device.DeviceType} occupancy sensor ${fullName}`);
const sensor = new OccupancySensor_1.OccupancySensor(this, accessory, bridge);
return sensor.initialize();
}
// known devices that are not exposed to homekit, pending support
case 'Pico4Button':
case 'FourGroupRemote': {
return Promise.resolve({
kind: DeviceWireResultType.Skipped,
reason: `Device type ${device.DeviceType} not yet supported, skipping setup. Please file a request ticket`,
});
}
// any device we don't know about yet
default:
return Promise.resolve({
kind: DeviceWireResultType.Skipped,
reason: `Device type ${device.DeviceType} not supported by this plugin`,
});
}
}
handleUnsolicitedMessage(bridgeID, response) {
this.log.debug('bridge', bridgeID, 'got unsolicited message', response);
if (response.CommuniqueType === 'UpdateResponse' && response.Header.Url === '/device/status/deviceheard') {
const heardDevice = response.Body.DeviceStatus.DeviceHeard;
this.log.info(`New ${heardDevice.DeviceType} s/n ${heardDevice.SerialNumber}. Triggering refresh in 30s.`);
const bridge = this.bridgeMgr.get(bridgeID);
if (bridge !== undefined) {
setTimeout(() => this.processAllDevices(bridge), 30000);
}
}
else {
this.emit('unsolicited', response);
}
}
}
exports.LutronCasetaLeap = LutronCasetaLeap;
//# sourceMappingURL=platform.js.map