@homebridge-plugins/homebridge-lutron-caseta-leap
Version:
Homebridge support for Lutron
340 lines • 19 kB
JavaScript
import { DeviceWireResultType, LutronCasetaLeap } from './Platform.HAP.js';
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
const MATTER_NODE_LABEL_MAX_LENGTH = 32;
function normalizeMatterDisplayName(fullName, serialNumber) {
if (fullName.length <= MATTER_NODE_LABEL_MAX_LENGTH) {
return fullName;
}
// Keep labels deterministic and human-readable while preserving uniqueness.
const compactName = fullName.replace(/\s+/g, ' ').trim();
if (compactName.length <= MATTER_NODE_LABEL_MAX_LENGTH) {
return compactName;
}
const serialSuffix = ` ${serialNumber.slice(-4)}`;
const available = Math.max(1, MATTER_NODE_LABEL_MAX_LENGTH - serialSuffix.length);
return `${compactName.slice(0, available).trimEnd()}${serialSuffix}`;
}
/**
* Homebridge Matter platform for the Lutron Caseta LEAP plugin.
*
* Extends the standard HAP platform ({@link LutronCasetaLeap}) and overrides
* accessory registration to use the Homebridge Matter API when it is
* available. If the Matter API is absent at runtime the class falls back
* transparently to the standard HAP behaviour inherited from
* {@link LutronCasetaLeap}.
*
* Note: `configureAccessory` is intentionally **not** overridden here so that
* cached accessories restored by Homebridge continue to be stored in
* `this.accessories` via the base-class implementation.
*/
export class LutronCasetaLeapMatterPlatform extends LutronCasetaLeap {
constructor(log, config, api) {
super(log, config, api);
// Use this.log (wrapped by the base class with the user's logLevel) so
// the Matter banner respects the same verbosity setting as everything else.
this.log.info('LutronCasetaLeapMatterPlatform: Matter mode active');
}
/**
* Returns the Homebridge Matter sub-API if both `registerPlatformAccessories`
* and `unregisterPlatformAccessories` are present, or `undefined` otherwise.
*/
get matterApi() {
const anyApi = this.api;
if (anyApi?.matter
&& typeof anyApi.matter.registerPlatformAccessories === 'function'
&& typeof anyApi.matter.unregisterPlatformAccessories === 'function') {
return anyApi.matter;
}
return undefined;
}
/**
* Overrides {@link LutronCasetaLeap.processDevice} to register newly
* discovered accessories with the Matter API when available, falling back
* to the standard HAP path when Matter is not present.
*/
async processDevice(bridge, d) {
const mApi = this.matterApi;
if (!mApi) {
// Matter API not present – delegate entirely to the HAP implementation.
return super.processDevice(bridge, d);
}
const fullName = d.FullyQualifiedName.join(' ');
const serialNumber = d.SerialNumber?.toString() || '';
const matterDisplayName = normalizeMatterDisplayName(fullName, serialNumber);
const uuid = this.api.hap.uuid.generate(d.SerialNumber.toString());
let accessory = this.accessories.get(uuid);
let isFromCache = true;
if (accessory === undefined) {
isFromCache = false;
// Use the PlatformAccessory constructor from the API
const PlatformAccessoryCtor = this.api.platformAccessory;
accessory = new PlatformAccessoryCtor(fullName, uuid);
this.log.debug(`Device ${fullName} not found in accessory cache (Matter mode)`);
}
const result = await this.wireAccessory(accessory, bridge, d);
if (result.kind === DeviceWireResultType.Skipped
&& result.reason.startsWith('Device type excluded by config: ')) {
if (isFromCache) {
// In Matter mode, accessories can exist in both HAP and Matter registries.
// Remove from both so previously cached accessories are fully purged.
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
mApi.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.accessories.delete(accessory.UUID);
this.log.info(`Unregistered cached Matter accessory for excluded device type ${d.DeviceType}: ${fullName}`);
return Promise.resolve(`Removed cached Matter accessory for excluded device type: ${fullName}`);
}
return Promise.resolve(`Skipped Matter registration for excluded device type: ${fullName}`);
}
accessory.displayName = matterDisplayName;
if (matterDisplayName !== fullName) {
this.log.debug(`Truncated Matter node label for ${fullName} -> ${matterDisplayName}`);
}
// Set all required Matter fields before registration
// Generalized Matter support for all device types
let matterFields;
let clusters;
let handlers;
let shouldRefreshCachedMatterAccessory = false;
switch (d.DeviceType) {
case 'SmartBridge':
// Suppress SmartBridge from logging or registration
return Promise.resolve(`Skipped Matter registration for SmartBridge`);
case 'Pico2Button':
case 'Pico2ButtonRaiseLower':
case 'Pico3Button':
case 'Pico3ButtonRaiseLower':
case 'Pico4Button':
case 'Pico4Button2Group':
case 'Pico4ButtonScene':
case 'Pico4ButtonZone':
case 'PaddleSwitchPico': {
matterFields = {
deviceType: mApi.deviceTypes.BridgedNode,
manufacturer: 'Lutron Electronics',
model: (d.ModelNumber || d.DeviceType || 'Pico Remote').toString(),
serialNumber: serialNumber || uuid,
};
const PicoRemote = (await import('./PicoRemote.js')).PicoRemote;
const pico = new PicoRemote(this, accessory, bridge, this.optionsFromConfig(this.config), mApi);
clusters = pico.getMatterClusters();
this.log.debug(`[Matter] Pico remote ${fullName} (${d.DeviceType}): getMatterClusters returned ${Object.keys(clusters).length} top-level keys: ${Object.keys(clusters).join(', ')}`);
if (clusters.parts && Array.isArray(clusters.parts)) {
this.log.info(`[Matter] Pico remote ${fullName} will register with ${clusters.parts.length} composed endpoints`);
}
break;
}
case 'SerenaTiltOnlyWoodBlind': {
matterFields = {
deviceType: mApi.deviceTypes.WindowCovering,
manufacturer: 'Lutron Electronics',
model: (d.ModelNumber || d.DeviceType || 'Serena Blind').toString(),
serialNumber: serialNumber || uuid,
};
const { SerenaTiltOnlyWoodBlinds } = await import('./SerenaTiltOnlyWoodBlinds.js');
clusters = SerenaTiltOnlyWoodBlinds.getMatterClusters();
handlers = {
windowCovering: {
goToTiltPercentage: async (request) => {
const zoneTilt100ths = Number(request?.tiltPercent100thsValue
?? request?.targetPositionTiltPercent100ths
?? 0);
const clamped100ths = Math.max(0, Math.min(10000, Math.round(zoneTilt100ths)));
const lutronTilt = clamped100ths / 200;
await bridge.setBlindsTilt(d, lutronTilt);
},
},
};
break;
}
case 'RPSOccupancySensor': {
matterFields = {
deviceType: mApi.deviceTypes.OccupancySensor,
manufacturer: 'Lutron Electronics',
model: (d.ModelNumber || d.DeviceType || 'Occupancy Sensor').toString(),
serialNumber: serialNumber || uuid,
};
const { OccupancySensor } = await import('./OccupancySensor.js');
clusters = OccupancySensor.getMatterClusters();
break;
}
case 'WallDimmer': {
matterFields = {
deviceType: mApi.deviceTypes.DimmableLight,
manufacturer: 'Lutron Electronics',
model: (d.ModelNumber || d.DeviceType || 'Wall Dimmer').toString(),
serialNumber: serialNumber || uuid,
};
const { WallDimmer } = await import('./WallDimmer.js');
clusters = WallDimmer.getMatterClusters();
const zoneHref = d.LocalZones?.[0]?.href;
if (!zoneHref) {
return Promise.resolve(`Skipped Matter registration for device missing LocalZones: ${fullName}`);
}
const clampPercent = (value) => Math.max(0, Math.min(100, Math.round(value)));
const percentFromMatterLevel = (level) => clampPercent((Math.max(0, Math.min(254, level)) / 254) * 100);
const sendLevel = async (levelPercent) => {
await bridge.client.request('CreateRequest', `${zoneHref}/commandprocessor`, {
Command: {
CommandType: 'GoToLevel',
Parameter: [{ Type: 'Level', Value: clampPercent(levelPercent) }],
},
});
};
handlers = {
onOff: {
on: async () => {
const levelState = await mApi.getAccessoryState(uuid, 'levelControl');
const knownLevel = Number(levelState?.currentLevel);
const targetPercent = Number.isFinite(knownLevel) && knownLevel > 0
? percentFromMatterLevel(knownLevel)
: 100;
this.log.debug(`[Matter] WallDimmer ${fullName}: received onOff.on, restoring to ${targetPercent}`);
await sendLevel(targetPercent);
},
off: async () => sendLevel(0),
toggle: async () => {
const resp = await bridge.client.request('ReadRequest', `${zoneHref}/status`);
const status = resp.Body?.ZoneStatus;
const isOn = typeof status?.Level === 'number' && status.Level > 0;
await sendLevel(isOn ? 0 : 100);
},
},
levelControl: {
moveToLevel: async (request) => sendLevel(percentFromMatterLevel(Number(request?.level ?? 0))),
moveToLevelWithOnOff: async (request) => sendLevel(percentFromMatterLevel(Number(request?.level ?? 0))),
},
};
break;
}
case 'WallSwitch': {
matterFields = {
deviceType: mApi.deviceTypes.OnOffLight,
manufacturer: 'Lutron Electronics',
model: (d.ModelNumber || d.DeviceType || 'Wall Switch').toString(),
serialNumber: serialNumber || uuid,
};
const { WallSwitch } = await import('./WallSwitch.js');
clusters = WallSwitch.getMatterClusters();
const zoneHref = d.LocalZones?.[0]?.href;
if (!zoneHref) {
return Promise.resolve(`Skipped Matter registration for device missing LocalZones: ${fullName}`);
}
const sendLevel = async (on) => {
this.log.debug(`[Matter->LEAP] WallSwitch ${fullName}: sending GoToLevel ${on ? 100 : 0} to ${zoneHref}`);
await bridge.client.request('CreateRequest', `${zoneHref}/commandprocessor`, {
Command: {
CommandType: 'GoToLevel',
Parameter: [{ Type: 'Level', Value: on ? 100 : 0 }],
},
});
};
handlers = {
onOff: {
on: async () => {
this.log.debug(`[Matter] WallSwitch ${fullName}: received onOff.on`);
await sendLevel(true);
},
off: async () => {
this.log.debug(`[Matter] WallSwitch ${fullName}: received onOff.off`);
await sendLevel(false);
},
toggle: async () => {
this.log.debug(`[Matter] WallSwitch ${fullName}: received onOff.toggle`);
const resp = await bridge.client.request('ReadRequest', `${zoneHref}/status`);
const status = resp.Body?.ZoneStatus;
const isOn = typeof status?.Level === 'number' && status.Level > 0;
this.log.debug(`[Matter] WallSwitch ${fullName}: current level=${status?.Level ?? 'unknown'}, toggling to ${isOn ? 0 : 100}`);
await sendLevel(!isOn);
},
},
};
break;
}
default:
// Not supported for Matter, skip registration (no log for SmartBridge)
this.log.debug(`Device type ${d.DeviceType} is not supported for Matter, skipping Matter registration for ${fullName}`);
return Promise.resolve(`Skipped Matter registration for unsupported device: ${fullName}`);
}
if (matterFields && clusters) {
const previousParts = Array.isArray(accessory.parts)
? accessory.parts
: [];
// Matter validator requires these fields on the accessory object itself.
Object.assign(accessory, matterFields);
// Keep metadata in context as well so cached accessory restores keep parity.
Object.assign(accessory.context, matterFields);
if ('parts' in clusters && Array.isArray(clusters.parts) && clusters.parts.length > 0) {
const nextParts = clusters.parts;
accessory.parts = nextParts;
delete accessory.clusters;
delete accessory.handlers;
this.log.debug(`[Matter] Registering composed device '${fullName}' with ${nextParts.length} endpoint(s)`);
this.log.debug(`[Matter Debug] Part structure for '${fullName}':`, JSON.stringify(nextParts, null, 2));
// Cached accessories can retain stale endpoint metadata from earlier
// registrations. If part IDs/count changed (or no prior parts existed),
// force a re-register so Home receives the updated composed structure.
if (isFromCache) {
const previousIds = previousParts.map(part => String(part.id)).sort();
const nextIds = nextParts.map(part => String(part.id)).sort();
shouldRefreshCachedMatterAccessory = previousIds.length !== nextIds.length
|| previousIds.some((id, index) => id !== nextIds[index]);
}
}
else {
// Single-endpoint device — strip any 'behaviors' and 'parts' keys and set clusters directly
const pureClusters = Object.fromEntries(Object.entries(clusters).filter(([k]) => k !== 'behaviors' && k !== 'parts'));
if (Object.keys(pureClusters).length === 0) {
this.log.warn(`Device ${fullName} did not provide valid Matter clusters, skipping Matter registration.`);
return Promise.resolve(`Skipped Matter registration for device with invalid clusters: ${fullName}`);
}
;
accessory.clusters = pureClusters;
if (handlers && Object.keys(handlers).length > 0) {
;
accessory.handlers = handlers;
}
else {
delete accessory.handlers;
}
delete accessory.parts;
this.log.debug(`[Matter Debug] Registering '${fullName}' with clusters:`, JSON.stringify(pureClusters, null, 2));
}
}
else {
this.log.warn(`Device ${fullName} did not provide valid Matter clusters, skipping Matter registration.`);
return Promise.resolve(`Skipped Matter registration for device with invalid clusters: ${fullName}`);
}
switch (result.kind) {
case DeviceWireResultType.Error: {
if (isFromCache) {
mApi.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.log.debug(`un-registered cached device ${fullName} (Matter) due to an error: ${result.reason}`);
}
return Promise.reject(new Error(`Failed to wire device ${fullName}: ${result.reason}`));
}
case DeviceWireResultType.Skipped: {
if (isFromCache) {
this.log.debug(`un-registered cached device ${fullName} (Matter) because it was skipped`);
mApi.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
}
return Promise.resolve(`Skipped setting up device: ${result.reason}`);
}
case DeviceWireResultType.Success: {
if (!isFromCache) {
this.accessories.set(accessory.UUID, accessory);
mApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.log.debug(`registered new device ${fullName} (Matter) because it was new`);
}
else if (shouldRefreshCachedMatterAccessory) {
await mApi.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
await mApi.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
this.log.info(`Refreshed cached Matter accessory for composed endpoint changes: ${fullName}`);
}
return Promise.resolve(isFromCache
? `Restoring existing accessory from cache (Matter): ${fullName}`
: `Adding new accessory (Matter): ${fullName}`);
}
}
}
}
//# sourceMappingURL=Platform.Matter.js.map