Skip to content

Commit 2355c93

Browse files
committed
matterAllowNonCompliantSinglePress
1 parent 572b456 commit 2355c93

7 files changed

Lines changed: 89 additions & 29 deletions

File tree

config.schema.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@
7676
{ "title": "Debug — visible only with global debug", "enum": ["debug"] },
7777
{ "title": "Silent — never logged", "enum": ["silent"] }
7878
]
79+
},
80+
"matterAllowNonCompliantSinglePress": {
81+
"type": "boolean",
82+
"title": "Allow non-compliant Matter single-press-only mode (breaks spec, hides double/long press in Home app)",
83+
"description": "If enabled, and double press is disabled, exposes Pico remotes as single-press only in Matter (multiPressMax: 1). This breaks the Matter spec but improves Home app UI. Requires re-pairing to take effect.",
84+
"default": false
7985
}
8086

8187
}
@@ -164,6 +170,12 @@
164170
"notitle": false,
165171
"type": "string",
166172
"flex": "1 1 150px"
173+
},
174+
{
175+
"key": "options.matterAllowNonCompliantSinglePress",
176+
"notitle": false,
177+
"type": "boolean",
178+
"flex": "1 1 300px"
167179
}
168180
]
169181

package-lock.json

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

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
],
2323
"main": "dist/index.js",
2424
"engines": {
25-
"homebridge": "^1.11.4 || ^2.0.0-beta.111",
25+
"homebridge": "^2.0.0",
2626
"node": "^22 || ^24"
2727
},
2828
"scripts": {
@@ -56,7 +56,7 @@
5656
"eslint-plugin-format": "^2.0.1",
5757
"eslint-plugin-html": "^8.1.4",
5858
"eslint-plugin-perfectionist": "^5.8.0",
59-
"homebridge": "^2.0.0-beta.111",
59+
"homebridge": "^2.0.0",
6060
"homebridge-config-ui-x": "^5.21.0",
6161
"nodemon": "^3.1.14",
6262
"shx": "^0.4.0",

src/PicoRemote.test.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -145,30 +145,55 @@ describe('picoRemote.getMatterClusters', () => {
145145
},
146146
}
147147

148-
const { platform, accessory } = createPlatformAndAccessory()
149-
const remote = new PicoRemote(
150-
platform,
151-
accessory,
148+
// Test with both double and long press disabled
149+
// If non-compliant option is OFF, multiPressMax should be 2 (spec-compliant)
150+
const { platform: platform1, accessory: accessory1 } = createPlatformAndAccessory()
151+
const remote1 = new PicoRemote(
152+
platform1,
153+
accessory1,
152154
{} as any,
153155
createOptions({
154156
clickSpeedDouble: 'disabled',
155157
clickSpeedLong: 'disabled',
158+
matterAllowNonCompliantSinglePress: false,
156159
}),
157160
{
158161
deviceTypes: {
159162
GenericSwitch: genericSwitchDeviceType,
160163
},
161164
},
162165
)
163-
164-
const clusters = remote.getMatterClusters()
165-
const parts = (clusters as any).parts
166-
167-
expect(parts).toHaveLength(2)
168-
for (const part of parts) {
169-
// Matter spec requires multiPressMax >= 2; always 2 regardless of double-press config
166+
const clusters1 = remote1.getMatterClusters()
167+
const parts1 = (clusters1 as any).parts
168+
expect(parts1).toHaveLength(2)
169+
for (const part of parts1) {
170170
expect(part.clusters.switch.multiPressMax).toBe(2)
171171
expect(part.clusters.switch.longPressTime).toBeUndefined()
172172
}
173+
174+
// If non-compliant option is ON, multiPressMax should be undefined
175+
const { platform: platform2, accessory: accessory2 } = createPlatformAndAccessory()
176+
const remote2 = new PicoRemote(
177+
platform2,
178+
accessory2,
179+
{} as any,
180+
createOptions({
181+
clickSpeedDouble: 'disabled',
182+
clickSpeedLong: 'disabled',
183+
matterAllowNonCompliantSinglePress: true,
184+
}),
185+
{
186+
deviceTypes: {
187+
GenericSwitch: genericSwitchDeviceType,
188+
},
189+
},
190+
)
191+
const clusters2 = remote2.getMatterClusters()
192+
const parts2 = (clusters2 as any).parts
193+
expect(parts2).toHaveLength(2)
194+
for (const part of parts2) {
195+
expect(part.clusters.switch.multiPressMax).toBeUndefined()
196+
expect(part.clusters.switch.longPressTime).toBeUndefined()
197+
}
173198
})
174199
})

src/PicoRemote.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,9 @@ export class PicoRemote {
257257
}
258258
for (const button of buttons) {
259259
const pmHref = (button.ProgrammingModel as { href?: string } | undefined)?.href
260-
if (!pmHref)
260+
if (!pmHref) {
261261
continue
262+
}
262263
let pm: { Preset?: { href?: string } } | undefined
263264
try {
264265
const resp = await this.bridge.getHref({ href: pmHref } as any) as any
@@ -268,8 +269,9 @@ export class PicoRemote {
268269
continue
269270
}
270271
const presetHref = pm?.Preset?.href
271-
if (!presetHref)
272+
if (!presetHref) {
272273
continue
274+
}
273275
let preset: unknown
274276
try {
275277
const resp = await this.bridge.getHref({ href: presetHref } as any) as any
@@ -569,19 +571,21 @@ export class PicoRemote {
569571
const sortedAliases = Array.from(dentry.values()).sort((a, b) => a.index - b.index)
570572
this.platform.log.debug(`[Matter] Creating ${sortedAliases.length} button parts for '${type}'`)
571573
const isLongPressEnabled = this.options.clickSpeedLong !== 'disabled'
574+
const isDoublePressEnabled = this.options.clickSpeedDouble !== 'disabled'
575+
const allowNonCompliant = !!this.options.matterAllowNonCompliantSinglePress
572576

573577
const parts = sortedAliases.map((alias) => {
574578
const switchCluster: Record<string, number> = {
575579
currentPosition: 0,
576580
numberOfPositions: 2, // Button has 2 positions: unpressed (0) and pressed (1)
577581
}
578582

579-
// Matter's Switch cluster uses multiPressMax to advertise multi-press support.
580-
// The Matter spec requires multiPressMax >= 2; use 2 regardless of whether
581-
// double-press is enabled (we simply won't emit multi-press events when disabled).
582-
switchCluster.multiPressMax = 2
583+
// Always set multiPressMax = 2 if non-compliant option is off (spec-compliant), regardless of double press config
584+
if (!allowNonCompliant) {
585+
switchCluster.multiPressMax = 2
586+
}
583587

584-
// longPressTime indicates long-press capability. Omit it when disabled.
588+
// Only set longPressTime if long press is enabled
585589
if (isLongPressEnabled) {
586590
switchCluster.longPressTime = 1000
587591
}
@@ -625,13 +629,16 @@ export class PicoRemote {
625629
// with a non-empty array counts, which makes the check forward-compatible
626630
// with future LEAP types while ignoring unrelated array fields LEAP may add.
627631
export function presetIsProgrammed(preset: unknown): boolean {
628-
if (!preset || typeof preset !== 'object')
632+
if (!preset || typeof preset !== 'object') {
629633
return false
634+
}
630635
for (const [k, v] of Object.entries(preset as Record<string, unknown>)) {
631-
if (!k.endsWith('Assignments'))
636+
if (!k.endsWith('Assignments')) {
632637
continue
633-
if (Array.isArray(v) && v.length > 0)
638+
}
639+
if (Array.isArray(v) && v.length > 0) {
634640
return true
641+
}
635642
}
636643
return false
637644
}

src/Platform.HAP.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ interface PlatformEvents {
3535

3636
// see config.schema.json
3737
export interface GlobalOptions {
38+
/**
39+
* If true, and double press is disabled, expose Pico remotes as single-press only in Matter (multiPressMax: 1).
40+
* This breaks the Matter spec but may improve Home app UI. Requires re-pairing to take effect.
41+
*/
42+
matterAllowNonCompliantSinglePress?: boolean
3843
filterPico: boolean
3944
excludedDeviceTypes: string[]
4045
clickSpeedLong: 'quick' | 'default' | 'relaxed' | 'disabled'
@@ -168,7 +173,9 @@ export class LutronCasetaLeap
168173
// second unhandledRejection event rather than a process exit, so we
169174
// use process.nextTick() to break out of the handler's call stack.
170175
this.log.warn('Unhandled promise rejection (not a known lutron-leap timeout):', reason)
171-
process.nextTick(() => { throw reason })
176+
process.nextTick(() => {
177+
throw reason
178+
})
172179
})
173180
}
174181

src/homebridge-ui/public/index.html

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ <h4 class="card-title mt-2">Discovered Bridges</h4>
88

99
<h4 class="card-title">Global Options</h4>
1010
<form id='optionsForm' class="form-horizontal">
11+
<div class="form-check row py-sm-3 mb-0">
12+
<input class="form-check-input col-sm-1" type="checkbox" value="" id="matterAllowNonCompliantSinglePressChk"/>
13+
<label class="form-check-label col-sm-11 text-left" for="matterAllowNonCompliantSinglePressChk">
14+
Allow non-compliant Matter single-press-only mode (breaks spec, hides double/long press in Home app)
15+
</label>
16+
<br/>
17+
</div>
1118

1219
<div class="form-check row py-sm-3 mb-0">
1320
<input class="form-check-input col-sm-1" type="checkbox" value="" id="filterPicoChk"/>
@@ -146,6 +153,7 @@ <h4 class="card-title">Global Options</h4>
146153
document.querySelector('#clickSpeedLongSelect').value = pluginConfig[0].options.clickSpeedLong;
147154
document.querySelector('#clickSpeedDoubleSelect').value = pluginConfig[0].options.clickSpeedDouble;
148155
setSelectedDeviceTypes(pluginConfig[0].options.excludedDeviceTypes);
156+
document.querySelector('#matterAllowNonCompliantSinglePressChk').checked = !!pluginConfig[0].options.matterAllowNonCompliantSinglePress;
149157

150158
document.getElementById('optionsForm').addEventListener('input', () => {
151159
console.log("options clicked");
@@ -154,6 +162,7 @@ <h4 class="card-title">Global Options</h4>
154162
pluginConfig[0].options.clickSpeedLong = document.querySelector('#clickSpeedLongSelect').value;
155163
pluginConfig[0].options.clickSpeedDouble = document.querySelector('#clickSpeedDoubleSelect').value;
156164
pluginConfig[0].options.excludedDeviceTypes = getSelectedDeviceTypes();
165+
pluginConfig[0].options.matterAllowNonCompliantSinglePress = document.querySelector('#matterAllowNonCompliantSinglePressChk').checked;
157166
console.log(pluginConfig[0].options);
158167
homebridge.updatePluginConfig(pluginConfig);
159168
});

0 commit comments

Comments
 (0)