@homebridge-plugins/homebridge-lutron-caseta-leap
Version:
Homebridge support for Lutron
528 lines • 26.7 kB
JavaScript
import { inspect } from 'node:util';
import { ExceptionDetail } from 'lutron-leap';
import { ButtonTracker } from './ButtonState.js';
import { logButtonPress } from './Logger.js';
import { DeviceWireResultType } from './Platform.HAP.js';
// This maps DeviceType and ButtonNumber to human-readable labels and
// ServiceLabelIndex values. n.b. the labels are not shown in Apple's Home app,
// but are shown in other apps. The index value determines the order that
// buttons are shown in the Home app. They're ordered top-to-bottom (as they
// appear on the physical remote) in this map.
//
// [
// $DeviceType,
// new Map([
// [$ButtonNumber, { label: '...', index: ... }],
// ...
// ]),
// ]
const BUTTON_MAP = new Map([
[
'Pico2Button',
new Map([
[0, { label: 'On', index: 1, isUpDown: false }],
[2, { label: 'Off', index: 2, isUpDown: false }],
]),
],
[
'Pico2ButtonRaiseLower',
new Map([
[0, { label: 'On', index: 1, isUpDown: false }],
[2, { label: 'Off', index: 4, isUpDown: false }],
[3, { label: 'Raise', index: 2, isUpDown: true }],
[4, { label: 'Lower', index: 3, isUpDown: true }],
]),
],
[
'Pico3Button',
new Map([
[0, { label: 'On', index: 1, isUpDown: false }],
[1, { label: 'Center', index: 2, isUpDown: false }],
[2, { label: 'Off', index: 3, isUpDown: false }],
]),
],
[
'Pico3ButtonRaiseLower',
new Map([
[0, { label: 'On', index: 1, isUpDown: false }],
[1, { label: 'Center', index: 3, isUpDown: false }],
[2, { label: 'Off', index: 5, isUpDown: false }],
[3, { label: 'Raise', index: 2, isUpDown: true }],
[4, { label: 'Lower', index: 4, isUpDown: true }],
]),
],
[
'Pico4Button2Group',
new Map([
[1, { label: 'Group 1 On', index: 1, isUpDown: false }],
[2, { label: 'Group 1 Off', index: 2, isUpDown: false }],
[3, { label: 'Group 2 On', index: 3, isUpDown: false }],
[4, { label: 'Group 2 Off', index: 4, isUpDown: false }],
]),
],
[
'Pico4ButtonScene',
new Map([
[1, { label: 'Button 1', index: 1, isUpDown: false }],
[2, { label: 'Button 2', index: 2, isUpDown: false }],
[3, { label: 'Button 3', index: 3, isUpDown: false }],
[4, { label: 'Button 4', index: 4, isUpDown: false }],
]),
],
[
'Pico4ButtonZone',
new Map([
[1, { label: 'Button 1', index: 1, isUpDown: false }],
[2, { label: 'Button 2', index: 2, isUpDown: false }],
[3, { label: 'Button 3', index: 3, isUpDown: false }],
[4, { label: 'Button 4', index: 4, isUpDown: false }],
]),
],
[
'PaddleSwitchPico',
new Map([
[0, { label: 'On', index: 1, isUpDown: false }],
[2, { label: 'Off', index: 2, isUpDown: false }],
]),
],
[
'Pico4Button',
new Map([
[1, { label: 'Button 1', index: 1, isUpDown: false }],
[2, { label: 'Button 2', index: 2, isUpDown: false }],
[3, { label: 'Button 3', index: 3, isUpDown: false }],
[4, { label: 'Button 4', index: 4, isUpDown: false }],
]),
],
]);
export class PicoRemote {
platform;
accessory;
bridge;
options;
services = new Map();
trackers = new Map();
// Map button href to ButtonNumber for event lookup
hrefToButtonNumber = new Map();
// Resolved alias per button href so event handling does not depend solely on ButtonNumber.
hrefToAlias = new Map();
// Buttons collected during initialize() so a single 'disconnected' handler
// can re-subscribe all of them (LeapClient._empty() drops subscriptions on
// socket close — unlike pylutron-caseta, which preserves them).
buttons = [];
matterApi;
constructor(platform, accessory, bridge, options, matterApi) {
this.platform = platform;
this.accessory = accessory;
this.bridge = bridge;
this.options = options;
this.matterApi = matterApi;
}
normalizeButtonName(name) {
return (name || '').toLowerCase().replace(/\s+/g, ' ').trim();
}
aliasFromButtonName(dentry, buttonName) {
const normalizedName = this.normalizeButtonName(buttonName);
if (normalizedName.length === 0) {
return undefined;
}
const directMatch = Array.from(dentry.values()).find(v => this.normalizeButtonName(v.label) === normalizedName);
if (directMatch) {
return directMatch;
}
// Common Lutron naming variant for center button.
if (normalizedName === 'favorite') {
return Array.from(dentry.values()).find(v => this.normalizeButtonName(v.label) === 'center');
}
return undefined;
}
resolveAlias(dentry, button) {
const byNumber = dentry.get(button.ButtonNumber);
if (byNumber) {
return byNumber;
}
return this.aliasFromButtonName(dentry, button.Name);
}
async initialize() {
const fullName = this.accessory.context.device.FullyQualifiedName.join(' ');
this.accessory
.getService(this.platform.api.hap.Service.AccessoryInformation)
.setCharacteristic(this.platform.api.hap.Characteristic.Manufacturer, 'Lutron Electronics Co., Inc')
.setCharacteristic(this.platform.api.hap.Characteristic.Model, this.accessory.context.device.ModelNumber)
.setCharacteristic(this.platform.api.hap.Characteristic.Name, fullName)
.setCharacteristic(this.platform.api.hap.Characteristic.ConfiguredName, fullName)
.setCharacteristic(this.platform.api.hap.Characteristic.SerialNumber, this.accessory.context.device.SerialNumber.toString());
const label_svc = this.accessory.getService(this.platform.api.hap.Service.ServiceLabel)
|| this.accessory.addService(this.platform.api.hap.Service.ServiceLabel);
label_svc.setCharacteristic(this.platform.api.hap.Characteristic.ServiceLabelNamespace, this.platform.api.hap.Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS);
let bgs;
try {
bgs = await this.bridge.getButtonGroupsFromDevice(this.accessory.context.device);
}
catch (e) {
this.platform.log.error('Failed to get button group(s) belonging to', fullName, e);
return {
kind: DeviceWireResultType.Error,
reason: `Failed to get button group(s) belonging to ${fullName}: ${e}`,
};
}
// Bail out if the bridge returned an ExceptionDetail for any button group
// (typically when the device is mid-removal in the Lutron app). The previous
// code used forEach with `return new Error(...)` — both the return and the
// constructed-but-not-thrown Error were no-ops, so ExceptionDetail objects
// flowed into getButtonsFromGroup() below and surfaced as opaque errors.
// Returning Error here (not Skipped) means the cached accessory is preserved
// by the platform.ts cache-preservation rule until the user removes it.
for (const bg of bgs) {
if (bg instanceof ExceptionDetail) {
return {
kind: DeviceWireResultType.Error,
reason: `Bridge returned ExceptionDetail for button group on ${fullName}: ${bg.Message}`,
};
}
}
let buttons = [];
for (const bg of bgs) {
try {
buttons = buttons.concat(await this.bridge.getButtonsFromGroup(bg));
}
catch (e) {
this.platform.log.error('Failed to get buttons from button group', bg.href);
return {
kind: DeviceWireResultType.Error,
reason: `Failed to get buttons from button group ${bg.href}: ${e}`,
};
}
}
// If we've been told to skip Picos already associated in the Lutron app,
// do a two-stage check.
//
// Stage 1 (cheap): any button group with non-empty AffectedZones is
// wired to a light/dimmer/switch/scene in the Lutron app.
//
// The previous check `AffectedZones !== undefined` was always true on
// the wire even for empty arrays, so it skipped audio Picos, fan Picos,
// and scene-only Picos that carry an empty AffectedZones field but
// still have real programming. The opposite of the desired behaviour.
//
// Stage 2 (deep): for Picos with no zone wiring, every button reports
// ProgrammingModelType: 'SingleActionProgrammingModel' regardless of
// whether the user has actually programmed it - so the only ground
// truth is whether each button's linked Preset carries assignments.
// Unprogrammed Presets are a bare {href, Parent} shell. Programmed
// ones carry one or more *Assignments arrays (PresetAssignments,
// DimmedLevelAssignments, SwitchedLevelAssignments, FanSpeedAssignments,
// PlayPauseToggleAssignments, NextTrackAssignments, FavoriteCycleAssignments,
// RaiseLowerAssignments, etc).
if (this.options.filterPico) {
const wired = bgs.find(bg => Array.isArray(bg.AffectedZones) && bg.AffectedZones.length > 0);
if (wired) {
return {
kind: DeviceWireResultType.Skipped,
reason: `Associated with a device outside HomeKit (${wired.href} has ${wired.AffectedZones.length} zone(s) wired)`,
};
}
for (const button of buttons) {
const pmHref = button.ProgrammingModel?.href;
if (!pmHref)
continue;
let pm;
try {
const resp = await this.bridge.getHref({ href: pmHref });
pm = resp?.ProgrammingModel ?? resp;
}
catch (e) {
this.platform.log.warn(`Failed to read programming model ${pmHref} for ${fullName}; treating ${button.href} as unprogrammed:`, e);
continue;
}
const presetHref = pm?.Preset?.href;
if (!presetHref)
continue;
let preset;
try {
const resp = await this.bridge.getHref({ href: presetHref });
preset = resp?.Preset ?? resp;
}
catch (e) {
this.platform.log.warn(`Failed to read preset ${presetHref} for ${fullName}; treating ${button.href} as unprogrammed:`, e);
continue;
}
if (presetIsProgrammed(preset)) {
return {
kind: DeviceWireResultType.Skipped,
reason: `Associated with a device outside HomeKit (${button.href} preset ${presetHref} has assignments)`,
};
}
}
}
const dentry = BUTTON_MAP.get(this.accessory.context.device.DeviceType);
if (dentry === undefined) {
return {
kind: DeviceWireResultType.Error,
reason: `Could not find ${this.accessory.context.device.DeviceType} in button map`,
};
}
for (const button of buttons) {
const alias = this.resolveAlias(dentry, button);
if (alias === undefined) {
this.platform.log.warn(`Skipping unmapped button ${button.ButtonNumber} (${button.Name}) for ${this.accessory.context.device.DeviceType}`);
continue;
}
// Map href to ButtonNumber for event lookup
this.hrefToButtonNumber.set(button.href, button.ButtonNumber);
this.hrefToAlias.set(button.href, alias);
this.platform.log.debug(`setting up ${button.href} named ${button.Name} numbered ${button.ButtonNumber} as ${inspect(alias, true, null)}`);
const service = this.accessory.getServiceById(this.platform.api.hap.Service.StatelessProgrammableSwitch, alias.label)
|| this.accessory.addService(this.platform.api.hap.Service.StatelessProgrammableSwitch, button.Name, alias.label);
service.addLinkedService(label_svc);
service.setCharacteristic(this.platform.api.hap.Characteristic.Name, alias.label);
service.setCharacteristic(this.platform.api.hap.Characteristic.ServiceLabelIndex, alias.index);
const validValues = [this.platform.api.hap.Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS];
if (this.options.clickSpeedDouble !== 'disabled') {
validValues.push(this.platform.api.hap.Characteristic.ProgrammableSwitchEvent.DOUBLE_PRESS);
}
else {
this.platform.log.debug('double press disabled');
}
if (this.options.clickSpeedLong !== 'disabled') {
validValues.push(this.platform.api.hap.Characteristic.ProgrammableSwitchEvent.LONG_PRESS);
}
else {
this.platform.log.debug('long press disabled');
}
const maxProgrammableSwitchEventValue = this.options.clickSpeedLong !== 'disabled'
? this.platform.api.hap.Characteristic.ProgrammableSwitchEvent.LONG_PRESS
: this.options.clickSpeedDouble !== 'disabled'
? this.platform.api.hap.Characteristic.ProgrammableSwitchEvent.DOUBLE_PRESS
: this.platform.api.hap.Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS;
this.platform.log.debug('validValues', validValues);
const emitMatterGesture = (gesture) => {
if (!this.matterApi) {
return;
}
const hasComposedParts = Array.isArray(this.accessory.parts);
const partId = `button-${alias.index}`;
const emitOptions = hasComposedParts
? { partId }
: this.accessory.clusters?.switch
? { position: alias.index }
: undefined;
if (!emitOptions) {
return;
}
const switchApi = this.matterApi.switch;
if (!switchApi || typeof switchApi.emitGesture !== 'function') {
this.platform.log.warn(`[Matter] switch.emitGesture is unavailable for ${this.accessory.displayName}; skipping ${gesture}`);
return;
}
this.platform.log.debug(`[Matter] Emitting switch gesture ${gesture} for ${this.accessory.displayName} ${'partId' in emitOptions ? `part ${emitOptions.partId}` : `position ${emitOptions.position}`}`);
void switchApi.emitGesture(this.accessory.UUID, gesture, emitOptions).catch((error) => {
this.platform.log.warn(`[Matter] Failed to emit gesture ${gesture} for ${this.accessory.displayName}: ${String(error)}`);
});
};
service
.getCharacteristic(this.platform.api.hap.Characteristic.ProgrammableSwitchEvent)
.setProps({
maxValue: maxProgrammableSwitchEventValue,
validValues,
});
const SINGLE_PRESS = () => {
emitMatterGesture('singlePress');
return service
.getCharacteristic(this.platform.api.hap.Characteristic.ProgrammableSwitchEvent)
.setProps({
maxValue: maxProgrammableSwitchEventValue,
validValues,
})
.updateValue(this.platform.api.hap.Characteristic.ProgrammableSwitchEvent.SINGLE_PRESS);
};
let DOUBLE_PRESS;
if (this.options.clickSpeedDouble !== 'disabled') {
DOUBLE_PRESS = () => {
emitMatterGesture('doublePress');
return service
.getCharacteristic(this.platform.api.hap.Characteristic.ProgrammableSwitchEvent)
.setProps({
maxValue: maxProgrammableSwitchEventValue,
validValues,
})
.updateValue(this.platform.api.hap.Characteristic.ProgrammableSwitchEvent.DOUBLE_PRESS);
};
}
else {
DOUBLE_PRESS = () => {
return null;
};
}
let LONG_PRESS;
if (this.options.clickSpeedLong !== 'disabled') {
LONG_PRESS = () => {
emitMatterGesture('longPress');
return service
.getCharacteristic(this.platform.api.hap.Characteristic.ProgrammableSwitchEvent)
.setProps({
maxValue: maxProgrammableSwitchEventValue,
validValues,
})
.updateValue(this.platform.api.hap.Characteristic.ProgrammableSwitchEvent.LONG_PRESS);
};
}
else {
LONG_PRESS = () => {
return null;
};
}
this.services.set(button.href, service);
this.trackers.set(button.href, new ButtonTracker(SINGLE_PRESS, DOUBLE_PRESS, LONG_PRESS, this.platform.log, button.href, this.options.clickSpeedDouble, this.options.clickSpeedLong, alias.isUpDown,
// Thread the user's buttonPressLogging choice into the tracker so
// the interpreted short/long/double press lines respect it. The
// raw Press/Release event log in handleEvent() below uses the
// same option via logButtonPress().
this.options.buttonPressLogging));
// Track this button so the single 'disconnected' handler registered after
// the loop can re-subscribe all of them. The previous code registered one
// disconnect listener per button, which leaked listeners (one Pico with N
// buttons added N listeners) — that's why platform.ts has setMaxListeners(400).
this.buttons.push(button);
this.platform.log.debug(`subscribing to ${button.href} events`);
this.bridge.subscribeToButton(button, this.handleEvent.bind(this));
}
if (this.trackers.size === 0) {
return {
kind: DeviceWireResultType.Error,
reason: `No mapped buttons found for ${this.accessory.context.device.DeviceType}`,
};
}
// LeapClient._empty() clears all taggedSubscriptions on socket close, so we
// must re-register them on reconnect. Home Assistant's lutron_caseta has no
// equivalent because pylutron-caseta preserves subscriptions across reconnect;
// that asymmetry is worth filing upstream against lutron-leap-js.
// One handler per device, not per button — see comment on this.buttons above.
this.bridge.on('disconnected', () => {
this.platform.log.debug(`re-subscribing to ${this.buttons.length} button(s) after connection loss`);
for (const button of this.buttons) {
this.bridge.subscribeToButton(button, this.handleEvent.bind(this));
}
});
this.platform.on('unsolicited', this.handleUnsolicited.bind(this));
return {
kind: DeviceWireResultType.Success,
name: fullName,
};
}
handleEvent(response) {
const evt = response.Body.ButtonStatus;
const buttonHref = evt.Button.href;
const fullName = this.accessory.context.device.FullyQualifiedName.join(' ');
// Raw Press/Release event from the bridge — fires twice per physical
// press (once for Press, once for Release). Routed through
// logButtonPress() so the user's buttonPressLogging option (info /
// debug / silent) governs visibility, independent of the broader
// logLevel option. See Logger.ts.
logButtonPress(this.platform.log, this.options.buttonPressLogging, `Button ${buttonHref} on Pico remote ${fullName} got action ${evt.ButtonEvent.EventType}`);
// Route to the button tracker, which interprets the raw Press/Release
// events into single-press, double-press, or long-press gestures.
// The tracker's callbacks emit both HAP events and Matter events.
this.trackers.get(buttonHref).update(evt.ButtonEvent.EventType);
}
handleUnsolicited(response) {
if (response.Header.MessageBodyType === 'OneButtonStatusEvent') {
const href = response.Body?.ButtonStatus.Button.href;
if (this.services.has(href)) {
this.platform.log.warn('got unsolicited response for known button ', href, ', handling anyway');
this.handleEvent(response);
}
}
}
/**
* Returns a Matter clusters object for this Pico remote, based on its button map.
*/
getMatterClusters() {
const type = this.accessory.context.device.DeviceType;
const fullName = this.accessory.context.device.FullyQualifiedName.join(' ');
this.platform.log.debug(`[Matter] getMatterClusters called for Pico type: ${type}, display name: ${this.accessory.displayName}`);
const dentry = BUTTON_MAP.get(type);
if (!dentry) {
this.platform.log.warn(`[Matter] PicoRemote type '${type}' not found in BUTTON_MAP (${fullName}), returning empty clusters.`);
return {};
}
this.platform.log.debug(`[Matter] Found BUTTON_MAP entry for '${type}' with ${dentry.size} buttons`);
// Expose Pico remotes as composed Matter endpoints: one GenericSwitch-like
// part per physical button. This aligns better with Home's separate button
// presentation than a single multi-position switch endpoint.
const genericSwitchDeviceType = this.matterApi?.deviceTypes?.GenericSwitch;
if (!genericSwitchDeviceType) {
this.platform.log.warn(`[Matter] GenericSwitch deviceType is unavailable for '${type}' (${fullName}), matterApi state: ${this.matterApi ? 'present' : 'absent'}`);
return {};
}
// matter.js GenericSwitch requires explicitly adding Switch behavior/features
// via .with(...). Without this, the endpoint may be created but not expose
// switch event semantics correctly to controllers.
let partDeviceType = genericSwitchDeviceType;
const switchServer = genericSwitchDeviceType?.requirements?.server?.mandatory?.Switch;
if (typeof genericSwitchDeviceType.with === 'function' && switchServer) {
partDeviceType = genericSwitchDeviceType.with(switchServer);
this.platform.log.debug(`[Matter] GenericSwitch parts configured with explicit SwitchServer behavior for '${type}'`);
}
else {
this.platform.log.warn(`[Matter] GenericSwitch SwitchServer behavior was not resolved for '${type}'. Endpoint visibility may be limited in some controllers.`);
}
this.platform.log.debug(`[Matter] GenericSwitch deviceType available, creating parts for '${type}'`);
const sortedAliases = Array.from(dentry.values()).sort((a, b) => a.index - b.index);
this.platform.log.debug(`[Matter] Creating ${sortedAliases.length} button parts for '${type}'`);
const isLongPressEnabled = this.options.clickSpeedLong !== 'disabled';
const parts = sortedAliases.map((alias) => {
const switchCluster = {
currentPosition: 0,
numberOfPositions: 2, // Button has 2 positions: unpressed (0) and pressed (1)
};
// Matter's Switch cluster uses multiPressMax to advertise multi-press support.
// The Matter spec requires multiPressMax >= 2; use 2 regardless of whether
// double-press is enabled (we simply won't emit multi-press events when disabled).
switchCluster.multiPressMax = 2;
// longPressTime indicates long-press capability. Omit it when disabled.
if (isLongPressEnabled) {
switchCluster.longPressTime = 1000;
}
const part = {
id: `button-${alias.index}`,
name: alias.label,
displayName: alias.label,
deviceType: partDeviceType,
clusters: {
switch: switchCluster,
},
};
this.platform.log.debug(`[Matter] Created part: id=${part.id}, name=${part.name}`);
return part;
});
if (parts.length === 0) {
this.platform.log.warn(`[Matter] No button parts created for Pico type '${type}' (${fullName})`);
return {};
}
this.platform.log.info(`[Matter] Pico remote '${fullName}' (${type}): creating ${parts.length} composed endpoint(s)`);
this.platform.log.debug(`[Matter] Pico composed parts payload for '${type}': ${JSON.stringify(parts, null, 2)}`);
const composed = { parts };
return composed;
}
}
// A LEAP Preset is "programmed" if it carries any non-empty assignment array.
// The unprogrammed shape is just {href, Parent}. Programmed Presets carry one
// or more of: PresetAssignments, DimmedLevelAssignments, SwitchedLevelAssignments,
// FanSpeedAssignments, TiltAssignments, PlayPauseToggleAssignments,
// NextTrackAssignments, FavoriteCycleAssignments, RaiseLowerAssignments, etc.
// We don't enumerate the families - any key whose name ends in "Assignments"
// with a non-empty array counts, which makes the check forward-compatible
// with future LEAP types while ignoring unrelated array fields LEAP may add.
export function presetIsProgrammed(preset) {
if (!preset || typeof preset !== 'object')
return false;
for (const [k, v] of Object.entries(preset)) {
if (!k.endsWith('Assignments'))
continue;
if (Array.isArray(v) && v.length > 0)
return true;
}
return false;
}
//# sourceMappingURL=PicoRemote.js.map