UNPKG

homebridge-lutron-caseta-leap-fast

Version:
290 lines 14.5 kB
"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