@mdjhnson/homebridge-yoto 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <img src="https://github.com/homebridge/branding/raw/latest/logos/homebridge-wordmark-logo-vertical.png" width="150">
4
4
 
5
- <img src="./logo.png" width="150">
5
+ <img src="https://raw.githubusercontent.com/mdjhnson/homebridge-yoto/master/logo.png" width="150">
6
6
 
7
7
  </p>
8
8
 
@@ -33,7 +33,7 @@ Search for `@mdjhnson/homebridge-yoto` in the Homebridge UI **Plugins** tab, or
33
33
  npm install -g @mdjhnson/homebridge-yoto
34
34
  ```
35
35
 
36
- Requires Node.js 22+ and Homebridge 1.8+ or 2.x.
36
+ Requires Node.js 22, 24 or 26 and Homebridge 1.8+ or 2.x.
37
37
 
38
38
  ## Sign in
39
39
 
@@ -110,6 +110,11 @@ The TV accessory, and the legacy Smart Speaker below, are external accessories.
110
110
 
111
111
  - **Switching from `homebridge-yoto`:** uninstall the original plugin first. Both register the `Yoto` platform and would conflict. Your existing `Yoto` config block keeps working, but bridged accessories are re-created, so you'll need to re-add them to rooms and automations.
112
112
  - **Removed external accessories:** if you turn off the Smart Speaker or TV accessory, or a player leaves your account, remove the old accessory from the Home app by hand. Homebridge can't unpublish external accessories.
113
+ - **Yoto unreachable at startup:** if the network or Yoto's API is down when Homebridge starts, the plugin keeps retrying, waiting longer each time (up to 10 minutes between tries). You don't need to restart Homebridge. If Yoto rejects the saved login, the log asks you to sign in again instead of retrying.
114
+
115
+ ## Privacy
116
+
117
+ The plugin only connects to Yoto's API and MQTT service, using your own sign-in. It has no analytics or tracking. The only file it changes is Homebridge's `config.json`, to save refreshed sign-in tokens. It writes a temporary copy next to `config.json` first and renames it into place, so an interrupted save can't corrupt the file.
113
118
 
114
119
  ## Development
115
120
 
package/lib/accessory.js CHANGED
@@ -50,7 +50,7 @@ import { getPlaybackAccessoryConfig } from './service-config.js'
50
50
  import { getCardControlConfigs } from './card-controls.js'
51
51
  import { getCardStartOptions, getShortcutEntries, getShortcutsSignature, toPlayableCard } from './shortcuts.js'
52
52
  import { formatError } from './utils/error-format.js'
53
- import { ListenerGroup } from './utils/listener-group.js'
53
+ import { ListenerGroup, logListenerError } from './utils/listener-group.js'
54
54
  import { setDeviceVolume } from './utils/set-device-volume.js'
55
55
  import { getBooleanSetting } from './utils/get-boolean-setting.js'
56
56
  import {
@@ -118,7 +118,10 @@ export class YotoPlayerAccessory {
118
118
  this.#platform = platform
119
119
  this.#accessory = accessory
120
120
  this.#deviceModel = deviceModel
121
- this.#listeners = new ListenerGroup(deviceModel)
121
+ this.#listeners = new ListenerGroup(
122
+ deviceModel,
123
+ logListenerError(platform.log, `${LOG_PREFIX.ACCESSORY} [${accessory.context.device.name}]`)
124
+ )
122
125
  this.#log = platform.log
123
126
 
124
127
  // Extract device info from context
package/lib/platform.js CHANGED
@@ -5,6 +5,7 @@
5
5
  /** @import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge' */
6
6
  /** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
7
7
  /** @import { YotoDeviceModel } from 'yoto-nodejs-client' */
8
+ /** @import { YotoAccountEventMap } from 'yoto-nodejs-client/lib/yoto-account.js' */
8
9
  /** @import { PlaybackAccessoryConfig } from './service-config.js' */
9
10
  /** @import { CardControlConfig } from './card-controls.js' */
10
11
 
@@ -29,6 +30,7 @@ import {
29
30
  PLATFORM_NAME,
30
31
  PLUGIN_NAME,
31
32
  DEFAULT_CLIENT_ID,
33
+ LOG_PREFIX,
32
34
  } from './settings.js'
33
35
  import { YotoPlayerAccessory } from './accessory.js'
34
36
  import { YotoSpeakerAccessory } from './speaker-accessory.js'
@@ -37,10 +39,28 @@ import { YotoCardControlAccessory } from './card-control-accessory.js'
37
39
  import { sanitizeName } from './utils/sanitize-name.js'
38
40
  import { getPlaybackAccessoryConfig } from './service-config.js'
39
41
  import { getCardControlConfigs } from './card-controls.js'
40
- import { formatError } from './utils/error-format.js'
42
+ import { errorMessage, formatError, getStatusCode } from './utils/error-format.js'
43
+ import { ListenerGroup, logListenerError } from './utils/listener-group.js'
41
44
  import { applyTokenUpdate } from './utils/token-config.js'
42
45
  import { tolerateMissingStatusScope } from './utils/status-scope-fallback.js'
43
46
 
47
+ /** First retry delay when the Yoto API can't be reached at startup; doubles each attempt */
48
+ const START_RETRY_BASE_MS = 30 * 1000
49
+ /** Longest wait between startup retries */
50
+ const START_RETRY_MAX_MS = 10 * 60 * 1000
51
+
52
+ /**
53
+ * Whether a failed account start could succeed if tried again. Network errors
54
+ * and 5xx responses are transient; other 4xx responses won't change on retry.
55
+ * @param {unknown} error
56
+ * @returns {boolean}
57
+ */
58
+ function isRetryableStartError (error) {
59
+ const statusCode = getStatusCode(error)
60
+ if (statusCode === undefined) return true
61
+ return statusCode >= 500 || statusCode === 408 || statusCode === 429
62
+ }
63
+
44
64
  /**
45
65
  * Yoto Platform implementation
46
66
  * This class is the main constructor for your plugin, this is where you should
@@ -63,8 +83,13 @@ export class YotoPlatform {
63
83
  /** @type {Map<string, YotoTelevisionAccessory>} */ televisionAccessoryHandlers = new Map()
64
84
  /** @type {Map<string, YotoCardControlAccessory>} */ cardAccessoryHandlers = new Map()
65
85
  /** @type {YotoAccount | null} */ yotoAccount = null
86
+ /** @type {ListenerGroup<YotoAccountEventMap> | null} */ accountListeners = null
66
87
  /** @type {string} */ sessionId = randomUUID()
67
88
  /** @type {Map<string, Promise<string | null>>} */ cardTitleCache = new Map()
89
+ /** @type {boolean} */ authInvalid = false
90
+ /** @type {number} */ startRetryCount = 0
91
+ /** @type {ReturnType<typeof setTimeout> | null} */ startRetryTimer = null
92
+ /** @type {boolean} */ shuttingDown = false
68
93
 
69
94
  /**
70
95
  * @param {Logger} log - Homebridge logger
@@ -102,63 +127,83 @@ export class YotoPlatform {
102
127
  const { sessionId } = this
103
128
  const updateHomebridgeConfig = this.updateHomebridgeConfig.bind(this)
104
129
 
105
- // Initialize YotoAccount with client and device options
106
- this.yotoAccount = new YotoAccount({
107
- clientOptions: {
108
- clientId,
109
- refreshToken,
110
- accessToken,
111
- onTokenRefresh: async ({ updatedAccessToken, updatedRefreshToken, updatedExpiresAt, prevAccessToken, prevRefreshToken }) => {
112
- // Client reports expiry in seconds; config.json stores milliseconds (matches the UI)
113
- const tokenExpiresAt = updatedExpiresAt * 1000
114
- log.debug('Access token refreshed, expires at:', new Date(tokenExpiresAt).toISOString())
115
-
116
- // Keep the in-memory config in sync so later reads see current tokens
117
- config['accessToken'] = updatedAccessToken
118
- config['refreshToken'] = updatedRefreshToken
119
- config['tokenExpiresAt'] = tokenExpiresAt
120
-
121
- await updateHomebridgeConfig((configContents) => applyTokenUpdate(configContents, {
122
- platform: PLATFORM_NAME,
123
- accessToken: updatedAccessToken,
124
- refreshToken: updatedRefreshToken,
125
- tokenExpiresAt,
126
- prevAccessToken,
127
- prevRefreshToken,
128
- }))
129
- },
130
- onRefreshStart: () => {
131
- log.debug('Refreshing Yoto access token...')
132
- },
133
- onRefreshError: (error) => {
134
- log.warn('Yoto token refresh failed, will retry:', error.message)
135
- log.debug('Token refresh error details:', formatError(error))
136
- },
137
- onInvalid: (error) => {
138
- log.error(
139
- 'Yoto login has expired or been revoked. Open the plugin settings in the Homebridge UI and sign in again.',
140
- error.message
141
- )
130
+ // Initialize YotoAccount with client and device options. It decodes the saved
131
+ // access token, which throws if config.json holds a malformed one; Homebridge
132
+ // doesn't catch errors from platform constructors, so it would crash on every start.
133
+ /** @type {YotoAccount} */
134
+ let account
135
+ try {
136
+ account = new YotoAccount({
137
+ clientOptions: {
138
+ clientId,
139
+ refreshToken,
140
+ accessToken,
141
+ onTokenRefresh: async ({ updatedAccessToken, updatedRefreshToken, updatedExpiresAt, prevAccessToken, prevRefreshToken }) => {
142
+ // Client reports expiry in seconds; config.json stores milliseconds (matches the UI)
143
+ const tokenExpiresAt = updatedExpiresAt * 1000
144
+ log.debug('Access token refreshed, expires at:', new Date(tokenExpiresAt).toISOString())
145
+
146
+ // Keep the in-memory config in sync so later reads see current tokens
147
+ config['accessToken'] = updatedAccessToken
148
+ config['refreshToken'] = updatedRefreshToken
149
+ config['tokenExpiresAt'] = tokenExpiresAt
150
+
151
+ await updateHomebridgeConfig((configContents) => applyTokenUpdate(configContents, {
152
+ platform: PLATFORM_NAME,
153
+ accessToken: updatedAccessToken,
154
+ refreshToken: updatedRefreshToken,
155
+ tokenExpiresAt,
156
+ prevAccessToken,
157
+ prevRefreshToken,
158
+ }))
159
+ },
160
+ onRefreshStart: () => {
161
+ log.debug('Refreshing Yoto access token...')
162
+ },
163
+ onRefreshError: (error) => {
164
+ log.warn('Yoto token refresh failed, will retry:', error.message)
165
+ log.debug('Token refresh error details:', formatError(error))
166
+ },
167
+ onInvalid: (error) => {
168
+ this.authInvalid = true
169
+ log.error(
170
+ 'Yoto login has expired or been revoked. Open the plugin settings in the Homebridge UI and sign in again.',
171
+ error.message
172
+ )
173
+ },
142
174
  },
143
- },
144
- deviceOptions: {
145
- httpPollIntervalMs: Math.max(10000, Number(config['httpPollIntervalMs']) || 60000),
146
- yotoDeviceMqttOptions: {
147
- sessionId
175
+ deviceOptions: {
176
+ httpPollIntervalMs: Math.max(10000, Number(config['httpPollIntervalMs']) || 60000),
177
+ yotoDeviceMqttOptions: {
178
+ sessionId
179
+ }
148
180
  }
149
- }
150
- })
181
+ })
182
+ } catch (error) {
183
+ log.error(
184
+ 'Could not load the saved Yoto login. Open the plugin settings in the Homebridge UI and sign in again.',
185
+ errorMessage(error)
186
+ )
187
+ return
188
+ }
189
+ this.yotoAccount = account
190
+ // The account re-emits device events from MQTT and timer callbacks, so a
191
+ // throwing listener would be an uncaught exception that stops Homebridge
192
+ this.accountListeners = new ListenerGroup(account, logListenerError(log, LOG_PREFIX.PLATFORM))
151
193
 
152
194
  // The status endpoint needs a scope the Yoto dashboard doesn't offer yet
153
195
  tolerateMissingStatusScope(this.yotoAccount.client, (message) => log.debug(message))
154
196
 
155
197
  // Listen to account-level events
156
- this.yotoAccount.on('error', ({ error, context }) => {
198
+ this.accountListeners.on('error', ({ error, context }) => {
157
199
  const details = formatError(error)
158
200
  if (context.deviceId) {
159
201
  const label = this.formatDeviceLabel(context.deviceId)
160
202
  log.error(`Device error [${label} ${context.operation} ${context.source}]:`, details)
161
203
  log.debug('Device error context:', context)
204
+ } else if (context.operation === 'start') {
205
+ // connectAccount() logs the failure and schedules a retry
206
+ log.debug('Account start error:', details)
162
207
  } else {
163
208
  log.error('Account error:', details)
164
209
  log.debug('Account error context:', context)
@@ -178,7 +223,9 @@ export class YotoPlatform {
178
223
  // When homebridge shuts down, cleanup all handlers and MQTT connections
179
224
  api.on('shutdown', () => {
180
225
  log.debug('Homebridge shutting down, cleaning up accessories...')
181
- this.shutdown()
226
+ this.shutdown().catch(error => {
227
+ log.error('Error while shutting down:', formatError(error))
228
+ })
182
229
  })
183
230
  }
184
231
 
@@ -215,7 +262,8 @@ export class YotoPlatform {
215
262
  * Start YotoAccount - discovers devices and creates device models
216
263
  */
217
264
  async startAccount () {
218
- if (!this.yotoAccount) {
265
+ const listeners = this.accountListeners
266
+ if (!this.yotoAccount || !listeners) {
219
267
  this.log.error('Cannot start account - YotoAccount not initialized')
220
268
  return
221
269
  }
@@ -234,35 +282,40 @@ export class YotoPlatform {
234
282
  }
235
283
 
236
284
  // Listen for devices being added
237
- this.yotoAccount.on('deviceAdded', async ({ deviceId }) => {
238
- // Homebridge may shut down while the account is still starting
239
- if (!this.yotoAccount) return
240
- const deviceModel = this.yotoAccount.getDevice(deviceId)
241
- if (!deviceModel) {
242
- const label = this.formatDeviceLabel(deviceId)
243
- this.log.warn(`Device added but no model found for ${label}`)
244
- return
285
+ listeners.on('deviceAdded', async ({ deviceId }) => {
286
+ // The account doesn't await this listener, so errors must not escape it
287
+ try {
288
+ // Homebridge may shut down while the account is still starting
289
+ if (this.shuttingDown || !this.yotoAccount) return
290
+ const deviceModel = this.yotoAccount.getDevice(deviceId)
291
+ if (!deviceModel) {
292
+ const label = this.formatDeviceLabel(deviceId)
293
+ this.log.warn(`Device added but no model found for ${label}`)
294
+ return
295
+ }
296
+
297
+ const device = deviceModel.device
298
+ this.log.info(`Device discovered: ${device.name} (${deviceId})`)
299
+ this.log.debug('Registering device from account discovery:', device.name, deviceId)
300
+ await this.registerDevice(device, deviceModel)
301
+ } catch (error) {
302
+ this.log.error(`Failed to set up ${this.formatDeviceLabel(deviceId)}:`, formatError(error))
245
303
  }
246
-
247
- const device = deviceModel.device
248
- this.log.info(`Device discovered: ${device.name} (${deviceId})`)
249
- this.log.debug('Registering device from account discovery:', device.name, deviceId)
250
- await this.registerDevice(device, deviceModel)
251
304
  })
252
305
 
253
- this.yotoAccount.on('deviceRemoved', ({ deviceId }) => {
306
+ listeners.on('deviceRemoved', ({ deviceId }) => {
254
307
  const label = this.formatDeviceLabel(deviceId)
255
308
  this.log.debug(`Device removed: ${label}`)
256
309
  this.removeStaleAccessories()
257
310
  })
258
311
 
259
- this.yotoAccount.on('online', ({ deviceId, metadata }) => {
312
+ listeners.on('online', ({ deviceId, metadata }) => {
260
313
  const label = this.formatDeviceLabel(deviceId)
261
314
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
262
315
  this.log.info(`Device online: ${label}${reason}`)
263
316
  })
264
317
 
265
- this.yotoAccount.on('offline', ({ deviceId, metadata }) => {
318
+ listeners.on('offline', ({ deviceId, metadata }) => {
266
319
  const label = this.formatDeviceLabel(deviceId)
267
320
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
268
321
  this.log.info(`Device offline: ${label}${reason}`)
@@ -282,74 +335,74 @@ export class YotoPlatform {
282
335
  return ` fields: ${preview}${suffix}`
283
336
  }
284
337
 
285
- this.yotoAccount.on('statusUpdate', ({ deviceId, source, changedFields }) => {
338
+ listeners.on('statusUpdate', ({ deviceId, source, changedFields }) => {
286
339
  const label = this.formatDeviceLabel(deviceId)
287
340
  const fields = Array.from(changedFields).join(', ')
288
341
  this.log.debug(`Status update [${label} ${source}]: ${fields}`)
289
342
  })
290
343
 
291
- this.yotoAccount.on('configUpdate', ({ deviceId, changedFields }) => {
344
+ listeners.on('configUpdate', ({ deviceId, changedFields }) => {
292
345
  const label = this.formatDeviceLabel(deviceId)
293
346
  const fields = Array.from(changedFields).join(', ')
294
347
  this.log.debug(`Config update [${label}]: ${fields}`)
295
348
  })
296
349
 
297
- this.yotoAccount.on('playbackUpdate', ({ deviceId, changedFields }) => {
350
+ listeners.on('playbackUpdate', ({ deviceId, changedFields }) => {
298
351
  const label = this.formatDeviceLabel(deviceId)
299
352
  const fields = Array.from(changedFields).join(', ')
300
353
  this.log.debug(`Playback update [${label}]: ${fields}`)
301
354
  })
302
355
 
303
- this.yotoAccount.on('mqttConnect', ({ deviceId }) => {
356
+ listeners.on('mqttConnect', ({ deviceId }) => {
304
357
  const label = this.formatDeviceLabel(deviceId)
305
358
  this.log.debug(`MQTT connected: ${label}`)
306
359
  })
307
360
 
308
- this.yotoAccount.on('mqttDisconnect', ({ deviceId, metadata }) => {
361
+ listeners.on('mqttDisconnect', ({ deviceId, metadata }) => {
309
362
  const label = this.formatDeviceLabel(deviceId)
310
363
  const reasonCode = metadata?.packet?.reasonCode
311
364
  const reason = typeof reasonCode === 'number' ? ` (code ${reasonCode})` : ''
312
365
  this.log.warn(`MQTT disconnected: ${label}${reason}`)
313
366
  })
314
367
 
315
- this.yotoAccount.on('mqttClose', ({ deviceId, metadata }) => {
368
+ listeners.on('mqttClose', ({ deviceId, metadata }) => {
316
369
  const label = this.formatDeviceLabel(deviceId)
317
370
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
318
371
  this.log.debug(`MQTT closed: ${label}${reason}`)
319
372
  })
320
373
 
321
- this.yotoAccount.on('mqttReconnect', ({ deviceId }) => {
374
+ listeners.on('mqttReconnect', ({ deviceId }) => {
322
375
  const label = this.formatDeviceLabel(deviceId)
323
376
  this.log.debug(`MQTT reconnecting: ${label}`)
324
377
  })
325
378
 
326
- this.yotoAccount.on('mqttOffline', ({ deviceId }) => {
379
+ listeners.on('mqttOffline', ({ deviceId }) => {
327
380
  const label = this.formatDeviceLabel(deviceId)
328
381
  this.log.debug(`MQTT offline: ${label}`)
329
382
  })
330
383
 
331
- this.yotoAccount.on('mqttEnd', ({ deviceId }) => {
384
+ listeners.on('mqttEnd', ({ deviceId }) => {
332
385
  const label = this.formatDeviceLabel(deviceId)
333
386
  this.log.debug(`MQTT ended: ${label}`)
334
387
  })
335
388
 
336
- this.yotoAccount.on('mqttStatus', ({ deviceId, topic }) => {
389
+ listeners.on('mqttStatus', ({ deviceId, topic }) => {
337
390
  const label = this.formatDeviceLabel(deviceId)
338
391
  this.log.debug(`MQTT status [${label}]: ${topic}`)
339
392
  })
340
393
 
341
- this.yotoAccount.on('mqttEvents', ({ deviceId, topic }) => {
394
+ listeners.on('mqttEvents', ({ deviceId, topic }) => {
342
395
  const label = this.formatDeviceLabel(deviceId)
343
396
  this.log.debug(`MQTT events [${label}]: ${topic}`)
344
397
  })
345
398
 
346
- this.yotoAccount.on('mqttStatusLegacy', ({ deviceId, topic, message }) => {
399
+ listeners.on('mqttStatusLegacy', ({ deviceId, topic, message }) => {
347
400
  const label = this.formatDeviceLabel(deviceId)
348
401
  const fields = formatLegacyStatusFields(message)
349
402
  this.log.debug(`MQTT legacy status [${label}]: ${topic}${fields}`)
350
403
  })
351
404
 
352
- this.yotoAccount.on('mqttResponse', ({ deviceId, topic, message }) => {
405
+ listeners.on('mqttResponse', ({ deviceId, topic, message }) => {
353
406
  const label = this.formatDeviceLabel(deviceId)
354
407
  let payload = ''
355
408
  try {
@@ -360,32 +413,80 @@ export class YotoPlatform {
360
413
  this.log.debug(`MQTT response [${label}]: ${topic}${payload}`)
361
414
  })
362
415
 
363
- this.yotoAccount.on('mqttUnknown', ({ deviceId, topic }) => {
416
+ listeners.on('mqttUnknown', ({ deviceId, topic }) => {
364
417
  const label = this.formatDeviceLabel(deviceId)
365
418
  this.log.debug(`MQTT unknown [${label}]: ${topic}`)
366
419
  })
367
420
 
368
- // Start the account (discovers devices, creates device models, starts MQTT)
369
- const account = this.yotoAccount
421
+ await this.connectAccount()
422
+ } catch (error) {
423
+ this.log.error('Failed to start account:', errorMessage(error))
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Start the account (discovers devices, creates device models, starts MQTT).
429
+ * If the Yoto API can't be reached, e.g. because the network isn't up yet
430
+ * when Homebridge boots, retry with backoff instead of giving up. Errors that
431
+ * a retry won't fix (an invalid login, other 4xx responses) are logged once.
432
+ * @returns {Promise<void>}
433
+ */
434
+ async connectAccount () {
435
+ const account = this.yotoAccount
436
+ if (!account || this.shuttingDown) return
437
+
438
+ try {
370
439
  await account.start()
440
+ } catch (error) {
441
+ // Homebridge shut down while the account was starting
442
+ if (this.shuttingDown) return
443
+ if (this.authInvalid) {
444
+ this.log.error('Failed to start account:', errorMessage(error))
445
+ return
446
+ }
447
+ const statusCode = getStatusCode(error)
448
+ if (statusCode === 401 || statusCode === 403) {
449
+ this.log.error(
450
+ `Yoto rejected the saved login (HTTP ${statusCode}). Open the plugin settings in the Homebridge UI and sign in again.`,
451
+ formatError(error)
452
+ )
453
+ return
454
+ }
455
+ if (!isRetryableStartError(error)) {
456
+ this.log.error('Failed to start account:', formatError(error))
457
+ return
458
+ }
459
+ const delayMs = Math.min(START_RETRY_MAX_MS, START_RETRY_BASE_MS * 2 ** this.startRetryCount)
460
+ this.startRetryCount++
461
+ this.log.warn(`Could not connect to Yoto (${errorMessage(error)}). Retrying in ${Math.round(delayMs / 1000)} seconds.`)
462
+ this.startRetryTimer = setTimeout(() => {
463
+ this.startRetryTimer = null
464
+ this.connectAccount().catch(retryError => {
465
+ this.log.error('Failed to start account:', formatError(retryError))
466
+ })
467
+ }, delayMs)
468
+ return
469
+ }
371
470
 
372
- // Homebridge shut down while the account was starting; shutdown() cleans up
373
- if (this.yotoAccount !== account) return
471
+ // Homebridge shut down while the account was starting. shutdown()'s stop() ran
472
+ // before start() finished, so stop the device models and MQTT start() just opened.
473
+ if (this.shuttingDown) {
474
+ await account.stop()
475
+ return
476
+ }
374
477
 
375
- this.log.info(`✓ Yoto account started with ${account.devices.size} device(s)`)
376
- this.log.debug(
377
- 'Account devices:',
378
- Array.from(account.devices.keys()).join(', ') || 'none'
379
- )
478
+ this.startRetryCount = 0
479
+ this.log.info(`✓ Yoto account started with ${account.devices.size} device(s)`)
480
+ this.log.debug(
481
+ 'Account devices:',
482
+ Array.from(account.devices.keys()).join(', ') || 'none'
483
+ )
380
484
 
381
- // Remove stale accessories after all devices are registered
382
- this.removeStaleAccessories()
485
+ // Remove stale accessories after all devices are registered
486
+ this.removeStaleAccessories()
383
487
 
384
- this.log.debug('Registering card control accessories (playOnAll).')
385
- await this.registerCardControlAccessories()
386
- } catch (error) {
387
- this.log.error('Failed to start account:', error instanceof Error ? error.message : String(error))
388
- }
488
+ this.log.debug('Registering card control accessories (playOnAll).')
489
+ await this.registerCardControlAccessories()
389
490
  }
390
491
 
391
492
  /**
@@ -928,6 +1029,11 @@ export class YotoPlatform {
928
1029
  */
929
1030
  async shutdown () {
930
1031
  this.log.debug('Shutting down Yoto platform...')
1032
+ this.shuttingDown = true
1033
+ if (this.startRetryTimer) {
1034
+ clearTimeout(this.startRetryTimer)
1035
+ this.startRetryTimer = null
1036
+ }
931
1037
  this.log.debug(
932
1038
  'Handlers to stop:',
933
1039
  `devices=${this.accessoryHandlers.size}`,
@@ -1004,7 +1110,13 @@ export class YotoPlatform {
1004
1110
  // Keep the original file mode, since config.json holds tokens.
1005
1111
  const { mode } = await stat(configPath)
1006
1112
  const tmpPath = `${configPath}.${process.pid}.tmp`
1007
- await writeFile(tmpPath, updatedContents, { encoding: 'utf8', mode: mode & 0o777 })
1113
+ try {
1114
+ await writeFile(tmpPath, updatedContents, { encoding: 'utf8', mode: mode & 0o777 })
1115
+ } catch (writeError) {
1116
+ // Don't leave a partial copy of the tokens next to config.json
1117
+ await unlink(tmpPath).catch(() => {})
1118
+ throw writeError
1119
+ }
1008
1120
  try {
1009
1121
  await rename(tmpPath, configPath)
1010
1122
  } catch (renameError) {
@@ -1015,8 +1127,7 @@ export class YotoPlatform {
1015
1127
  }
1016
1128
  this.log.debug('Updated config.json with new tokens')
1017
1129
  } catch (error) {
1018
- const details = error instanceof Error ? error.message : String(error)
1019
- this.log.error(`Failed to update config.json at ${configPath}. Refreshed tokens may not persist:`, details)
1130
+ this.log.error(`Failed to update config.json at ${configPath}. Refreshed tokens may not persist:`, errorMessage(error))
1020
1131
  }
1021
1132
  }
1022
1133
  }
@@ -17,7 +17,7 @@ import {
17
17
  import { sanitizeName } from './utils/sanitize-name.js'
18
18
  import { syncServiceNames } from './sync-service-names.js'
19
19
  import { formatError } from './utils/error-format.js'
20
- import { ListenerGroup } from './utils/listener-group.js'
20
+ import { ListenerGroup, logListenerError } from './utils/listener-group.js'
21
21
  import { setDeviceVolume } from './utils/set-device-volume.js'
22
22
  import {
23
23
  clampPercent,
@@ -51,7 +51,10 @@ export class YotoSpeakerAccessory {
51
51
  this.#platform = platform
52
52
  this.#accessory = accessory
53
53
  this.#deviceModel = deviceModel
54
- this.#listeners = new ListenerGroup(deviceModel)
54
+ this.#listeners = new ListenerGroup(
55
+ deviceModel,
56
+ logListenerError(platform.log, `${LOG_PREFIX.ACCESSORY} [${accessory.context.device.name}]`)
57
+ )
55
58
  this.#log = platform.log
56
59
 
57
60
  this.#device = accessory.context.device
@@ -26,7 +26,7 @@ import {
26
26
  import { sanitizeName } from './utils/sanitize-name.js'
27
27
  import { syncServiceNames } from './sync-service-names.js'
28
28
  import { formatError } from './utils/error-format.js'
29
- import { ListenerGroup } from './utils/listener-group.js'
29
+ import { ListenerGroup, logListenerError } from './utils/listener-group.js'
30
30
  import { setDeviceVolume } from './utils/set-device-volume.js'
31
31
  import { getTrimmedString } from './utils/get-trimmed-string.js'
32
32
  import { getCardControlConfigs } from './card-controls.js'
@@ -79,7 +79,10 @@ export class YotoTelevisionAccessory {
79
79
  this.#platform = platform
80
80
  this.#accessory = accessory
81
81
  this.#deviceModel = deviceModel
82
- this.#listeners = new ListenerGroup(deviceModel)
82
+ this.#listeners = new ListenerGroup(
83
+ deviceModel,
84
+ logListenerError(platform.log, `${LOG_PREFIX.ACCESSORY} [${accessory.context.device.name}]`)
85
+ )
83
86
  this.#log = platform.log
84
87
 
85
88
  this.#device = accessory.context.device
@@ -1,3 +1,26 @@
1
+ /**
2
+ * A one-line description of an error for user-facing logs. Includes the HTTP
3
+ * status for Yoto API errors, whose message alone is always
4
+ * "Unexpected response status code".
5
+ * @param {unknown} error
6
+ * @returns {string}
7
+ */
8
+ export function errorMessage (error) {
9
+ const message = error instanceof Error ? error.message : String(error)
10
+ const statusCode = getStatusCode(error)
11
+ return statusCode === undefined ? message : `${message} (HTTP ${statusCode})`
12
+ }
13
+
14
+ /**
15
+ * The HTTP status of a Yoto API error, if the error has one.
16
+ * @param {unknown} error
17
+ * @returns {number | undefined}
18
+ */
19
+ export function getStatusCode (error) {
20
+ if (!error || typeof error !== 'object' || !('statusCode' in error)) return undefined
21
+ return typeof error.statusCode === 'number' ? error.statusCode : undefined
22
+ }
23
+
1
24
  /**
2
25
  * @param {unknown} error
3
26
  * @returns {string}
@@ -1,4 +1,19 @@
1
1
  /** @import { EventEmitter } from 'node:events' */
2
+ /** @import { Logger } from 'homebridge' */
3
+
4
+ import { formatError } from './error-format.js'
5
+
6
+ /**
7
+ * A `ListenerGroup` error callback that logs the failure.
8
+ * @param {Logger} log
9
+ * @param {string} label - Names the listener's owner, e.g. `[Accessory] [Kitchen Yoto]`
10
+ * @returns {(event: string, error: unknown) => void}
11
+ */
12
+ export function logListenerError (log, label) {
13
+ return (event, error) => {
14
+ log.error(`${label} Failed to handle ${event} event:`, formatError(error))
15
+ }
16
+ }
2
17
 
3
18
  /**
4
19
  * Tracks listeners registered on a shared emitter so they can be removed
@@ -11,24 +26,44 @@
11
26
  */
12
27
  export class ListenerGroup {
13
28
  /** @type {EventEmitter} */ #emitter
29
+ /** @type {((event: string, error: unknown) => void) | undefined} */ #onListenerError
14
30
  /** @type {Array<[string, Parameters<EventEmitter['on']>[1]]>} */ #entries = []
15
31
 
16
32
  /**
17
33
  * @param {EventEmitter<TEvents>} emitter
34
+ * @param {(event: string, error: unknown) => void} [onListenerError] - Called when a
35
+ * listener throws or its returned promise rejects. The device model emits from inside
36
+ * MQTT and timer callbacks, where an escaping error would be an uncaught exception
37
+ * (or unhandled rejection) that stops Homebridge.
18
38
  */
19
- constructor (emitter) {
39
+ constructor (emitter, onListenerError) {
20
40
  this.#emitter = /** @type {EventEmitter} */ (/** @type {unknown} */ (emitter))
41
+ this.#onListenerError = onListenerError
21
42
  }
22
43
 
23
44
  /**
24
45
  * @template {keyof TEvents & string} K
25
46
  * @param {K} event
26
- * @param {(...args: TEvents[K]) => void} listener
47
+ * @param {(...args: TEvents[K]) => unknown} listener
27
48
  * @returns {this}
28
49
  */
29
50
  on (event, listener) {
30
- this.#emitter.on(event, listener)
31
- this.#entries.push([event, listener])
51
+ const onListenerError = this.#onListenerError
52
+ const registered = onListenerError
53
+ ? (/** @type {TEvents[K]} */ ...args) => {
54
+ try {
55
+ const result = listener(...args)
56
+ if (result instanceof Promise) {
57
+ result.catch(error => { onListenerError(event, error) })
58
+ }
59
+ } catch (error) {
60
+ onListenerError(event, error)
61
+ }
62
+ }
63
+ : listener
64
+ const untyped = /** @type {Parameters<EventEmitter['on']>[1]} */ (registered)
65
+ this.#emitter.on(event, untyped)
66
+ this.#entries.push([event, untyped])
32
67
  return this
33
68
  }
34
69
 
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@mdjhnson/homebridge-yoto",
3
+ "displayName": "Homebridge Yoto",
3
4
  "description": "Control your Yoto players through Apple HomeKit with real-time MQTT updates",
4
- "version": "0.1.3",
5
+ "version": "0.1.4",
5
6
  "author": "mdjhnson (https://github.com/mdjhnson)",
6
7
  "contributors": [
7
8
  "Bret Comnes <bcomnes@gmail.com> (https://bret.io)"
@@ -9,6 +10,10 @@
9
10
  "bugs": {
10
11
  "url": "https://github.com/mdjhnson/homebridge-yoto/issues"
11
12
  },
13
+ "funding": {
14
+ "type": "github",
15
+ "url": "https://github.com/sponsors/mdjhnson"
16
+ },
12
17
  "dependencies": {
13
18
  "@homebridge/plugin-ui-utils": "^2.2.6",
14
19
  "color-convert": "3.1.3",
@@ -27,20 +32,24 @@
27
32
  "typescript": "~5.9.3"
28
33
  },
29
34
  "engines": {
30
- "node": ">=22",
35
+ "node": "^22.12.0 || ^24.0.0 || ^26.0.0",
31
36
  "npm": ">=10",
32
37
  "homebridge": "^1.8.0 || ^2.0.0"
33
38
  },
34
39
  "homepage": "https://github.com/mdjhnson/homebridge-yoto",
35
40
  "keywords": [
36
41
  "homebridge-plugin",
37
- "yoto"
42
+ "supports-hap",
43
+ "homebridge",
44
+ "homekit",
45
+ "yoto",
46
+ "yoto-player",
47
+ "yoto-mini"
38
48
  ],
39
49
  "license": "MIT",
40
50
  "type": "module",
41
51
  "module": "index.js",
42
52
  "main": "index.js",
43
- "types": "index.d.ts",
44
53
  "files": [
45
54
  "index.js",
46
55
  "config.schema.json",