@mdjhnson/homebridge-yoto 0.1.0
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/LICENSE +22 -0
- package/README.md +131 -0
- package/config.schema.cjs +11 -0
- package/config.schema.json +231 -0
- package/homebridge-ui/public/client.js +339 -0
- package/homebridge-ui/public/index.html +125 -0
- package/homebridge-ui/server.js +247 -0
- package/index.js +16 -0
- package/lib/accessory.js +2350 -0
- package/lib/card-control-accessory.js +212 -0
- package/lib/card-controls.js +75 -0
- package/lib/platform.js +1019 -0
- package/lib/service-config.js +47 -0
- package/lib/settings.js +56 -0
- package/lib/shortcuts.js +131 -0
- package/lib/speaker-accessory.js +551 -0
- package/lib/sync-service-names.js +39 -0
- package/lib/television-accessory.js +987 -0
- package/lib/utils/error-format.js +29 -0
- package/lib/utils/get-boolean-setting.js +8 -0
- package/lib/utils/get-trimmed-string.js +7 -0
- package/lib/utils/listener-group.js +45 -0
- package/lib/utils/oauth.js +70 -0
- package/lib/utils/sanitize-name.js +49 -0
- package/lib/utils/set-device-volume.js +22 -0
- package/lib/utils/status-scope-fallback.js +48 -0
- package/lib/utils/token-config.js +59 -0
- package/lib/utils/volume.js +78 -0
- package/package.json +76 -0
package/lib/platform.js
ADDED
|
@@ -0,0 +1,1019 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Main platform implementation for Yoto Homebridge plugin
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge' */
|
|
6
|
+
/** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
|
|
7
|
+
/** @import { YotoDeviceModel } from 'yoto-nodejs-client' */
|
|
8
|
+
/** @import { PlaybackAccessoryConfig } from './service-config.js' */
|
|
9
|
+
/** @import { CardControlConfig } from './card-controls.js' */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Context stored in PlatformAccessory for Yoto devices
|
|
13
|
+
* @typedef {Object} YotoAccessoryContext
|
|
14
|
+
* @property {YotoDevice} device - Device metadata from Yoto API
|
|
15
|
+
* @property {'device'} [type] - Accessory type marker
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Context stored in PlatformAccessory for card control accessories
|
|
20
|
+
* @typedef {Object} YotoCardAccessoryContext
|
|
21
|
+
* @property {CardControlConfig} cardControl - Card control configuration
|
|
22
|
+
* @property {'card-control'} type - Accessory type marker
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
26
|
+
import { YotoAccount } from 'yoto-nodejs-client'
|
|
27
|
+
import { randomUUID } from 'node:crypto'
|
|
28
|
+
import {
|
|
29
|
+
PLATFORM_NAME,
|
|
30
|
+
PLUGIN_NAME,
|
|
31
|
+
DEFAULT_CLIENT_ID,
|
|
32
|
+
} from './settings.js'
|
|
33
|
+
import { YotoPlayerAccessory } from './accessory.js'
|
|
34
|
+
import { YotoSpeakerAccessory } from './speaker-accessory.js'
|
|
35
|
+
import { YotoTelevisionAccessory } from './television-accessory.js'
|
|
36
|
+
import { YotoCardControlAccessory } from './card-control-accessory.js'
|
|
37
|
+
import { sanitizeName } from './utils/sanitize-name.js'
|
|
38
|
+
import { getPlaybackAccessoryConfig } from './service-config.js'
|
|
39
|
+
import { getCardControlConfigs } from './card-controls.js'
|
|
40
|
+
import { formatError } from './utils/error-format.js'
|
|
41
|
+
import { applyTokenUpdate } from './utils/token-config.js'
|
|
42
|
+
import { tolerateMissingStatusScope } from './utils/status-scope-fallback.js'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Yoto Platform implementation
|
|
46
|
+
* This class is the main constructor for your plugin, this is where you should
|
|
47
|
+
* parse the user config and discover/register accessories with Homebridge.
|
|
48
|
+
* @implements {DynamicPlatformPlugin}
|
|
49
|
+
*/
|
|
50
|
+
export class YotoPlatform {
|
|
51
|
+
/** @type {Logger} */ log
|
|
52
|
+
/** @type {PlatformConfig} */ config
|
|
53
|
+
/** @type {API} */ api
|
|
54
|
+
/** @type {typeof Service} */ Service
|
|
55
|
+
/** @type {typeof Characteristic} */ Characteristic
|
|
56
|
+
/** @type {PlaybackAccessoryConfig} */ playbackAccessoryConfig
|
|
57
|
+
/** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */ accessories = new Map()
|
|
58
|
+
/** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */ speakerAccessories = new Map()
|
|
59
|
+
/** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */ televisionAccessories = new Map()
|
|
60
|
+
/** @type {Map<string, PlatformAccessory<YotoCardAccessoryContext>>} */ cardAccessories = new Map()
|
|
61
|
+
/** @type {Map<string, YotoPlayerAccessory>} */ accessoryHandlers = new Map()
|
|
62
|
+
/** @type {Map<string, YotoSpeakerAccessory>} */ speakerAccessoryHandlers = new Map()
|
|
63
|
+
/** @type {Map<string, YotoTelevisionAccessory>} */ televisionAccessoryHandlers = new Map()
|
|
64
|
+
/** @type {Map<string, YotoCardControlAccessory>} */ cardAccessoryHandlers = new Map()
|
|
65
|
+
/** @type {YotoAccount | null} */ yotoAccount = null
|
|
66
|
+
/** @type {string} */ sessionId = randomUUID()
|
|
67
|
+
/** @type {Map<string, Promise<string | null>>} */ cardTitleCache = new Map()
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {Logger} log - Homebridge logger
|
|
71
|
+
* @param {PlatformConfig} config - Platform configuration
|
|
72
|
+
* @param {API} api - Homebridge API
|
|
73
|
+
*/
|
|
74
|
+
constructor (log, config, api) {
|
|
75
|
+
this.log = log
|
|
76
|
+
this.config = config
|
|
77
|
+
this.api = api
|
|
78
|
+
this.Service = api.hap.Service
|
|
79
|
+
this.Characteristic = api.hap.Characteristic
|
|
80
|
+
this.playbackAccessoryConfig = getPlaybackAccessoryConfig(config)
|
|
81
|
+
|
|
82
|
+
log.debug('Finished initializing platform:', config.name)
|
|
83
|
+
|
|
84
|
+
// Extract auth tokens once
|
|
85
|
+
const clientId = config['clientId'] || DEFAULT_CLIENT_ID
|
|
86
|
+
const refreshToken = config['refreshToken']
|
|
87
|
+
const accessToken = config['accessToken']
|
|
88
|
+
|
|
89
|
+
// Debug: Log what we found (redacted)
|
|
90
|
+
log.debug('Config check - Has refreshToken:', !!refreshToken)
|
|
91
|
+
log.debug('Config check - Has accessToken:', !!accessToken)
|
|
92
|
+
log.debug('Config check - ClientId:', clientId ? 'present' : 'missing')
|
|
93
|
+
|
|
94
|
+
// Check if we have authentication tokens
|
|
95
|
+
if (!refreshToken || !accessToken) {
|
|
96
|
+
log.warn('No authentication tokens found. Please configure the plugin through the Homebridge UI.')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
log.debug('Authentication tokens found, initializing Yoto account...')
|
|
101
|
+
|
|
102
|
+
const { sessionId } = this
|
|
103
|
+
const updateHomebridgeConfig = this.updateHomebridgeConfig.bind(this)
|
|
104
|
+
|
|
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
|
+
)
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
deviceOptions: {
|
|
145
|
+
httpPollIntervalMs: Math.max(10000, Number(config['httpPollIntervalMs']) || 60000),
|
|
146
|
+
yotoDeviceMqttOptions: {
|
|
147
|
+
sessionId
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// The status endpoint needs a scope the Yoto dashboard doesn't offer yet
|
|
153
|
+
tolerateMissingStatusScope(this.yotoAccount.client, (message) => log.debug(message))
|
|
154
|
+
|
|
155
|
+
// Listen to account-level events
|
|
156
|
+
this.yotoAccount.on('error', ({ error, context }) => {
|
|
157
|
+
const details = formatError(error)
|
|
158
|
+
if (context.deviceId) {
|
|
159
|
+
const label = this.formatDeviceLabel(context.deviceId)
|
|
160
|
+
log.error(`Device error [${label} ${context.operation} ${context.source}]:`, details)
|
|
161
|
+
log.debug('Device error context:', context)
|
|
162
|
+
} else {
|
|
163
|
+
log.error('Account error:', details)
|
|
164
|
+
log.debug('Account error context:', context)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// When this event is fired it means Homebridge has restored all cached accessories from disk.
|
|
169
|
+
// Dynamic Platform plugins should only register new accessories after this event was fired,
|
|
170
|
+
// in order to ensure they weren't added to homebridge already. This event can also be used
|
|
171
|
+
// to start discovery of new accessories.
|
|
172
|
+
api.on('didFinishLaunching', async () => {
|
|
173
|
+
log.debug('Executed didFinishLaunching callback')
|
|
174
|
+
// Start the YotoAccount which will discover and start all devices
|
|
175
|
+
await this.startAccount()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// When homebridge shuts down, cleanup all handlers and MQTT connections
|
|
179
|
+
api.on('shutdown', () => {
|
|
180
|
+
log.debug('Homebridge shutting down, cleaning up accessories...')
|
|
181
|
+
this.shutdown()
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* This function is invoked when homebridge restores cached accessories from disk at startup.
|
|
187
|
+
* It should be used to set up event handlers for characteristics and update respective values.
|
|
188
|
+
* In practice, it never is. It simply collects previously created devices into an accessories map
|
|
189
|
+
* that is then used to setup devices after didFinishLaunching fires.
|
|
190
|
+
* @param {PlatformAccessory} accessory - Cached accessory
|
|
191
|
+
*/
|
|
192
|
+
configureAccessory (accessory) {
|
|
193
|
+
const { log, accessories, cardAccessories } = this
|
|
194
|
+
log.debug('Loading accessory from cache:', accessory.displayName, accessory.UUID)
|
|
195
|
+
|
|
196
|
+
const context = accessory.context
|
|
197
|
+
const record = context && typeof context === 'object'
|
|
198
|
+
? /** @type {Record<string, unknown>} */ (context)
|
|
199
|
+
: null
|
|
200
|
+
const accessoryType = record && typeof record['type'] === 'string'
|
|
201
|
+
? record['type']
|
|
202
|
+
: undefined
|
|
203
|
+
log.debug('Cached accessory context type:', accessory.displayName, accessoryType ?? 'device')
|
|
204
|
+
|
|
205
|
+
if (accessoryType === 'card-control' || record?.['cardControl']) {
|
|
206
|
+
cardAccessories.set(accessory.UUID, /** @type {PlatformAccessory<YotoCardAccessoryContext>} */ (accessory))
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Add to our tracking map (cast to our typed version)
|
|
211
|
+
accessories.set(accessory.UUID, /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Start YotoAccount - discovers devices and creates device models
|
|
216
|
+
*/
|
|
217
|
+
async startAccount () {
|
|
218
|
+
if (!this.yotoAccount) {
|
|
219
|
+
this.log.error('Cannot start account - YotoAccount not initialized')
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
this.log.debug('Starting Yoto account...')
|
|
225
|
+
this.log.debug(
|
|
226
|
+
'Playback controls config:',
|
|
227
|
+
`playback=${this.playbackAccessoryConfig.playbackEnabled}`,
|
|
228
|
+
`volume=${this.playbackAccessoryConfig.volumeEnabled}`,
|
|
229
|
+
`smartSpeaker=${this.playbackAccessoryConfig.smartSpeakerEnabled}`,
|
|
230
|
+
`tvPlayback=${this.playbackAccessoryConfig.televisionEnabled}`
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
// 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
|
|
242
|
+
}
|
|
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
|
+
})
|
|
249
|
+
|
|
250
|
+
this.yotoAccount.on('deviceRemoved', ({ deviceId }) => {
|
|
251
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
252
|
+
this.log.debug(`Device removed: ${label}`)
|
|
253
|
+
this.removeStaleAccessories()
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
this.yotoAccount.on('online', ({ deviceId, metadata }) => {
|
|
257
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
258
|
+
const reason = metadata?.reason ? ` (${metadata.reason})` : ''
|
|
259
|
+
this.log.info(`Device online: ${label}${reason}`)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
this.yotoAccount.on('offline', ({ deviceId, metadata }) => {
|
|
263
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
264
|
+
const reason = metadata?.reason ? ` (${metadata.reason})` : ''
|
|
265
|
+
this.log.info(`Device offline: ${label}${reason}`)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @param {{ status?: Record<string, unknown> } | null | undefined} message
|
|
270
|
+
* @returns {string}
|
|
271
|
+
*/
|
|
272
|
+
const formatLegacyStatusFields = (message) => {
|
|
273
|
+
const status = message?.status
|
|
274
|
+
if (!status || typeof status !== 'object') return ''
|
|
275
|
+
const fields = Object.keys(status)
|
|
276
|
+
if (!fields.length) return ''
|
|
277
|
+
const preview = fields.slice(0, 8).join(', ')
|
|
278
|
+
const suffix = fields.length > 8 ? `, +${fields.length - 8} more` : ''
|
|
279
|
+
return ` fields: ${preview}${suffix}`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.yotoAccount.on('statusUpdate', ({ deviceId, source, changedFields }) => {
|
|
283
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
284
|
+
const fields = Array.from(changedFields).join(', ')
|
|
285
|
+
this.log.debug(`Status update [${label} ${source}]: ${fields}`)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
this.yotoAccount.on('configUpdate', ({ deviceId, changedFields }) => {
|
|
289
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
290
|
+
const fields = Array.from(changedFields).join(', ')
|
|
291
|
+
this.log.debug(`Config update [${label}]: ${fields}`)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
this.yotoAccount.on('playbackUpdate', ({ deviceId, changedFields }) => {
|
|
295
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
296
|
+
const fields = Array.from(changedFields).join(', ')
|
|
297
|
+
this.log.debug(`Playback update [${label}]: ${fields}`)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
this.yotoAccount.on('mqttConnect', ({ deviceId }) => {
|
|
301
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
302
|
+
this.log.debug(`MQTT connected: ${label}`)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
this.yotoAccount.on('mqttDisconnect', ({ deviceId, metadata }) => {
|
|
306
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
307
|
+
const reasonCode = metadata?.packet?.reasonCode
|
|
308
|
+
const reason = typeof reasonCode === 'number' ? ` (code ${reasonCode})` : ''
|
|
309
|
+
this.log.warn(`MQTT disconnected: ${label}${reason}`)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
this.yotoAccount.on('mqttClose', ({ deviceId, metadata }) => {
|
|
313
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
314
|
+
const reason = metadata?.reason ? ` (${metadata.reason})` : ''
|
|
315
|
+
this.log.debug(`MQTT closed: ${label}${reason}`)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
this.yotoAccount.on('mqttReconnect', ({ deviceId }) => {
|
|
319
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
320
|
+
this.log.debug(`MQTT reconnecting: ${label}`)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
this.yotoAccount.on('mqttOffline', ({ deviceId }) => {
|
|
324
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
325
|
+
this.log.debug(`MQTT offline: ${label}`)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
this.yotoAccount.on('mqttEnd', ({ deviceId }) => {
|
|
329
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
330
|
+
this.log.debug(`MQTT ended: ${label}`)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
this.yotoAccount.on('mqttStatus', ({ deviceId, topic }) => {
|
|
334
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
335
|
+
this.log.debug(`MQTT status [${label}]: ${topic}`)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
this.yotoAccount.on('mqttEvents', ({ deviceId, topic }) => {
|
|
339
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
340
|
+
this.log.debug(`MQTT events [${label}]: ${topic}`)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
this.yotoAccount.on('mqttStatusLegacy', ({ deviceId, topic, message }) => {
|
|
344
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
345
|
+
const fields = formatLegacyStatusFields(message)
|
|
346
|
+
this.log.debug(`MQTT legacy status [${label}]: ${topic}${fields}`)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
this.yotoAccount.on('mqttResponse', ({ deviceId, topic, message }) => {
|
|
350
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
351
|
+
let payload = ''
|
|
352
|
+
try {
|
|
353
|
+
payload = message ? ` ${JSON.stringify(message)}` : ''
|
|
354
|
+
} catch {
|
|
355
|
+
payload = ' [unserializable message]'
|
|
356
|
+
}
|
|
357
|
+
this.log.debug(`MQTT response [${label}]: ${topic}${payload}`)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
this.yotoAccount.on('mqttUnknown', ({ deviceId, topic }) => {
|
|
361
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
362
|
+
this.log.debug(`MQTT unknown [${label}]: ${topic}`)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
// Start the account (discovers devices, creates device models, starts MQTT)
|
|
366
|
+
const account = this.yotoAccount
|
|
367
|
+
await account.start()
|
|
368
|
+
|
|
369
|
+
// Homebridge shut down while the account was starting; shutdown() cleans up
|
|
370
|
+
if (this.yotoAccount !== account) return
|
|
371
|
+
|
|
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
|
+
)
|
|
377
|
+
|
|
378
|
+
// Remove stale accessories after all devices are registered
|
|
379
|
+
this.removeStaleAccessories()
|
|
380
|
+
|
|
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
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* @param {string} deviceId
|
|
390
|
+
* @returns {string}
|
|
391
|
+
*/
|
|
392
|
+
formatDeviceLabel (deviceId) {
|
|
393
|
+
const deviceName = this.yotoAccount?.getDevice(deviceId)?.device?.name
|
|
394
|
+
if (deviceName && deviceName !== deviceId) {
|
|
395
|
+
return `${deviceName} (${deviceId})`
|
|
396
|
+
}
|
|
397
|
+
return deviceId
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Look up a card's title from the Yoto content API (cached per runtime).
|
|
402
|
+
* Resolves to null when the title can't be fetched.
|
|
403
|
+
* @param {string} cardId
|
|
404
|
+
* @returns {Promise<string | null>}
|
|
405
|
+
*/
|
|
406
|
+
getCardTitle (cardId) {
|
|
407
|
+
const cached = this.cardTitleCache.get(cardId)
|
|
408
|
+
if (cached) return cached
|
|
409
|
+
|
|
410
|
+
const client = this.yotoAccount?.client
|
|
411
|
+
if (!client) return Promise.resolve(null)
|
|
412
|
+
|
|
413
|
+
const lookup = client.getContent({ cardId })
|
|
414
|
+
.then(response => response.card?.title?.trim() || null)
|
|
415
|
+
.catch(error => {
|
|
416
|
+
const statusCode = error && typeof error === 'object' && 'statusCode' in error ? error.statusCode : undefined
|
|
417
|
+
this.log.debug(`Could not look up title for card ${cardId}${statusCode ? ` (HTTP ${statusCode})` : ''}`)
|
|
418
|
+
// Forbidden cards (e.g. Yoto system cards) stay unresolved; retry anything else later
|
|
419
|
+
if (statusCode !== 403) this.cardTitleCache.delete(cardId)
|
|
420
|
+
return null
|
|
421
|
+
})
|
|
422
|
+
this.cardTitleCache.set(cardId, lookup)
|
|
423
|
+
return lookup
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* @param {string} deviceId
|
|
428
|
+
* @returns {string}
|
|
429
|
+
*/
|
|
430
|
+
getSpeakerAccessoryUuid (deviceId) {
|
|
431
|
+
return this.api.hap.uuid.generate(`${deviceId}:speaker`)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* @param {YotoDevice} device
|
|
436
|
+
* @returns {string}
|
|
437
|
+
*/
|
|
438
|
+
getSpeakerAccessoryName (device) {
|
|
439
|
+
const rawName = `${device.name} Speaker`
|
|
440
|
+
return sanitizeName(rawName) || `${device.deviceId} Speaker`
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* @param {string} deviceId
|
|
445
|
+
* @returns {string}
|
|
446
|
+
*/
|
|
447
|
+
getTelevisionAccessoryUuid (deviceId) {
|
|
448
|
+
return this.api.hap.uuid.generate(`${deviceId}:playback`)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* @param {YotoDevice} device
|
|
453
|
+
* @returns {string}
|
|
454
|
+
*/
|
|
455
|
+
getTelevisionAccessoryName (device) {
|
|
456
|
+
const rawName = `${device.name} Playback`
|
|
457
|
+
return sanitizeName(rawName) || `${device.deviceId} Playback`
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* @param {CardControlConfig} control
|
|
462
|
+
* @returns {string}
|
|
463
|
+
*/
|
|
464
|
+
getCardControlAccessoryUuid (control) {
|
|
465
|
+
return this.api.hap.uuid.generate(`card-control:${control.id}`)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* @param {CardControlConfig} control
|
|
470
|
+
* @returns {string}
|
|
471
|
+
*/
|
|
472
|
+
getCardControlAccessoryName (control) {
|
|
473
|
+
const rawName = `${control.label} (All Yotos)`
|
|
474
|
+
return sanitizeName(rawName) || `${control.cardId} (All Yotos)`
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Register a device as a platform accessory
|
|
479
|
+
* @param {YotoDevice} device - Device to register
|
|
480
|
+
* @param {YotoDeviceModel} deviceModel - Device model instance
|
|
481
|
+
* @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
|
|
482
|
+
*/
|
|
483
|
+
async registerDevice (device, deviceModel) {
|
|
484
|
+
// Generate UUID for this device
|
|
485
|
+
const uuid = this.api.hap.uuid.generate(device.deviceId)
|
|
486
|
+
const sanitizedDeviceName = sanitizeName(device.name)
|
|
487
|
+
const accessoryCategory = this.api.hap.Categories.SPEAKER
|
|
488
|
+
this.log.debug(
|
|
489
|
+
'Register device:',
|
|
490
|
+
`${device.name} (${device.deviceId})`,
|
|
491
|
+
`uuid=${uuid}`,
|
|
492
|
+
`category=${accessoryCategory}`
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
// Stop any handler left over from a previous registration of this device
|
|
496
|
+
await this.stopHandler(this.accessoryHandlers, uuid, 'device')
|
|
497
|
+
|
|
498
|
+
// Check if accessory already exists
|
|
499
|
+
const existingAccessory = this.accessories.get(uuid)
|
|
500
|
+
|
|
501
|
+
if (existingAccessory) {
|
|
502
|
+
// Accessory exists - update it
|
|
503
|
+
this.log.debug('Restoring existing accessory from cache:', device.name, existingAccessory.UUID)
|
|
504
|
+
|
|
505
|
+
// Update display name if it has changed
|
|
506
|
+
if (existingAccessory.displayName !== sanitizedDeviceName) {
|
|
507
|
+
this.log.debug('Updating accessory display name:', existingAccessory.displayName, '->', sanitizedDeviceName)
|
|
508
|
+
existingAccessory.updateDisplayName(sanitizedDeviceName)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Update context with fresh device data
|
|
512
|
+
existingAccessory.context = {
|
|
513
|
+
...existingAccessory.context,
|
|
514
|
+
type: 'device',
|
|
515
|
+
device,
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Ensure category matches our current service model
|
|
519
|
+
if (existingAccessory.category !== accessoryCategory) {
|
|
520
|
+
existingAccessory.category = accessoryCategory
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Update accessory information
|
|
524
|
+
this.api.updatePlatformAccessories([existingAccessory])
|
|
525
|
+
this.log.debug('Updated accessory cache entry:', existingAccessory.displayName, existingAccessory.UUID)
|
|
526
|
+
|
|
527
|
+
// Create handler for this accessory with device model
|
|
528
|
+
const handler = new YotoPlayerAccessory({
|
|
529
|
+
platform: this,
|
|
530
|
+
accessory: existingAccessory,
|
|
531
|
+
deviceModel,
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
// Track handler
|
|
535
|
+
this.accessoryHandlers.set(uuid, handler)
|
|
536
|
+
this.log.debug('Created accessory handler:', existingAccessory.displayName, uuid)
|
|
537
|
+
|
|
538
|
+
// Initialize accessory (setup services and event listeners)
|
|
539
|
+
await handler.setup()
|
|
540
|
+
this.log.debug('Accessory setup complete:', existingAccessory.displayName)
|
|
541
|
+
|
|
542
|
+
if (this.playbackAccessoryConfig.smartSpeakerEnabled) {
|
|
543
|
+
await this.registerSpeakerAccessory(device, deviceModel)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (this.playbackAccessoryConfig.televisionEnabled) {
|
|
547
|
+
await this.registerTelevisionAccessory(device, deviceModel)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return { success: true }
|
|
551
|
+
} else {
|
|
552
|
+
// Create new accessory
|
|
553
|
+
this.log.debug('Adding new accessory:', device.name, uuid)
|
|
554
|
+
|
|
555
|
+
// Create platform accessory
|
|
556
|
+
/** @type {PlatformAccessory<YotoAccessoryContext>} */
|
|
557
|
+
// eslint-disable-next-line new-cap
|
|
558
|
+
const accessory = new this.api.platformAccessory(sanitizedDeviceName, uuid, accessoryCategory)
|
|
559
|
+
|
|
560
|
+
// Set accessory context
|
|
561
|
+
accessory.context = {
|
|
562
|
+
type: 'device',
|
|
563
|
+
device,
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Create handler for this accessory with device model
|
|
567
|
+
const handler = new YotoPlayerAccessory({
|
|
568
|
+
platform: this,
|
|
569
|
+
accessory,
|
|
570
|
+
deviceModel,
|
|
571
|
+
})
|
|
572
|
+
|
|
573
|
+
// Track handler
|
|
574
|
+
this.accessoryHandlers.set(uuid, handler)
|
|
575
|
+
this.log.debug('Created accessory handler:', device.name, uuid)
|
|
576
|
+
|
|
577
|
+
// Initialize accessory (setup services and event listeners)
|
|
578
|
+
await handler.setup()
|
|
579
|
+
this.log.debug('Accessory setup complete:', device.name)
|
|
580
|
+
|
|
581
|
+
// Register as a platform accessory (bridged).
|
|
582
|
+
this.log.debug(`Registering new accessory: ${device.name}`)
|
|
583
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
|
|
584
|
+
this.log.debug('Registered platform accessory:', device.name, uuid)
|
|
585
|
+
|
|
586
|
+
if (this.playbackAccessoryConfig.smartSpeakerEnabled) {
|
|
587
|
+
await this.registerSpeakerAccessory(device, deviceModel)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (this.playbackAccessoryConfig.televisionEnabled) {
|
|
591
|
+
await this.registerTelevisionAccessory(device, deviceModel)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Add to our tracking map (cast to typed version)
|
|
595
|
+
this.accessories.set(uuid, accessory)
|
|
596
|
+
this.log.debug('Tracked new accessory:', device.name, uuid)
|
|
597
|
+
|
|
598
|
+
return { success: true }
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Register a device as an external SmartSpeaker accessory
|
|
604
|
+
* @param {YotoDevice} device - Device to register
|
|
605
|
+
* @param {YotoDeviceModel} deviceModel - Device model instance
|
|
606
|
+
* @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
|
|
607
|
+
*/
|
|
608
|
+
async registerSpeakerAccessory (device, deviceModel) {
|
|
609
|
+
const uuid = this.getSpeakerAccessoryUuid(device.deviceId)
|
|
610
|
+
const speakerName = this.getSpeakerAccessoryName(device)
|
|
611
|
+
const publishedAccessory = this.speakerAccessories.get(uuid)
|
|
612
|
+
if (publishedAccessory) {
|
|
613
|
+
// External accessories can only be published once per runtime, so
|
|
614
|
+
// re-attach a fresh handler for the (possibly new) device model.
|
|
615
|
+
this.log.debug('SmartSpeaker accessory already published, re-attaching handler:', speakerName, uuid)
|
|
616
|
+
await this.stopHandler(this.speakerAccessoryHandlers, uuid, 'SmartSpeaker')
|
|
617
|
+
publishedAccessory.context = { device }
|
|
618
|
+
const handler = new YotoSpeakerAccessory({
|
|
619
|
+
platform: this,
|
|
620
|
+
accessory: publishedAccessory,
|
|
621
|
+
deviceModel,
|
|
622
|
+
})
|
|
623
|
+
this.speakerAccessoryHandlers.set(uuid, handler)
|
|
624
|
+
await handler.setup()
|
|
625
|
+
return { success: true }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
this.log.info('Adding new SmartSpeaker accessory:', speakerName)
|
|
629
|
+
this.log.debug('Creating SmartSpeaker accessory:', speakerName, uuid)
|
|
630
|
+
|
|
631
|
+
/** @type {PlatformAccessory<YotoAccessoryContext>} */
|
|
632
|
+
// eslint-disable-next-line new-cap
|
|
633
|
+
const accessory = new this.api.platformAccessory(
|
|
634
|
+
speakerName,
|
|
635
|
+
uuid,
|
|
636
|
+
this.api.hap.Categories.SPEAKER
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
accessory.context = {
|
|
640
|
+
device,
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const handler = new YotoSpeakerAccessory({
|
|
644
|
+
platform: this,
|
|
645
|
+
accessory,
|
|
646
|
+
deviceModel,
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
this.speakerAccessoryHandlers.set(uuid, handler)
|
|
650
|
+
this.log.debug('Created SmartSpeaker handler:', speakerName, uuid)
|
|
651
|
+
|
|
652
|
+
await handler.setup()
|
|
653
|
+
this.log.debug('SmartSpeaker setup complete:', speakerName)
|
|
654
|
+
|
|
655
|
+
this.log.info(`Publishing external SmartSpeaker accessory: ${speakerName}`)
|
|
656
|
+
this.api.publishExternalAccessories(PLUGIN_NAME, [accessory])
|
|
657
|
+
this.log.debug('Published external SmartSpeaker accessory:', speakerName, uuid)
|
|
658
|
+
|
|
659
|
+
this.speakerAccessories.set(uuid, accessory)
|
|
660
|
+
|
|
661
|
+
return { success: true }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Register a device as an external Television accessory
|
|
666
|
+
* @param {YotoDevice} device - Device to register
|
|
667
|
+
* @param {YotoDeviceModel} deviceModel - Device model instance
|
|
668
|
+
* @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
|
|
669
|
+
*/
|
|
670
|
+
async registerTelevisionAccessory (device, deviceModel) {
|
|
671
|
+
const uuid = this.getTelevisionAccessoryUuid(device.deviceId)
|
|
672
|
+
const playbackName = this.getTelevisionAccessoryName(device)
|
|
673
|
+
const publishedAccessory = this.televisionAccessories.get(uuid)
|
|
674
|
+
if (publishedAccessory) {
|
|
675
|
+
// External accessories can only be published once per runtime, so
|
|
676
|
+
// re-attach a fresh handler for the (possibly new) device model.
|
|
677
|
+
this.log.debug('Television playback accessory already published, re-attaching handler:', playbackName, uuid)
|
|
678
|
+
await this.stopHandler(this.televisionAccessoryHandlers, uuid, 'Television playback')
|
|
679
|
+
publishedAccessory.context = { device }
|
|
680
|
+
const handler = new YotoTelevisionAccessory({
|
|
681
|
+
platform: this,
|
|
682
|
+
accessory: publishedAccessory,
|
|
683
|
+
deviceModel,
|
|
684
|
+
})
|
|
685
|
+
this.televisionAccessoryHandlers.set(uuid, handler)
|
|
686
|
+
await handler.setup()
|
|
687
|
+
return { success: true }
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
this.log.info('Adding new Television playback accessory:', playbackName)
|
|
691
|
+
this.log.debug('Creating Television playback accessory:', playbackName, uuid)
|
|
692
|
+
|
|
693
|
+
/** @type {PlatformAccessory<YotoAccessoryContext>} */
|
|
694
|
+
// eslint-disable-next-line new-cap
|
|
695
|
+
const accessory = new this.api.platformAccessory(
|
|
696
|
+
playbackName,
|
|
697
|
+
uuid,
|
|
698
|
+
this.api.hap.Categories.TELEVISION
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
accessory.context = {
|
|
702
|
+
device,
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const handler = new YotoTelevisionAccessory({
|
|
706
|
+
platform: this,
|
|
707
|
+
accessory,
|
|
708
|
+
deviceModel,
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
this.televisionAccessoryHandlers.set(uuid, handler)
|
|
712
|
+
this.log.debug('Created Television playback handler:', playbackName, uuid)
|
|
713
|
+
|
|
714
|
+
await handler.setup()
|
|
715
|
+
this.log.debug('Television playback setup complete:', playbackName)
|
|
716
|
+
|
|
717
|
+
this.log.info(`Publishing external Television playback accessory: ${playbackName}`)
|
|
718
|
+
this.api.publishExternalAccessories(PLUGIN_NAME, [accessory])
|
|
719
|
+
this.log.debug('Published external Television playback accessory:', playbackName, uuid)
|
|
720
|
+
|
|
721
|
+
this.televisionAccessories.set(uuid, accessory)
|
|
722
|
+
|
|
723
|
+
return { success: true }
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Register or update card control accessories that target all devices.
|
|
728
|
+
* @returns {Promise<void>}
|
|
729
|
+
*/
|
|
730
|
+
async registerCardControlAccessories () {
|
|
731
|
+
const cardControls = getCardControlConfigs(this.config).filter(control => control.playOnAll)
|
|
732
|
+
const desiredUuids = new Set()
|
|
733
|
+
this.log.debug('Card control configs (playOnAll):', cardControls.length)
|
|
734
|
+
|
|
735
|
+
for (const control of cardControls) {
|
|
736
|
+
const uuid = this.getCardControlAccessoryUuid(control)
|
|
737
|
+
const accessoryName = this.getCardControlAccessoryName(control)
|
|
738
|
+
desiredUuids.add(uuid)
|
|
739
|
+
this.log.debug('Ensuring card control accessory:', accessoryName, uuid)
|
|
740
|
+
|
|
741
|
+
const existingAccessory = this.cardAccessories.get(uuid)
|
|
742
|
+
if (existingAccessory) {
|
|
743
|
+
this.log.debug('Restoring existing card control accessory from cache:', accessoryName, uuid)
|
|
744
|
+
|
|
745
|
+
if (existingAccessory.displayName !== accessoryName) {
|
|
746
|
+
this.log.debug('Updating card control display name:', existingAccessory.displayName, '->', accessoryName)
|
|
747
|
+
existingAccessory.updateDisplayName(accessoryName)
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
existingAccessory.context = {
|
|
751
|
+
type: 'card-control',
|
|
752
|
+
cardControl: control,
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
this.api.updatePlatformAccessories([existingAccessory])
|
|
756
|
+
this.log.debug('Updated card control cache entry:', existingAccessory.displayName, uuid)
|
|
757
|
+
|
|
758
|
+
const existingHandler = this.cardAccessoryHandlers.get(uuid)
|
|
759
|
+
if (existingHandler) {
|
|
760
|
+
await existingHandler.stop().catch(error => {
|
|
761
|
+
this.log.error(`Failed to stop card control handler for ${existingAccessory.displayName}:`, error)
|
|
762
|
+
})
|
|
763
|
+
this.cardAccessoryHandlers.delete(uuid)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const handler = new YotoCardControlAccessory({
|
|
767
|
+
platform: this,
|
|
768
|
+
accessory: existingAccessory,
|
|
769
|
+
cardControl: control,
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
this.cardAccessoryHandlers.set(uuid, handler)
|
|
773
|
+
await handler.setup()
|
|
774
|
+
this.log.debug('Card control setup complete:', accessoryName)
|
|
775
|
+
continue
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
this.log.debug('Adding new card control accessory:', accessoryName, uuid)
|
|
779
|
+
|
|
780
|
+
/** @type {PlatformAccessory<YotoCardAccessoryContext>} */
|
|
781
|
+
// eslint-disable-next-line new-cap
|
|
782
|
+
const accessory = new this.api.platformAccessory(
|
|
783
|
+
accessoryName,
|
|
784
|
+
uuid,
|
|
785
|
+
this.api.hap.Categories.SWITCH
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
accessory.context = {
|
|
789
|
+
type: 'card-control',
|
|
790
|
+
cardControl: control,
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const handler = new YotoCardControlAccessory({
|
|
794
|
+
platform: this,
|
|
795
|
+
accessory,
|
|
796
|
+
cardControl: control,
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
this.cardAccessoryHandlers.set(uuid, handler)
|
|
800
|
+
await handler.setup()
|
|
801
|
+
this.log.debug('Card control setup complete:', accessoryName)
|
|
802
|
+
|
|
803
|
+
this.log.debug(`Registering card control accessory: ${accessoryName}`)
|
|
804
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
|
|
805
|
+
this.log.debug('Registered card control accessory:', accessoryName, uuid)
|
|
806
|
+
|
|
807
|
+
this.cardAccessories.set(uuid, accessory)
|
|
808
|
+
this.log.debug('Tracked card control accessory:', accessoryName, uuid)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
for (const [uuid, accessory] of this.cardAccessories) {
|
|
812
|
+
if (desiredUuids.has(uuid)) {
|
|
813
|
+
continue
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
this.log.debug('Removing card control accessory from cache:', accessory.displayName, uuid)
|
|
817
|
+
|
|
818
|
+
const handler = this.cardAccessoryHandlers.get(uuid)
|
|
819
|
+
if (handler) {
|
|
820
|
+
await handler.stop().catch(error => {
|
|
821
|
+
this.log.error(`Failed to stop card control handler for ${accessory.displayName}:`, error)
|
|
822
|
+
})
|
|
823
|
+
this.cardAccessoryHandlers.delete(uuid)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
|
|
827
|
+
this.cardAccessories.delete(uuid)
|
|
828
|
+
this.log.debug('Removed card control accessory:', accessory.displayName, uuid)
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Remove accessories that are no longer present in the account
|
|
834
|
+
*/
|
|
835
|
+
removeStaleAccessories () {
|
|
836
|
+
if (!this.yotoAccount) {
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Get current device IDs from account
|
|
841
|
+
const currentDeviceIds = this.yotoAccount.getDeviceIds()
|
|
842
|
+
const currentUUIDs = currentDeviceIds.map(id => this.api.hap.uuid.generate(id))
|
|
843
|
+
this.log.debug(
|
|
844
|
+
'Evaluating stale accessories:',
|
|
845
|
+
`accountDevices=${currentDeviceIds.length}`,
|
|
846
|
+
`cachedAccessories=${this.accessories.size}`,
|
|
847
|
+
`externalSpeakers=${this.speakerAccessories.size}`,
|
|
848
|
+
`externalPlayback=${this.televisionAccessories.size}`
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
for (const [uuid, accessory] of this.accessories) {
|
|
852
|
+
if (!currentUUIDs.includes(uuid)) {
|
|
853
|
+
this.log.debug('Removing existing accessory from cache:', accessory.displayName, uuid)
|
|
854
|
+
|
|
855
|
+
// Stop handler if it exists
|
|
856
|
+
const handler = this.accessoryHandlers.get(uuid)
|
|
857
|
+
if (handler) {
|
|
858
|
+
handler.stop().catch(error => {
|
|
859
|
+
this.log.error(`Failed to stop handler for ${accessory.displayName}:`, error)
|
|
860
|
+
})
|
|
861
|
+
this.accessoryHandlers.delete(uuid)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Unregister from Homebridge
|
|
865
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
|
|
866
|
+
this.log.debug('Unregistered accessory from Homebridge:', accessory.displayName, uuid)
|
|
867
|
+
|
|
868
|
+
// Remove from our tracking map
|
|
869
|
+
this.accessories.delete(uuid)
|
|
870
|
+
this.log.debug('Removed accessory from tracking map:', accessory.displayName, uuid)
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
for (const [uuid, accessory] of this.speakerAccessories) {
|
|
875
|
+
const deviceId = accessory.context.device?.deviceId
|
|
876
|
+
if (!deviceId || !currentDeviceIds.includes(deviceId)) {
|
|
877
|
+
const handler = this.speakerAccessoryHandlers.get(uuid)
|
|
878
|
+
if (handler) {
|
|
879
|
+
handler.stop().catch(error => {
|
|
880
|
+
this.log.error(`Failed to stop SmartSpeaker handler for ${accessory.displayName}:`, error)
|
|
881
|
+
})
|
|
882
|
+
this.speakerAccessoryHandlers.delete(uuid)
|
|
883
|
+
// Homebridge has no API to unpublish external accessories. Keep it tracked so
|
|
884
|
+
// it is re-used if the device comes back; it disappears after a restart.
|
|
885
|
+
this.log.warn(`${accessory.displayName} is no longer in your Yoto account. Remove it from the Home app manually.`)
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
for (const [uuid, accessory] of this.televisionAccessories) {
|
|
891
|
+
const deviceId = accessory.context.device?.deviceId
|
|
892
|
+
if (!deviceId || !currentDeviceIds.includes(deviceId)) {
|
|
893
|
+
const handler = this.televisionAccessoryHandlers.get(uuid)
|
|
894
|
+
if (handler) {
|
|
895
|
+
handler.stop().catch(error => {
|
|
896
|
+
this.log.error(`Failed to stop Television playback handler for ${accessory.displayName}:`, error)
|
|
897
|
+
})
|
|
898
|
+
this.televisionAccessoryHandlers.delete(uuid)
|
|
899
|
+
// Homebridge has no API to unpublish external accessories. Keep it tracked so
|
|
900
|
+
// it is re-used if the device comes back; it disappears after a restart.
|
|
901
|
+
this.log.warn(`${accessory.displayName} is no longer in your Yoto account. Remove it from the Home app manually.`)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Stop and forget a tracked handler, if one exists.
|
|
909
|
+
* @param {Map<string, { stop: () => Promise<void> }>} handlers
|
|
910
|
+
* @param {string} uuid
|
|
911
|
+
* @param {string} label
|
|
912
|
+
* @returns {Promise<void>}
|
|
913
|
+
*/
|
|
914
|
+
async stopHandler (handlers, uuid, label) {
|
|
915
|
+
const handler = handlers.get(uuid)
|
|
916
|
+
if (!handler) return
|
|
917
|
+
handlers.delete(uuid)
|
|
918
|
+
await handler.stop().catch(error => {
|
|
919
|
+
this.log.error(`Failed to stop ${label} handler for ${uuid}:`, error)
|
|
920
|
+
})
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Shutdown platform - cleanup all handlers and stop account
|
|
925
|
+
*/
|
|
926
|
+
async shutdown () {
|
|
927
|
+
this.log.debug('Shutting down Yoto platform...')
|
|
928
|
+
this.log.debug(
|
|
929
|
+
'Handlers to stop:',
|
|
930
|
+
`devices=${this.accessoryHandlers.size}`,
|
|
931
|
+
`speakers=${this.speakerAccessoryHandlers.size}`,
|
|
932
|
+
`televisions=${this.televisionAccessoryHandlers.size}`,
|
|
933
|
+
`cardControls=${this.cardAccessoryHandlers.size}`
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
// Stop all accessory handlers
|
|
937
|
+
const stopPromises = []
|
|
938
|
+
for (const [uuid, handler] of this.accessoryHandlers) {
|
|
939
|
+
stopPromises.push(
|
|
940
|
+
handler.stop().catch(error => {
|
|
941
|
+
this.log.error(`Failed to stop handler for ${uuid}:`, error)
|
|
942
|
+
})
|
|
943
|
+
)
|
|
944
|
+
}
|
|
945
|
+
for (const [uuid, handler] of this.speakerAccessoryHandlers) {
|
|
946
|
+
stopPromises.push(
|
|
947
|
+
handler.stop().catch(error => {
|
|
948
|
+
this.log.error(`Failed to stop SmartSpeaker handler for ${uuid}:`, error)
|
|
949
|
+
})
|
|
950
|
+
)
|
|
951
|
+
}
|
|
952
|
+
for (const [uuid, handler] of this.televisionAccessoryHandlers) {
|
|
953
|
+
stopPromises.push(
|
|
954
|
+
handler.stop().catch(error => {
|
|
955
|
+
this.log.error(`Failed to stop Television playback handler for ${uuid}:`, error)
|
|
956
|
+
})
|
|
957
|
+
)
|
|
958
|
+
}
|
|
959
|
+
for (const [uuid, handler] of this.cardAccessoryHandlers) {
|
|
960
|
+
stopPromises.push(
|
|
961
|
+
handler.stop().catch(error => {
|
|
962
|
+
this.log.error(`Failed to stop card control handler for ${uuid}:`, error)
|
|
963
|
+
})
|
|
964
|
+
)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Wait for all handlers to cleanup
|
|
968
|
+
await Promise.all(stopPromises)
|
|
969
|
+
this.accessoryHandlers.clear()
|
|
970
|
+
this.speakerAccessoryHandlers.clear()
|
|
971
|
+
this.televisionAccessoryHandlers.clear()
|
|
972
|
+
this.cardAccessoryHandlers.clear()
|
|
973
|
+
this.speakerAccessories.clear()
|
|
974
|
+
this.televisionAccessories.clear()
|
|
975
|
+
this.cardAccessories.clear()
|
|
976
|
+
|
|
977
|
+
// Stop the YotoAccount (disconnects all device models and MQTT)
|
|
978
|
+
if (this.yotoAccount) {
|
|
979
|
+
await this.yotoAccount.stop()
|
|
980
|
+
this.yotoAccount = null
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
this.log.debug('✓ Yoto platform shutdown complete')
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Update Homebridge config.json file
|
|
988
|
+
* @param {(configContents: string) => string | null} updateFn - Returns updated contents, or null if it could not apply the update
|
|
989
|
+
*/
|
|
990
|
+
async updateHomebridgeConfig (updateFn) {
|
|
991
|
+
const configPath = this.api.user.configPath()
|
|
992
|
+
|
|
993
|
+
try {
|
|
994
|
+
const configContents = await readFile(configPath, 'utf8')
|
|
995
|
+
const updatedContents = updateFn(configContents)
|
|
996
|
+
if (updatedContents === null) {
|
|
997
|
+
this.log.warn(`Did not save refreshed tokens: no ${PLATFORM_NAME} platform block in ${configPath} holds the current tokens. If you signed in again or logged out in the plugin settings, restart Homebridge to use the new settings.`)
|
|
998
|
+
return
|
|
999
|
+
}
|
|
1000
|
+
// Write to a temp file and rename so a crash mid-write cannot corrupt config.json.
|
|
1001
|
+
// Keep the original file mode, since config.json holds tokens.
|
|
1002
|
+
const { mode } = await stat(configPath)
|
|
1003
|
+
const tmpPath = `${configPath}.${process.pid}.tmp`
|
|
1004
|
+
await writeFile(tmpPath, updatedContents, { encoding: 'utf8', mode: mode & 0o777 })
|
|
1005
|
+
try {
|
|
1006
|
+
await rename(tmpPath, configPath)
|
|
1007
|
+
} catch (renameError) {
|
|
1008
|
+
// e.g. config.json is a single-file bind mount (EBUSY); write it in place instead
|
|
1009
|
+
await unlink(tmpPath).catch(() => {})
|
|
1010
|
+
this.log.debug('Could not replace config.json atomically, writing in place:', formatError(renameError))
|
|
1011
|
+
await writeFile(configPath, updatedContents, 'utf8')
|
|
1012
|
+
}
|
|
1013
|
+
this.log.debug('Updated config.json with new tokens')
|
|
1014
|
+
} 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)
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|