@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 +7 -2
- package/lib/accessory.js +5 -2
- package/lib/platform.js +208 -97
- package/lib/speaker-accessory.js +5 -2
- package/lib/television-accessory.js +5 -2
- package/lib/utils/error-format.js +23 -0
- package/lib/utils/listener-group.js +39 -4
- package/package.json +13 -4
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="
|
|
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
|
|
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(
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
'Yoto
|
|
140
|
-
error
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
382
|
-
|
|
485
|
+
// Remove stale accessories after all devices are registered
|
|
486
|
+
this.removeStaleAccessories()
|
|
383
487
|
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/speaker-accessory.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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]) =>
|
|
47
|
+
* @param {(...args: TEvents[K]) => unknown} listener
|
|
27
48
|
* @returns {this}
|
|
28
49
|
*/
|
|
29
50
|
on (event, listener) {
|
|
30
|
-
this.#
|
|
31
|
-
|
|
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.
|
|
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": "
|
|
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
|
-
"
|
|
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",
|