Skip to content

Commit 3225546

Browse files
committed
Merge branch 'latest' into beta-3.0.5
2 parents a31f5d3 + 0c8ce10 commit 3225546

5 files changed

Lines changed: 84 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## [3.0.5](https://github.com/homebridge-plugins/homebridge-lutron-caseta-leap/compare/v3.0.4...v3.0.5) (2026-04-27)
2+
3+
4+
### Bug Fixes
5+
6+
* cached accessory loss on Skipped, listener leak, inventory retry ([#227](https://github.com/homebridge-plugins/homebridge-lutron-caseta-leap/issues/227)) ([4856be2](https://github.com/homebridge-plugins/homebridge-lutron-caseta-leap/commit/4856be2accc95aec4a1cf778b4bef1610540ee0e))
7+
8+
9+
110
## [3.0.4](https://github.com/homebridge-plugins/homebridge-lutron-caseta-leap/compare/v3.0.1...v3.0.4) (2026-04-25)
211

312
### Bug Fixes

package-lock.json

Lines changed: 1 addition & 1 deletion
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
@@ -2,7 +2,7 @@
22
"name": "@homebridge-plugins/homebridge-lutron-caseta-leap",
33
"displayName": "Lutron Caseta LEAP",
44
"type": "module",
5-
"version": "3.0.4",
5+
"version": "3.0.5",
66
"description": "Support for the Lutron Caseta Smart Bridge 2",
77
"license": "Apache-2.0",
88
"repository": {

src/PicoRemote.ts

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ export class PicoRemote {
104104
private trackers: Map<string, ButtonTracker> = new Map()
105105
// Map button href to ButtonNumber for event lookup
106106
private hrefToButtonNumber: Map<string, number> = new Map()
107+
// Buttons collected during initialize() so a single 'disconnected' handler
108+
// can re-subscribe all of them (LeapClient._empty() drops subscriptions on
109+
// socket close — unlike pylutron-caseta, which preserves them).
110+
private buttons: ButtonDefinition[] = []
107111

108112
private matterApi?: any
109113
constructor(
@@ -158,11 +162,21 @@ export class PicoRemote {
158162
}
159163
}
160164

161-
bgs.forEach((bg) => {
165+
// Bail out if the bridge returned an ExceptionDetail for any button group
166+
// (typically when the device is mid-removal in the Lutron app). The previous
167+
// code used forEach with `return new Error(...)` — both the return and the
168+
// constructed-but-not-thrown Error were no-ops, so ExceptionDetail objects
169+
// flowed into getButtonsFromGroup() below and surfaced as opaque errors.
170+
// Returning Error here (not Skipped) means the cached accessory is preserved
171+
// by the platform.ts cache-preservation rule until the user removes it.
172+
for (const bg of bgs) {
162173
if (bg instanceof ExceptionDetail) {
163-
return new Error('Device has been removed')
174+
return {
175+
kind: DeviceWireResultType.Error,
176+
reason: `Bridge returned ExceptionDetail for button group on ${fullName}: ${bg.Message}`,
177+
}
164178
}
165-
})
179+
}
166180

167181
let buttons: ButtonDefinition[] = []
168182
for (const bg of bgs) {
@@ -294,15 +308,26 @@ export class PicoRemote {
294308
),
295309
)
296310

311+
// Track this button so the single 'disconnected' handler registered after
312+
// the loop can re-subscribe all of them. The previous code registered one
313+
// disconnect listener per button, which leaked listeners (one Pico with N
314+
// buttons added N listeners) — that's why platform.ts has setMaxListeners(400).
315+
this.buttons.push(button)
297316
this.platform.log.debug(`subscribing to ${button.href} events`)
298317
this.bridge.subscribeToButton(button, this.handleEvent.bind(this))
318+
}
299319

300-
// when the connection is lost, so are subscriptions.
301-
this.bridge.on('disconnected', () => {
302-
this.platform.log.debug(`re-subscribing to ${button.href} events after connection loss`)
320+
// LeapClient._empty() clears all taggedSubscriptions on socket close, so we
321+
// must re-register them on reconnect. Home Assistant's lutron_caseta has no
322+
// equivalent because pylutron-caseta preserves subscriptions across reconnect;
323+
// that asymmetry is worth filing upstream against lutron-leap-js.
324+
// One handler per device, not per button — see comment on this.buttons above.
325+
this.bridge.on('disconnected', () => {
326+
this.platform.log.debug(`re-subscribing to ${this.buttons.length} button(s) after connection loss`)
327+
for (const button of this.buttons) {
303328
this.bridge.subscribeToButton(button, this.handleEvent.bind(this))
304-
})
305-
}
329+
}
330+
})
306331

307332
this.platform.on('unsolicited', this.handleUnsolicited.bind(this))
308333

src/platform.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,8 +235,34 @@ export class LutronCasetaLeap
235235
}
236236
}
237237

238+
// Wrap getDeviceInfo() with bounded exponential backoff. The plain bridge call
239+
// gives up after one failure, leaving the plugin degraded until mDNS re-announce
240+
// or a deviceheard event — fragile during bridge firmware updates and brief
241+
// network blips. Home Assistant's lutron_caseta delegates retry to Core's
242+
// config-entry harness; Homebridge has no equivalent, so we loop in-plugin.
243+
// ~65s total budget is comparable to HA's ~59s single-attempt window
244+
// (CONNECT_TIMEOUT 9s + CONFIGURE_TIMEOUT 50s).
245+
private async getDeviceInfoWithRetry(bridge: SmartBridge): Promise<DeviceDefinition[]> {
246+
const delaysMs = [5_000, 15_000, 45_000] // 4 attempts total: immediate, +5s, +15s, +45s
247+
let lastError: unknown
248+
for (let attempt = 0; attempt <= delaysMs.length; attempt++) {
249+
if (attempt > 0) {
250+
const delay = delaysMs[attempt - 1]
251+
this.log.info(`Retrying device inventory fetch in ${delay / 1000}s (attempt ${attempt + 1}/${delaysMs.length + 1})`)
252+
await new Promise(resolve => setTimeout(resolve, delay))
253+
}
254+
try {
255+
return await bridge.getDeviceInfo()
256+
} catch (error) {
257+
lastError = error
258+
this.log.warn(`Device inventory fetch failed (attempt ${attempt + 1}/${delaysMs.length + 1}):`, error)
259+
}
260+
}
261+
throw lastError
262+
}
263+
238264
private processAllDevices(bridge: SmartBridge) {
239-
bridge.getDeviceInfo().then(async (devices: DeviceDefinition[]) => {
265+
this.getDeviceInfoWithRetry(bridge).then(async (devices: DeviceDefinition[]) => {
240266
const results: PromiseSettledResult<string>[] = await Promise.allSettled(
241267
devices.map((device: DeviceDefinition) => this.processDevice(bridge, device)),
242268
)
@@ -253,7 +279,9 @@ export class LutronCasetaLeap
253279
}
254280
}
255281
}).catch((error) => {
256-
this.log.warn('Failed to fetch device inventory; skipping this scan:', error)
282+
// Log at error (not warn) so users see when the plugin has given up — they
283+
// may need to restart Homebridge if the bridge does not recover on its own.
284+
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)
257285
})
258286

259287
bridge.on('unsolicited', this.handleUnsolicitedMessage.bind(this))
@@ -283,9 +311,17 @@ export class LutronCasetaLeap
283311
return Promise.reject(new Error(`Failed to wire device ${fullName}: ${result.reason}`))
284312
}
285313
case DeviceWireResultType.Skipped: {
314+
// Mirror the Error-path fix from #207 (v3.0.4): never unregister a cached
315+
// accessory on a refresh-time classification miss. Skipped fires for transient
316+
// bridge responses missing AffectedZones (filterPico path) and for filter
317+
// toggles, both of which can flip across runs. Leaving the accessory registered
318+
// matches Home Assistant's lutron_caseta philosophy — bridge inventory, not
319+
// refresh state, is the source of truth for removal. Users still delete
320+
// intentionally-filtered devices via the cached-accessory cleanup documented
321+
// in the README.
286322
if (is_from_cache) {
287-
this.log.debug(`un-registered cached device ${fullName} because it was skipped`)
288-
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
323+
this.log.warn(`Skipping cached device ${fullName}; leaving accessory registered: ${result.reason}`)
324+
return Promise.resolve(`Leaving cached accessory registered (skipped): ${fullName}`)
289325
}
290326
return Promise.resolve(`Skipped setting up device: ${result.reason}`)
291327
}

0 commit comments

Comments
 (0)