@mdjhnson/homebridge-yoto 0.1.2 → 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,16 +33,16 @@ 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
 
40
40
  1. Open the plugin's **Settings** in the Homebridge UI and click **Sign in with Yoto**. Yoto's sign-in page opens in a new tab.
41
41
  2. Sign in and approve access.
42
- 3. Your browser then shows *"This site can't be reached"* at `127.0.0.1`. That's expected. Copy the full address from the address bar, paste it into the plugin settings, and click **Finish Sign-in**.
42
+ 3. Your browser then shows *"This site can't be reached"* at `127.0.0.1`. That's expected. Copy the full address from the address bar, paste it into the **Address from your browser** box on the sign-in screen, and click **Finish Sign-in**.
43
43
  4. Restart Homebridge.
44
44
 
45
- Using your own Yoto developer app? Make it a **Public Client**, add `http://127.0.0.1:8787/callback` as an allowed callback URL, enable the `family:devices:*`, `family:library:view`, `user:content:view` and `offline_access` scopes, and enter its client ID under **Advanced Settings** before signing in.
45
+ Using your own Yoto developer app? Make it a **Public Client**, add `http://127.0.0.1:8787/callback` as an allowed callback URL, enable the `family:devices:*`, `family:library:view`, `user:content:view` and `offline_access` scopes, and enter its client ID in the **Advanced Settings** panel on the sign-in screen before signing in. (That panel is only shown while you're signed out, and is separate from the **Advanced** section of the plugin settings.)
46
46
 
47
47
  The plugin asks for access to view, configure and control your players, plus read-only access to your card library (used to name shortcut switches). If you signed in with an older version, sign in again so the new permissions apply.
48
48
 
@@ -50,14 +50,13 @@ The plugin refreshes its tokens on its own. If the login ever expires or is revo
50
50
 
51
51
  ## Settings
52
52
 
53
- All options live under **Accessory Services** in the plugin settings.
53
+ Most options live under **Accessory Services** in the plugin settings. The settings form, including **Advanced**, appears once you've signed in.
54
54
 
55
55
  **Playback**
56
- - **Playback Controls**: Adds a play/pause switch and a volume dimmer to each player's bridged accessory.
57
- - **External Smart Speaker**: Publishes a separate Smart Speaker accessory for Home app scenes and automations (pause, resume, set volume). The Home app shows *"Controls not available"* when you open it, because iOS only offers live controls for AirPlay speakers. It does not make the Yoto an AirPlay target.
58
- - **TV Playback Accessory**: Publishes a separate TV-style accessory. Its inputs play your card controls and shortcuts, and you control it with the iOS remote.
56
+ - **Playback Controls**: Adds a play/pause switch and a volume dimmer to each player's bridged accessory. This is the simplest option and needs no extra pairing.
57
+ - **TV Playback Accessory**: Publishes a separate TV-style accessory. You control it with the iOS remote (play/pause, volume buttons), and its inputs play your card controls and shortcuts.
59
58
 
60
- External accessories must be added by hand in the Home app (**Add Accessory → More options**) using the setup code in the Homebridge log. Each one listens on its own port (logged as `... is running on port N`), so open those ports if Homebridge runs behind a firewall, or set a fixed port range under Homebridge **Settings → Network**.
59
+ The TV accessory, and the legacy Smart Speaker below, are external accessories. External accessories must be added by hand in the Home app (**Add Accessory → More options**) using the setup code in the Homebridge log. Each one listens on its own port (logged as `... is running on port N`), so open those ports if Homebridge runs behind a firewall, or set a fixed port range under Homebridge **Settings → Network**.
61
60
 
62
61
  **Card Controls** (`services.cardControls`)
63
62
  - A switch on each player that plays the card ID you configure.
@@ -70,8 +69,9 @@ External accessories must be added by hand in the Home app (**Add Accessory →
70
69
  **Service toggles**
71
70
  - **Battery**, **Temperature Sensor** (v3), **Nightlight** (v3), **Card Slot**, **Day Mode**, **Sleep Timer**, **Bluetooth**, **Volume Limits**.
72
71
 
73
- **Advanced**
72
+ **Advanced** (collapsed section at the bottom of the plugin settings)
74
73
  - **HTTP Poll Interval**: How often to poll the Yoto API as a fallback to MQTT. Defaults to 60 seconds; the minimum is 10 seconds.
74
+ - **External Smart Speaker (Legacy)**: Kept for existing setups. It publishes a separate Smart Speaker accessory that only works in Home app scenes and automations. When you open it, the Home app shows *"Controls not available"* and can't show what's playing, because iOS only offers live controls for AirPlay speakers. It does not make the Yoto an AirPlay target. For more features, use the **TV Playback Accessory** instead. If you switch, remove the old Smart Speaker from the Home app by hand (Homebridge can't unpublish external accessories).
75
75
 
76
76
  ## HomeKit services
77
77
 
@@ -79,7 +79,7 @@ External accessories must be added by hand in the Home app (**Add Accessory →
79
79
  - **Playback**: Switch. On resumes, Off pauses.
80
80
  - **Volume**: Lightbulb. On unmutes, Off mutes, and Brightness maps 0–100% to the player's volume steps.
81
81
 
82
- **Smart Speaker (external)**
82
+ **Smart Speaker (external, legacy)**
83
83
  - Current/Target Media State, Volume, Mute, and online status. Stop pauses, so playback can be resumed.
84
84
 
85
85
  **TV Playback (external)**
@@ -110,6 +110,11 @@ External accessories must be added by hand in the Home app (**Add Accessory →
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
 
@@ -61,13 +61,13 @@
61
61
  "title": "Playback Controls",
62
62
  "type": "boolean",
63
63
  "default": false,
64
- "description": "Expose bridged playback switch and volume dimmer controls."
64
+ "description": "Add a play/pause switch and a volume dimmer to each player. Simplest option, with no extra pairing."
65
65
  },
66
66
  "smartSpeaker": {
67
- "title": "External Smart Speaker",
67
+ "title": "External Smart Speaker (Legacy)",
68
68
  "type": "boolean",
69
69
  "default": false,
70
- "description": "Publish an external Smart Speaker accessory for use in Home app scenes and automations (pause, resume, set volume). The Home app shows \"Controls not available\" for it; for tap-to-control use Playback Controls or the TV Playback Accessory. Requires pairing and its own network port."
70
+ "description": "Legacy option kept for existing setups. Publishes a separate Smart Speaker accessory that only works in Home app scenes and automations. Requires pairing and its own network port."
71
71
  },
72
72
  "television": {
73
73
  "title": "TV Playback Accessory",
@@ -171,7 +171,7 @@
171
171
  "items": [
172
172
  {
173
173
  "type": "help",
174
- "helpvalue": "<p><strong>Authentication Instructions:</strong></p><ol><li>Click the <strong>\"Start Authentication\"</strong> button in the custom UI above</li><li>Follow the on-screen instructions to authorize with your Yoto account</li><li>Tokens will be saved automatically when authorization completes</li><li>Homebridge will restart and connect to your devices</li></ol><p><strong>Advanced:</strong> The default OAuth Client ID works for all users. The token fields below are automatically managed by the plugin and should not be edited manually.</p>"
174
+ "helpvalue": "Sign in and out from the Yoto panel above. The default OAuth Client ID works for all users. The token fields below are managed by the plugin and should not be edited by hand."
175
175
  },
176
176
  "clientId",
177
177
  "accessToken",
@@ -188,10 +188,9 @@
188
188
  },
189
189
  {
190
190
  "type": "help",
191
- "helpvalue": "<p><strong>Playback Controls:</strong> Enable a bridged switch + dimmer for play/pause and volume. <strong>External Smart Speaker:</strong> a separate accessory for scenes and automations only (the Home app shows \"Controls not available\"). <strong>TV Playback Accessory:</strong> a separate accessory controlled with the iPhone remote, with inputs for card controls and shortcuts. External accessories need pairing and each uses its own network port.</p>"
191
+ "helpvalue": "<p>The older <strong>External Smart Speaker</strong> option has moved to the <em>Advanced</em> section below.</p>"
192
192
  },
193
193
  "services.playbackControls",
194
- "services.smartSpeaker",
195
194
  "services.television",
196
195
  "services.volumeLimits",
197
196
  "services.battery",
@@ -225,7 +224,12 @@
225
224
  "expandable": true,
226
225
  "expanded": false,
227
226
  "items": [
228
- "httpPollIntervalMs"
227
+ "httpPollIntervalMs",
228
+ {
229
+ "type": "help",
230
+ "helpvalue": "<p><strong>Legacy: External Smart Speaker.</strong> Older playback option kept for existing setups. It only works in scenes and automations. The Home app shows \"Controls not available\" for it and can't show what's playing, because iOS only offers those for AirPlay speakers. For more features, turn on the <strong>TV Playback Accessory</strong> under <em>Accessory Services</em> instead. It supports the iPhone remote, volume buttons, and inputs for card controls and shortcuts. If you switch, remove the old Smart Speaker from the Home app by hand.</p>"
231
+ },
232
+ "services.smartSpeaker"
229
233
  ]
230
234
  }
231
235
  ]
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
  }
@@ -229,37 +277,45 @@ export class YotoPlatform {
229
277
  `smartSpeaker=${this.playbackAccessoryConfig.smartSpeakerEnabled}`,
230
278
  `tvPlayback=${this.playbackAccessoryConfig.televisionEnabled}`
231
279
  )
280
+ if (this.playbackAccessoryConfig.smartSpeakerEnabled && !this.playbackAccessoryConfig.televisionEnabled) {
281
+ this.log.info('The External Smart Speaker is a legacy option: the Home app shows "Controls not available" for it. For the iPhone remote, volume buttons, and inputs for card controls and shortcuts, turn on the TV Playback Accessory instead.')
282
+ }
232
283
 
233
284
  // Listen for devices being added
234
- this.yotoAccount.on('deviceAdded', async ({ deviceId }) => {
235
- // Homebridge may shut down while the account is still starting
236
- if (!this.yotoAccount) return
237
- const deviceModel = this.yotoAccount.getDevice(deviceId)
238
- if (!deviceModel) {
239
- const label = this.formatDeviceLabel(deviceId)
240
- this.log.warn(`Device added but no model found for ${label}`)
241
- 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))
242
303
  }
243
-
244
- const device = deviceModel.device
245
- this.log.info(`Device discovered: ${device.name} (${deviceId})`)
246
- this.log.debug('Registering device from account discovery:', device.name, deviceId)
247
- await this.registerDevice(device, deviceModel)
248
304
  })
249
305
 
250
- this.yotoAccount.on('deviceRemoved', ({ deviceId }) => {
306
+ listeners.on('deviceRemoved', ({ deviceId }) => {
251
307
  const label = this.formatDeviceLabel(deviceId)
252
308
  this.log.debug(`Device removed: ${label}`)
253
309
  this.removeStaleAccessories()
254
310
  })
255
311
 
256
- this.yotoAccount.on('online', ({ deviceId, metadata }) => {
312
+ listeners.on('online', ({ deviceId, metadata }) => {
257
313
  const label = this.formatDeviceLabel(deviceId)
258
314
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
259
315
  this.log.info(`Device online: ${label}${reason}`)
260
316
  })
261
317
 
262
- this.yotoAccount.on('offline', ({ deviceId, metadata }) => {
318
+ listeners.on('offline', ({ deviceId, metadata }) => {
263
319
  const label = this.formatDeviceLabel(deviceId)
264
320
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
265
321
  this.log.info(`Device offline: ${label}${reason}`)
@@ -279,74 +335,74 @@ export class YotoPlatform {
279
335
  return ` fields: ${preview}${suffix}`
280
336
  }
281
337
 
282
- this.yotoAccount.on('statusUpdate', ({ deviceId, source, changedFields }) => {
338
+ listeners.on('statusUpdate', ({ deviceId, source, changedFields }) => {
283
339
  const label = this.formatDeviceLabel(deviceId)
284
340
  const fields = Array.from(changedFields).join(', ')
285
341
  this.log.debug(`Status update [${label} ${source}]: ${fields}`)
286
342
  })
287
343
 
288
- this.yotoAccount.on('configUpdate', ({ deviceId, changedFields }) => {
344
+ listeners.on('configUpdate', ({ deviceId, changedFields }) => {
289
345
  const label = this.formatDeviceLabel(deviceId)
290
346
  const fields = Array.from(changedFields).join(', ')
291
347
  this.log.debug(`Config update [${label}]: ${fields}`)
292
348
  })
293
349
 
294
- this.yotoAccount.on('playbackUpdate', ({ deviceId, changedFields }) => {
350
+ listeners.on('playbackUpdate', ({ deviceId, changedFields }) => {
295
351
  const label = this.formatDeviceLabel(deviceId)
296
352
  const fields = Array.from(changedFields).join(', ')
297
353
  this.log.debug(`Playback update [${label}]: ${fields}`)
298
354
  })
299
355
 
300
- this.yotoAccount.on('mqttConnect', ({ deviceId }) => {
356
+ listeners.on('mqttConnect', ({ deviceId }) => {
301
357
  const label = this.formatDeviceLabel(deviceId)
302
358
  this.log.debug(`MQTT connected: ${label}`)
303
359
  })
304
360
 
305
- this.yotoAccount.on('mqttDisconnect', ({ deviceId, metadata }) => {
361
+ listeners.on('mqttDisconnect', ({ deviceId, metadata }) => {
306
362
  const label = this.formatDeviceLabel(deviceId)
307
363
  const reasonCode = metadata?.packet?.reasonCode
308
364
  const reason = typeof reasonCode === 'number' ? ` (code ${reasonCode})` : ''
309
365
  this.log.warn(`MQTT disconnected: ${label}${reason}`)
310
366
  })
311
367
 
312
- this.yotoAccount.on('mqttClose', ({ deviceId, metadata }) => {
368
+ listeners.on('mqttClose', ({ deviceId, metadata }) => {
313
369
  const label = this.formatDeviceLabel(deviceId)
314
370
  const reason = metadata?.reason ? ` (${metadata.reason})` : ''
315
371
  this.log.debug(`MQTT closed: ${label}${reason}`)
316
372
  })
317
373
 
318
- this.yotoAccount.on('mqttReconnect', ({ deviceId }) => {
374
+ listeners.on('mqttReconnect', ({ deviceId }) => {
319
375
  const label = this.formatDeviceLabel(deviceId)
320
376
  this.log.debug(`MQTT reconnecting: ${label}`)
321
377
  })
322
378
 
323
- this.yotoAccount.on('mqttOffline', ({ deviceId }) => {
379
+ listeners.on('mqttOffline', ({ deviceId }) => {
324
380
  const label = this.formatDeviceLabel(deviceId)
325
381
  this.log.debug(`MQTT offline: ${label}`)
326
382
  })
327
383
 
328
- this.yotoAccount.on('mqttEnd', ({ deviceId }) => {
384
+ listeners.on('mqttEnd', ({ deviceId }) => {
329
385
  const label = this.formatDeviceLabel(deviceId)
330
386
  this.log.debug(`MQTT ended: ${label}`)
331
387
  })
332
388
 
333
- this.yotoAccount.on('mqttStatus', ({ deviceId, topic }) => {
389
+ listeners.on('mqttStatus', ({ deviceId, topic }) => {
334
390
  const label = this.formatDeviceLabel(deviceId)
335
391
  this.log.debug(`MQTT status [${label}]: ${topic}`)
336
392
  })
337
393
 
338
- this.yotoAccount.on('mqttEvents', ({ deviceId, topic }) => {
394
+ listeners.on('mqttEvents', ({ deviceId, topic }) => {
339
395
  const label = this.formatDeviceLabel(deviceId)
340
396
  this.log.debug(`MQTT events [${label}]: ${topic}`)
341
397
  })
342
398
 
343
- this.yotoAccount.on('mqttStatusLegacy', ({ deviceId, topic, message }) => {
399
+ listeners.on('mqttStatusLegacy', ({ deviceId, topic, message }) => {
344
400
  const label = this.formatDeviceLabel(deviceId)
345
401
  const fields = formatLegacyStatusFields(message)
346
402
  this.log.debug(`MQTT legacy status [${label}]: ${topic}${fields}`)
347
403
  })
348
404
 
349
- this.yotoAccount.on('mqttResponse', ({ deviceId, topic, message }) => {
405
+ listeners.on('mqttResponse', ({ deviceId, topic, message }) => {
350
406
  const label = this.formatDeviceLabel(deviceId)
351
407
  let payload = ''
352
408
  try {
@@ -357,32 +413,80 @@ export class YotoPlatform {
357
413
  this.log.debug(`MQTT response [${label}]: ${topic}${payload}`)
358
414
  })
359
415
 
360
- this.yotoAccount.on('mqttUnknown', ({ deviceId, topic }) => {
416
+ listeners.on('mqttUnknown', ({ deviceId, topic }) => {
361
417
  const label = this.formatDeviceLabel(deviceId)
362
418
  this.log.debug(`MQTT unknown [${label}]: ${topic}`)
363
419
  })
364
420
 
365
- // Start the account (discovers devices, creates device models, starts MQTT)
366
- 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 {
367
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
+ }
368
470
 
369
- // Homebridge shut down while the account was starting; shutdown() cleans up
370
- 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
+ }
371
477
 
372
- this.log.info(`✓ Yoto account started with ${account.devices.size} device(s)`)
373
- this.log.debug(
374
- 'Account devices:',
375
- Array.from(account.devices.keys()).join(', ') || 'none'
376
- )
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
+ )
377
484
 
378
- // Remove stale accessories after all devices are registered
379
- this.removeStaleAccessories()
485
+ // Remove stale accessories after all devices are registered
486
+ this.removeStaleAccessories()
380
487
 
381
- this.log.debug('Registering card control accessories (playOnAll).')
382
- await this.registerCardControlAccessories()
383
- } catch (error) {
384
- this.log.error('Failed to start account:', error instanceof Error ? error.message : String(error))
385
- }
488
+ this.log.debug('Registering card control accessories (playOnAll).')
489
+ await this.registerCardControlAccessories()
386
490
  }
387
491
 
388
492
  /**
@@ -925,6 +1029,11 @@ export class YotoPlatform {
925
1029
  */
926
1030
  async shutdown () {
927
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
+ }
928
1037
  this.log.debug(
929
1038
  'Handlers to stop:',
930
1039
  `devices=${this.accessoryHandlers.size}`,
@@ -1001,7 +1110,13 @@ export class YotoPlatform {
1001
1110
  // Keep the original file mode, since config.json holds tokens.
1002
1111
  const { mode } = await stat(configPath)
1003
1112
  const tmpPath = `${configPath}.${process.pid}.tmp`
1004
- 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
+ }
1005
1120
  try {
1006
1121
  await rename(tmpPath, configPath)
1007
1122
  } catch (renameError) {
@@ -1012,8 +1127,7 @@ export class YotoPlatform {
1012
1127
  }
1013
1128
  this.log.debug('Updated config.json with new tokens')
1014
1129
  } catch (error) {
1015
- const details = error instanceof Error ? error.message : String(error)
1016
- 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))
1017
1131
  }
1018
1132
  }
1019
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
@@ -148,7 +151,9 @@ export class YotoSpeakerAccessory {
148
151
  .onGet(this.getTargetMediaState.bind(this))
149
152
  .onSet(this.setTargetMediaState.bind(this))
150
153
 
151
- // Advertise AirPlay support so Home keeps transport controls enabled.
154
+ // Report AirPlay as enabled. Kept from earlier versions so already-paired speakers keep
155
+ // the same characteristics; removing it hasn't been tested on real players. It does not
156
+ // make the Yoto an AirPlay target, and the Home app still shows "Controls not available".
152
157
  // AirPlayEnable is a writable uint8 (0/1); ignore writes and always report enabled.
153
158
  service
154
159
  .getCharacteristic(Characteristic.AirPlayEnable)
@@ -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.2",
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",