@homebridge-plugins/homebridge-lutron-caseta-leap
Version:
Homebridge support for Lutron
408 lines • 21.8 kB
JavaScript
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import process from 'node:process';
import v8 from 'node:v8';
import { BridgeFinder, LEAP_PORT, LeapClient, SmartBridge, } from 'lutron-leap';
import { createFilteredLogger } from './Logger.js';
import { OccupancySensor } from './OccupancySensor.js';
import { PicoRemote } from './PicoRemote.js';
import { SerenaTiltOnlyWoodBlinds } from './SerenaTiltOnlyWoodBlinds.js';
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
export var DeviceWireResultType;
(function (DeviceWireResultType) {
DeviceWireResultType[DeviceWireResultType["Success"] = 0] = "Success";
DeviceWireResultType[DeviceWireResultType["Skipped"] = 1] = "Skipped";
DeviceWireResultType[DeviceWireResultType["Error"] = 2] = "Error";
})(DeviceWireResultType || (DeviceWireResultType = {}));
export class LutronCasetaLeap extends EventEmitter {
config;
api;
accessories = new Map();
finder = null;
options;
secrets;
bridgeMgr = new Map();
// Log is declared as a regular field rather than a constructor parameter
// property because we need to wrap it (with the user's logLevel filter)
// before the rest of the constructor — and parameter properties are
// assigned implicitly at the start of the constructor, before user code
// runs. Wrapping at the platform level cascades to every device class
// that reads `this.platform.log`.
log;
constructor(log, config, api) {
super();
this.config = config;
this.api = api;
// Read options first so we know which verbosity level to wrap at, then
// wrap, then use this.log for everything else. createFilteredLogger
// returns the original instance unchanged when level is 'normal' (the
// default), so the only-overhead path is the active-filter case.
this.options = this.optionsFromConfig(config);
this.log = createFilteredLogger(log, this.options.logLevel);
this.log.info('Homebridge Lutron starting up...');
process.on('warning', e => this.log.warn(`Got ${e.name} process warning: ${e.message}:\n${e.stack}`));
this.secrets = this.secretsFromConfig(config);
if (this.secrets.size === 0) {
// Bumped from warn to error: with no secrets the plugin can do
// nothing, so this must bypass any user-configured 'errors-only'
// filter and demand attention. (Previously a warn, which would have
// been suppressed under the new errors-only logLevel.)
this.log.error('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
// a very high number (see [#123](https://github.com/homebridge-plugins/homebridge-lutron/issues/123))
this.setMaxListeners(400 * 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 */, () => {
this.log.info('Finished launching; starting up automatic discovery');
this.finder = new BridgeFinder();
this.finder.on('discovered', this.handleBridgeDiscovery.bind(this));
this.finder.on('failed', (error) => {
this.log.error('Could not connect to discovered hub:', error);
});
this.finder.beginSearching();
});
process.on('SIGUSR2', () => {
const fileName = `/tmp/lutron.${Date.now()}.heapsnapshot`;
const usage = process.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.getHeapSnapshot();
const fileStream = fs.createWriteStream(fileName);
snapshotStream.pipe(fileStream);
this.log.info(`Heap dump to ${fileName} finished.`);
});
this.log.info('Homebridge Lutron plugin finished early initialization');
}
optionsFromConfig(config) {
const rawExcludedDeviceTypes = config.options?.excludedDeviceTypes;
const excludedDeviceTypes = Array.isArray(rawExcludedDeviceTypes)
? rawExcludedDeviceTypes
.filter((value) => typeof value === 'string')
.map(value => value.trim())
.filter(value => value.length > 0)
: [];
return Object.assign({
filterPico: false,
excludedDeviceTypes: [],
clickSpeedDouble: 'default',
clickSpeedLong: 'default',
logSSLKeyDangerous: false,
// Defaults reflect the post-reclassification "sane quiet by default"
// posture. logLevel 'normal' means the wrapper is a passthrough; the
// quietness comes from the call sites being correctly classified.
// buttonPressLogging 'info' keeps press events visible in normal logs.
// Users can set 'debug' to only show presses with global Homebridge
// debug enabled, or 'silent' to drop them entirely.
logLevel: 'normal',
buttonPressLogging: 'info',
}, config.options, { excludedDeviceTypes });
}
isDeviceTypeExcluded(deviceType) {
return this.options.excludedDeviceTypes.includes(deviceType);
}
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
async 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
if (this.bridgeMgr.get(bridgeID).bridgeReconfigInProgress === true) {
// Per mDNS re-announcement noise — fires constantly during steady
// state. Was info; now debug. (See PR conversation: this trio of
// bridge-state lines was the user's primary complaint about info-
// level noise.)
this.log.debug('Bridge', bridgeInfo.bridgeid, 'reconfiguration in progress, do nothing.');
return;
}
// Same — fires on every mDNS re-announce after the first. info → debug.
this.log.debug('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;
if (this.options.logSSLKeyDangerous) {
logfile = fs.createWriteStream(`/tmp/${bridgeInfo.bridgeid}-tlskey.log`, { flags: 'a' });
}
const client = new LeapClient(bridgeInfo.ipAddr, 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
// Bookend an internal reconfigure operation. Useful when debugging
// a reconfigure issue, but normal-path noise otherwise. info → debug.
this.log.debug('Bridge', bridgeInfo.bridgeid, 'entering reconfiguration');
await this.bridgeMgr.get(bridgeID).reconfigureBridge(client);
this.log.debug('Bridge', bridgeInfo.bridgeid, 'exit reconfiguration');
}
else {
const bridge = new SmartBridge(bridgeID, client);
// every pico and occupancy sensor needs to subscribe to
// 'disconnected', and that may be a lot of devices.
// see [#123](https://github.com/homebridge-plugins/homebridge-lutron/issues/123)
bridge.setMaxListeners(400);
this.bridgeMgr.set(bridge.bridgeID, bridge);
this.processAllDevices(bridge);
}
}
else {
// Multi-bridge scenario noise — if the user has 2 bridges and only
// configured 1, the unconfigured one will hit this branch on every
// mDNS announce. info → debug.
this.log.debug('no credentials from bridge ID', bridgeInfo.bridgeid);
}
}
// Wrap getDeviceInfo() with bounded exponential backoff. The plain bridge call
// gives up after one failure, leaving the plugin degraded until mDNS re-announce
// or a deviceheard event — fragile during bridge firmware updates and brief
// network blips. Home Assistant's lutron_caseta delegates retry to Core's
// config-entry harness; Homebridge has no equivalent, so we loop in-plugin.
// ~65s total budget is comparable to HA's ~59s single-attempt window
// (CONNECT_TIMEOUT 9s + CONFIGURE_TIMEOUT 50s).
async getDeviceInfoWithRetry(bridge) {
const delaysMs = [5_000, 15_000, 45_000]; // 4 attempts total: immediate, +5s, +15s, +45s
let lastError;
for (let attempt = 0; attempt <= delaysMs.length; attempt++) {
if (attempt > 0) {
const delay = delaysMs[attempt - 1];
this.log.info(`Retrying device inventory fetch in ${delay / 1000}s (attempt ${attempt + 1}/${delaysMs.length + 1})`);
await new Promise(resolve => setTimeout(resolve, delay));
}
try {
return await bridge.getDeviceInfo();
}
catch (error) {
lastError = error;
this.log.warn(`Device inventory fetch failed (attempt ${attempt + 1}/${delaysMs.length + 1}):`, error);
}
}
throw lastError;
}
processAllDevices(bridge) {
this.getDeviceInfoWithRetry(bridge).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': {
// Fires per device per scan (e.g., 11 lines on every refresh
// for a setup with 11 Picos). The "Found a {DeviceType} ..."
// info lines emitted earlier already announce discovery; this
// line is just confirmation that wiring didn't throw, which
// is the normal outcome. info → debug.
this.log.debug(`Device setup finished: ${result.value}`);
break;
}
case 'rejected': {
this.log.error(`Failed to process device: ${result.reason}`);
break;
}
}
}
}).catch((error) => {
// Log at error (not warn) so users see when the plugin has given up — they
// may need to restart Homebridge if the bridge does not recover on its own.
this.log.error('Failed to fetch device inventory after retries; skipping this scan. Restart Homebridge if the bridge does not recover on its own:', error);
});
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
const PlatformAccessoryCtor = this.api.platformAccessory;
accessory = new PlatformAccessoryCtor(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.log.warn(`Could not refresh device data for cached device ${fullName}; leaving accessory registered: ${result.reason}`);
return Promise.resolve(`Leaving cached accessory registered (refresh failed): ${fullName}`);
}
return Promise.reject(new Error(`Failed to wire device ${fullName}: ${result.reason}`));
}
case DeviceWireResultType.Skipped: {
const isExplicitlyExcluded = result.reason.startsWith('Device type excluded by config: ');
// Mirror the Error-path fix from #207 (v3.0.4): never unregister a cached
// accessory on a refresh-time classification miss. Skipped fires for transient
// bridge responses missing AffectedZones (filterPico path) and for filter
// toggles, both of which can flip across runs. Leaving the accessory registered
// matches Home Assistant's lutron_caseta philosophy — bridge inventory, not
// refresh state, is the source of truth for removal. Users still delete
// intentionally-filtered devices via the cached-accessory cleanup documented
// in the README.
if (is_from_cache) {
if (isExplicitlyExcluded) {
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.accessories.delete(accessory.UUID);
this.log.info(`Unregistered cached accessory for excluded device type ${d.DeviceType}: ${fullName}`);
return Promise.resolve(`Removed cached accessory for excluded device type: ${fullName}`);
}
this.log.warn(`Skipping cached device ${fullName}; leaving accessory registered: ${result.reason}`);
return Promise.resolve(`Leaving cached accessory registered (skipped): ${fullName}`);
}
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(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.log.debug(`registered new device ${fullName} because it was new`);
}
return Promise.resolve(is_from_cache
? `Restoring existing accessory from cache: ${fullName}`
: `Adding new accessory: ${fullName}`);
}
}
}
async wireAccessory(accessory, bridge, device) {
const fullName = device.FullyQualifiedName.join(' ');
accessory.context.device = device;
accessory.context.bridgeID = bridge.bridgeID;
if (this.isDeviceTypeExcluded(device.DeviceType)) {
return Promise.resolve({
kind: DeviceWireResultType.Skipped,
reason: `Device type excluded by config: ${device.DeviceType}`,
});
}
switch (device.DeviceType) {
case 'WallDimmer': {
this.log.info(`Found a WallDimmer ${fullName}`);
const dimmer = new (await import('./WallDimmer.js')).WallDimmer(this, accessory, bridge, device);
if (typeof dimmer.initialize === 'function') {
return dimmer.initialize();
}
return {
kind: DeviceWireResultType.Success,
name: fullName,
};
}
case 'WallSwitch': {
this.log.info(`Found a WallSwitch ${fullName}`);
const wallSwitch = new (await import('./WallSwitch.js')).WallSwitch(this, accessory, bridge, device);
if (typeof wallSwitch.initialize === 'function') {
return wallSwitch.initialize();
}
return {
kind: DeviceWireResultType.Success,
name: fullName,
};
}
// serena blinds
case 'SerenaTiltOnlyWoodBlind': {
this.log.info('Found a Serena blind:', fullName);
// SIDE EFFECT: this constructor mutates the accessory object
new SerenaTiltOnlyWoodBlinds(this, accessory, bridge);
return {
kind: DeviceWireResultType.Success,
name: fullName,
};
}
// supported Pico remotes
case 'Pico2Button':
case 'Pico2ButtonRaiseLower':
case 'Pico3Button':
case 'Pico3ButtonRaiseLower':
case 'Pico4Button':
case 'Pico4Button2Group':
case 'Pico4ButtonScene':
case 'Pico4ButtonZone':
case 'PaddleSwitchPico': {
this.log.info(`Found a ${device.DeviceType} remote ${fullName}`);
// SIDE EFFECT: this constructor mutates the accessory object
const remote = new 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(this, accessory, bridge);
return sensor.initialize();
}
// known devices that are not exposed to homekit, pending support
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);
}
}
}
//# sourceMappingURL=Platform.HAP.js.map