Skip to content

Commit 035ef88

Browse files
committed
Matter
1 parent ac45eec commit 035ef88

6 files changed

Lines changed: 187 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,18 @@
1-
## [3.0.2](https://github.com/homebridge-plugins/homebridge-lutron-caseta-leap/compare/v3.0.1...v3.0.2) (2026-04-24)
2-
3-
4-
### Bug Fixes
5-
6-
* This accessory will not be registered. ([fb3c249](https://github.com/homebridge-plugins/homebridge-lutron-caseta-leap/commit/fb3c2492cdcb30b2abe58aa9368be7e410d396c2))
7-
8-
9-
10-
111
# Changelog
122

133
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
144

155
## [3.0.3](https://github.com/homebridge-plugins/homebridge-lutron-caseta-leap/releases/tag/v3.0.3) (2026-04-23)
166

177
### What's Changed
18-
* fix: This accessory will not be registered.
8+
* fix: This accessory will not be registered.
199

2010
**Full Changelog**: https://github.com/homebridge-plugins/homebridge-lutron-caseta-leap/compare/v3.0.2...v3.0.3
2111

2212
## [3.0.2](https://github.com/homebridge-plugins/homebridge-lutron-caseta-leap/releases/tag/v3.0.2) (2026-04-23)
2313

2414
### What's Changed
25-
* fix: This accessory will not be registered.
15+
* fix: This accessory will not be registered. ([fb3c249](https://github.com/homebridge-plugins/homebridge-lutron-caseta-leap/commit/fb3c2492cdcb30b2abe58aa9368be7e410d396c2))
2616

2717
**Full Changelog**: https://github.com/homebridge-plugins/homebridge-lutron-caseta-leap/compare/v3.0.1...v3.0.2
2818

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,4 @@
6565
"typescript": "^6.0.2",
6666
"vitest": "^4.1.4"
6767
}
68-
}
68+
}

src/ButtonState.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,9 @@ export class ButtonTracker {
7575
private longPressCB: () => void,
7676
private log: Logging,
7777
private href: string,
78-
clickSpeedDouble = 'default',
79-
clickSpeedLong = 'default',
80-
isUpDownButton = false,
78+
clickSpeedDouble = 'default',
79+
clickSpeedLong = 'default',
80+
isUpDownButton = false,
8181
) {
8282
log.debug(`btrk ${this.href} created speed ${clickSpeedDouble} dbl ${clickSpeedLong} long`)
8383

src/LutronCasetaLeapMatterPlatform.ts

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
// Type for Matter-required fields
12
// Matter device type IDs (see CSA Device Library)
23
import type { API, Logging, PlatformAccessory, PlatformConfig } from 'homebridge'
34
import type { DeviceDefinition, SmartBridge } from 'lutron-leap'
45

56
import { DeviceWireResultType, LutronCasetaLeap } from './platform.js'
67
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'
78

9+
interface MatterAccessoryFields {
10+
deviceType: number[]
11+
manufacturer: string
12+
model: string
13+
serialNumber: string
14+
}
15+
816
enum MatterDeviceType {
917
RemoteControl = 0x0016,
1018
// Add more as needed
@@ -76,17 +84,92 @@ export class LutronCasetaLeapMatterPlatform extends LutronCasetaLeap {
7684
const result = await this.wireAccessory(accessory, bridge, d)
7785
accessory.displayName = fullName
7886

79-
// Set required Matter deviceType before registration, using types
80-
let deviceType: MatterDeviceType | undefined
87+
// Set all required Matter fields before registration
88+
let matterFields: MatterAccessoryFields | undefined
8189
if (typeof d.DeviceType === 'string' && d.DeviceType.toLowerCase().includes('pico')) {
82-
deviceType = MatterDeviceType.RemoteControl
90+
matterFields = {
91+
deviceType: [MatterDeviceType.RemoteControl],
92+
manufacturer: 'Lutron Electronics',
93+
model: (d.ModelNumber || d.DeviceType || 'Pico Remote').toString(),
94+
serialNumber: d.SerialNumber?.toString() || uuid,
95+
}
96+
// Use PicoRemote to generate clusters and wire up Matter event emission
97+
const PicoRemote = (await import('./PicoRemote.js')).PicoRemote
98+
// Pass mApi and accessory for event emission
99+
const pico = new PicoRemote(this, accessory, bridge, {} as any, mApi)
100+
const clusters = pico.getMatterClusters()
101+
// Deeply remove accidental 'behaviors' property from clusters, accessory, and context, handling circular refs
102+
function deepDeleteBehaviors(obj: any, seen = new WeakSet()) {
103+
if (!obj || typeof obj !== 'object' || seen.has(obj)) {
104+
return
105+
}
106+
seen.add(obj)
107+
if ('behaviors' in obj) {
108+
delete obj.behaviors
109+
}
110+
for (const key of Object.keys(obj)) {
111+
if (typeof obj[key] === 'object' && obj[key] !== null) {
112+
deepDeleteBehaviors(obj[key], seen)
113+
}
114+
}
115+
}
116+
deepDeleteBehaviors(clusters);
117+
(accessory as any).clusters = clusters
118+
accessory.context.clusters = clusters
119+
deepDeleteBehaviors(accessory)
120+
deepDeleteBehaviors(accessory.context)
121+
122+
// Homebridge Matter API does not support setClusterHandler; only set clusters on accessory/context
123+
// Debug: log the final objects before registration to check for lingering 'behaviors'
124+
function safeStringify(obj: any, depth = 4) {
125+
const seen = new WeakSet()
126+
function isTimerObject(o: any) {
127+
if (!o || typeof o !== 'object') return false
128+
const ctor = o.constructor && o.constructor.name
129+
return ctor === 'Timeout' || ctor === 'TimersList'
130+
}
131+
function _stringify(o: any, d: number): any {
132+
if (d < 0 || o === null) return o
133+
if (typeof o !== 'object') return o
134+
if (seen.has(o)) return '[Circular]'
135+
if (isTimerObject(o)) return `[${o.constructor.name}]`
136+
seen.add(o)
137+
if (Array.isArray(o)) return o.map(v => _stringify(v, d - 1))
138+
const out: Record<string, any> = {}
139+
for (const k of Object.keys(o)) {
140+
if (k === 'log' || k === 'platform' || k === 'api') continue // skip noisy fields
141+
const v = o[k]
142+
if (typeof v === 'function') continue
143+
if (isTimerObject(v)) {
144+
out[k] = `[${v.constructor.name}]`
145+
continue
146+
}
147+
// Only serialize plain objects, arrays, and primitives
148+
const ctor = v && v.constructor && v.constructor.name
149+
if (v && typeof v === 'object' && ctor !== 'Object' && ctor !== 'Array') {
150+
out[k] = `[${ctor}]`
151+
continue
152+
}
153+
out[k] = _stringify(v, d - 1)
154+
}
155+
return out
156+
}
157+
try {
158+
return JSON.stringify(_stringify(obj, depth), null, 2)
159+
} catch (e) {
160+
return '[Unserializable object]'
161+
}
162+
}
163+
this.log.warn('[Matter Debug] Accessory before registration:', safeStringify(accessory))
164+
this.log.warn('[Matter Debug] Accessory.context before registration:', safeStringify(accessory.context))
165+
this.log.warn('[Matter Debug] Clusters before registration:', safeStringify(clusters))
83166
}
84167
// Add more device type mappings as needed, using MatterDeviceType enum
85168

86-
if (deviceType !== undefined) {
169+
if (matterFields) {
87170
// Set on both the accessory and its context for maximum compatibility
88-
(accessory as any).deviceType = deviceType
89-
accessory.context.deviceType = deviceType
171+
Object.assign(accessory, matterFields)
172+
Object.assign(accessory.context, matterFields)
90173
}
91174

92175
switch (result.kind) {

src/PicoRemote.ts

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,19 @@ const BUTTON_MAP = new Map<string, Map<number, { label: string, index: number, i
102102
export class PicoRemote {
103103
private services: Map<string, Service> = new Map()
104104
private trackers: Map<string, ButtonTracker> = new Map()
105+
// Map button href to ButtonNumber for event lookup
106+
private hrefToButtonNumber: Map<string, number> = new Map()
105107

108+
private matterApi?: any
106109
constructor(
107110
private readonly platform: LutronCasetaLeap,
108111
private readonly accessory: PlatformAccessory,
109112
private readonly bridge: SmartBridge,
110113
private readonly options: GlobalOptions,
111-
) { }
114+
matterApi?: any,
115+
) {
116+
this.matterApi = matterApi
117+
}
112118

113119
public async initialize(): Promise<DeviceWireResult> {
114120
const fullName = this.accessory.context.device.FullyQualifiedName.join(' ')
@@ -125,8 +131,8 @@ export class PicoRemote {
125131
)
126132

127133
const label_svc
128-
= this.accessory.getService(this.platform.api.hap.Service.ServiceLabel)
129-
|| this.accessory.addService(this.platform.api.hap.Service.ServiceLabel)
134+
= this.accessory.getService(this.platform.api.hap.Service.ServiceLabel)
135+
|| this.accessory.addService(this.platform.api.hap.Service.ServiceLabel)
130136
label_svc.setCharacteristic(
131137
this.platform.api.hap.Characteristic.ServiceLabelNamespace,
132138
this.platform.api.hap.Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS, // ha ha
@@ -187,6 +193,9 @@ export class PicoRemote {
187193
}
188194
}
189195

196+
// Map href to ButtonNumber for event lookup
197+
this.hrefToButtonNumber.set(button.href, button.ButtonNumber)
198+
190199
this.platform.log.debug(
191200
`setting up ${button.href} named ${button.Name} numbered ${button.ButtonNumber} as ${inspect(
192201
alias,
@@ -196,12 +205,12 @@ export class PicoRemote {
196205
)
197206

198207
const service
199-
= this.accessory.getServiceById(this.platform.api.hap.Service.StatelessProgrammableSwitch, alias.label)
200-
|| this.accessory.addService(
201-
this.platform.api.hap.Service.StatelessProgrammableSwitch,
202-
button.Name,
203-
alias.label,
204-
)
208+
= this.accessory.getServiceById(this.platform.api.hap.Service.StatelessProgrammableSwitch, alias.label)
209+
|| this.accessory.addService(
210+
this.platform.api.hap.Service.StatelessProgrammableSwitch,
211+
button.Name,
212+
alias.label,
213+
)
205214
service.addLinkedService(label_svc)
206215

207216
service.setCharacteristic(this.platform.api.hap.Characteristic.Name, alias.label)
@@ -305,11 +314,51 @@ export class PicoRemote {
305314

306315
handleEvent(response: Response): void {
307316
const evt = (response.Body! as OneButtonStatusEvent).ButtonStatus
317+
// Look up ButtonNumber from href
318+
const buttonHref = evt.Button.href
319+
const buttonNumber = this.hrefToButtonNumber.get(buttonHref)
320+
// Emit Matter cluster events for LevelControl and Scenes clusters if present
321+
if (this.matterApi && (this.accessory as any).clusters && buttonNumber !== undefined) {
322+
const dentry = BUTTON_MAP.get(this.accessory.context.device.DeviceType)
323+
if (dentry) {
324+
const alias = dentry.get(buttonNumber)
325+
if (alias) {
326+
// LevelControl: Raise/Lower
327+
if (alias.label.toLowerCase() === 'raise') {
328+
this.matterApi.emitClusterEvent(this.accessory, 'levelControl', 'raise')
329+
} else if (alias.label.toLowerCase() === 'lower') {
330+
this.matterApi.emitClusterEvent(this.accessory, 'levelControl', 'lower')
331+
}
332+
// Scenes: Button 1-4
333+
if (alias.label.toLowerCase().startsWith('button ')) {
334+
const sceneNum = Number.parseInt(alias.label.split(' ')[1], 10)
335+
if (!Number.isNaN(sceneNum)) {
336+
this.matterApi.emitClusterEvent(this.accessory, 'scenes', 'recallScene', sceneNum)
337+
}
338+
}
339+
}
340+
}
341+
}
308342
const fullName = this.accessory.context.device.FullyQualifiedName.join(' ')
309343
this.platform.log.info(
310344
`Button ${evt.Button.href} on Pico remote ${fullName} got action ${evt.ButtonEvent.EventType}`,
311345
)
312346
this.trackers.get(evt.Button.href)!.update(evt.ButtonEvent.EventType)
347+
348+
// Emit Matter cluster event for On/Off cluster if present
349+
if (this.matterApi && (this.accessory as any).clusters?.onOff && buttonNumber !== undefined) {
350+
const dentry = BUTTON_MAP.get(this.accessory.context.device.DeviceType)
351+
if (dentry) {
352+
const alias = dentry.get(buttonNumber)
353+
if (alias) {
354+
if (alias.label.toLowerCase() === 'on') {
355+
this.matterApi.emitClusterEvent(this.accessory, 'onOff', 'on')
356+
} else if (alias.label.toLowerCase() === 'off') {
357+
this.matterApi.emitClusterEvent(this.accessory, 'onOff', 'off')
358+
}
359+
}
360+
}
361+
}
313362
}
314363

315364
handleUnsolicited(response: Response): void {
@@ -321,4 +370,36 @@ export class PicoRemote {
321370
}
322371
}
323372
}
373+
374+
/**
375+
* Returns a Matter clusters object for this Pico remote, based on its button map.
376+
*/
377+
public getMatterClusters(): Record<string, any> {
378+
const type = this.accessory.context.device.DeviceType
379+
const dentry = BUTTON_MAP.get(type)
380+
if (!dentry) {
381+
return {}
382+
}
383+
// Gather all button labels for this remote
384+
const buttonLabels = Array.from(dentry.values()).map(v => v.label.toLowerCase())
385+
const clusters: Record<string, any> = {}
386+
// On/Off cluster for remotes with On/Off buttons
387+
if (buttonLabels.includes('on') && buttonLabels.includes('off')) {
388+
clusters.onOff = { onOff: false }
389+
}
390+
// LevelControl cluster for Raise/Lower
391+
if (buttonLabels.includes('raise') && buttonLabels.includes('lower')) {
392+
clusters.levelControl = { currentLevel: 0, minLevel: 0, maxLevel: 254 }
393+
}
394+
// Scenes cluster for 4-button scene/zone remotes
395+
if (type.includes('4ButtonScene') || type.includes('4ButtonZone')) {
396+
clusters.scenes = { sceneCount: 4 }
397+
}
398+
// 4Button2Group: treat as two on/off pairs
399+
if (type.includes('4Button2Group')) {
400+
clusters.onOff = { onOff: false }
401+
clusters.onOff2 = { onOff: false }
402+
}
403+
return clusters
404+
}
324405
}

0 commit comments

Comments
 (0)