@@ -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