homebridge-yoto 0.0.27 → 0.0.31

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/lib/platform.js CHANGED
@@ -2,464 +2,372 @@
2
2
  * @fileoverview Main platform implementation for Yoto Homebridge plugin
3
3
  */
4
4
 
5
- /** @import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig } from 'homebridge' */
6
- /** @import { YotoDevice, YotoPlatformConfig, YotoAccessoryContext } from './types.js' */
7
-
8
- import { readFile, writeFile, mkdir } from 'fs/promises'
9
- import { join } from 'path'
10
- import { YotoAuth } from './auth.js'
11
- import { YotoApi } from './yotoApi.js'
12
- import { YotoPlayerAccessory } from './playerAccessory.js'
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
+
9
+ /**
10
+ * Context stored in PlatformAccessory for Yoto devices
11
+ * @typedef {Object} YotoAccessoryContext
12
+ * @property {YotoDevice} device - Device metadata from Yoto API
13
+ */
14
+
15
+ import { readFile, writeFile } from 'node:fs/promises'
16
+ import { YotoAccount } from 'yoto-nodejs-client'
17
+ import { randomUUID } from 'node:crypto'
13
18
  import {
14
19
  PLATFORM_NAME,
15
20
  PLUGIN_NAME,
16
- DEFAULT_CONFIG,
17
- ERROR_MESSAGES,
18
- LOG_PREFIX
19
- } from './constants.js'
21
+ DEFAULT_CLIENT_ID,
22
+ } from './settings.js'
23
+ import { YotoPlayerAccessory } from './accessory.js'
24
+ import { sanitizeName } from './sanitize-name.js'
20
25
 
21
26
  /**
22
27
  * Yoto Platform implementation
28
+ * This class is the main constructor for your plugin, this is where you should
29
+ * parse the user config and discover/register accessories with Homebridge.
23
30
  * @implements {DynamicPlatformPlugin}
24
31
  */
25
32
  export class YotoPlatform {
33
+ /** @type {Logger} */ log
34
+ /** @type {PlatformConfig} */ config
35
+ /** @type {API} */ api
36
+ /** @type {typeof Service} */ Service
37
+ /** @type {typeof Characteristic} */ Characteristic
38
+ /** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */ accessories = new Map()
39
+ /** @type {Map<string, YotoPlayerAccessory>} */ accessoryHandlers = new Map()
40
+ /** @type {YotoAccount | null} */ yotoAccount = null
41
+ /** @type {string} */ sessionId = randomUUID()
42
+
26
43
  /**
27
44
  * @param {Logger} log - Homebridge logger
28
- * @param {PlatformConfig & YotoPlatformConfig} config - Platform configuration
45
+ * @param {PlatformConfig} config - Platform configuration
29
46
  * @param {API} api - Homebridge API
30
47
  */
31
48
  constructor (log, config, api) {
32
49
  this.log = log
33
- this.config = /** @type {YotoPlatformConfig} */ ({ ...DEFAULT_CONFIG, ...config })
50
+ this.config = config
34
51
  this.api = api
35
-
36
- // Homebridge service and characteristic references
37
52
  this.Service = api.hap.Service
38
53
  this.Characteristic = api.hap.Characteristic
39
54
 
40
- // Track registered accessories
41
- /** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */
42
- this.accessories = new Map()
43
-
44
- // Track discovered device UUIDs
45
- /** @type {string[]} */
46
- this.discoveredUUIDs = []
55
+ log.debug('Finished initializing platform:', config.name)
47
56
 
48
- // Persistent storage path for tokens
49
- this.storagePath = api.user.storagePath()
50
- this.tokenFilePath = join(this.storagePath, 'homebridge-yoto-tokens.json')
57
+ // Extract auth tokens once
58
+ const clientId = config['clientId'] || DEFAULT_CLIENT_ID
59
+ const refreshToken = config['refreshToken']
60
+ const accessToken = config['accessToken']
51
61
 
52
- // Track accessory handlers for status updates
53
- /** @type {Map<string, import('./playerAccessory.js').YotoPlayerAccessory>} */
54
- this.accessoryHandlers = new Map()
62
+ // Debug: Log what we found (redacted)
63
+ log.debug('Config check - Has refreshToken:', !!refreshToken)
64
+ log.debug('Config check - Has accessToken:', !!accessToken)
65
+ log.debug('Config check - ClientId:', clientId ? 'present' : 'missing')
55
66
 
56
- // Status polling interval
57
- this.statusPollInterval = null
58
-
59
- // Cache of user's owned MYO card IDs
60
- /** @type {Set<string>} */
61
- this.ownedCardIds = new Set()
67
+ // Check if we have authentication tokens
68
+ if (!refreshToken || !accessToken) {
69
+ log.warn('No authentication tokens found. Please configure the plugin through the Homebridge UI.')
70
+ return
71
+ }
62
72
 
63
- // Initialize API clients
64
- this.auth = new YotoAuth(log, this.config.clientId)
65
- this.yotoApi = new YotoApi(log, this.auth)
73
+ log.info('Authentication tokens found, initializing Yoto account...')
66
74
 
67
- // Set up token refresh callback
68
- this.yotoApi.setTokenRefreshCallback(this.handleTokenRefresh.bind(this))
75
+ const { updateHomebridgeConfig, sessionId } = this
69
76
 
70
- this.log.debug(LOG_PREFIX.PLATFORM, 'Initializing platform:', this.config.name)
77
+ // Initialize YotoAccount with client and device options
78
+ this.yotoAccount = new YotoAccount({
79
+ clientOptions: {
80
+ clientId,
81
+ refreshToken,
82
+ accessToken,
83
+ onTokenRefresh: async ({ updatedAccessToken, updatedRefreshToken, updatedExpiresAt, prevAccessToken, prevRefreshToken }) => {
84
+ log.info('Access token refreshed, expires at:', new Date(updatedExpiresAt * 1000).toISOString())
71
85
 
72
- // Wait for Homebridge to finish launching
73
- this.api.on('didFinishLaunching', () => {
74
- this.log.debug(LOG_PREFIX.PLATFORM, 'Executed didFinishLaunching callback')
75
- this.initialize().catch(error => {
76
- this.log.error(LOG_PREFIX.PLATFORM, 'Failed to initialize:', error)
77
- })
78
- })
79
- }
86
+ // Update config file with new tokens (similar to homebridge-ring pattern)
87
+ await updateHomebridgeConfig((configContents) => {
88
+ let updatedConfig = configContents
80
89
 
81
- /**
82
- * Initialize the platform - authenticate and discover devices
83
- * @returns {Promise<void>}
84
- */
85
- async initialize () {
86
- try {
87
- // Load tokens from persistent storage
88
- await this.loadTokens()
90
+ // Replace old tokens with new tokens
91
+ if (prevAccessToken) {
92
+ updatedConfig = updatedConfig.replace(prevAccessToken, updatedAccessToken)
93
+ }
94
+ if (prevRefreshToken && updatedRefreshToken) {
95
+ updatedConfig = updatedConfig.replace(prevRefreshToken, updatedRefreshToken)
96
+ }
89
97
 
90
- // Check if we have stored credentials
91
- if (!this.config.accessToken || !this.config.refreshToken) {
92
- await this.performDeviceFlow()
98
+ return updatedConfig
99
+ })
100
+ }
101
+ },
102
+ deviceOptions: {
103
+ httpPollIntervalMs: config['httpPollIntervalMs'] || 60000,
104
+ yotoDeviceMqttOptions: {
105
+ sessionId
106
+ }
93
107
  }
108
+ })
94
109
 
95
- // Set tokens in API client
96
- this.yotoApi.setTokens(
97
- this.config.accessToken || '',
98
- this.config.refreshToken || '',
99
- this.config.tokenExpiresAt || 0
100
- )
101
-
102
- // Discover and register devices
103
- try {
104
- await this.discoverDevices()
105
- } catch (error) {
106
- // Check if this is an auth error that requires re-authentication
107
- if (error instanceof Error && error.message.includes('TOKEN_REFRESH_FAILED')) {
108
- this.log.warn(LOG_PREFIX.PLATFORM, 'Token refresh failed, clearing tokens and restarting auth flow...')
109
- await this.clearTokensAndReauth()
110
- return
111
- }
112
- throw error
110
+ // Listen to account-level events
111
+ this.yotoAccount.on('error', ({ error, context }) => {
112
+ if (context.deviceId) {
113
+ log.error(`Device error [${context.deviceId} ${context.operation} ${context.source}]:`, error.message)
114
+ } else {
115
+ log.error('Account error:', error.message)
113
116
  }
117
+ })
114
118
 
115
- // Fetch user's owned cards for lookup optimization
116
- await this.fetchOwnedCards()
119
+ // When this event is fired it means Homebridge has restored all cached accessories from disk.
120
+ // Dynamic Platform plugins should only register new accessories after this event was fired,
121
+ // in order to ensure they weren't added to homebridge already. This event can also be used
122
+ // to start discovery of new accessories.
123
+ api.on('didFinishLaunching', async () => {
124
+ log.debug('Executed didFinishLaunching callback')
125
+ // Start the YotoAccount which will discover and start all devices
126
+ await this.startAccount()
127
+ })
117
128
 
118
- // Start platform-level status polling (every 60 seconds)
119
- this.startStatusPolling()
120
- } catch (error) {
121
- this.log.error(LOG_PREFIX.PLATFORM, 'Initialization failed:', error)
122
- }
129
+ // When homebridge shuts down, cleanup all handlers and MQTT connections
130
+ api.on('shutdown', () => {
131
+ log.debug('Homebridge shutting down, cleaning up accessories...')
132
+ this.shutdown()
133
+ })
123
134
  }
124
135
 
125
136
  /**
126
- * Start periodic status polling for all devices
137
+ * This function is invoked when homebridge restores cached accessories from disk at startup.
138
+ * It should be used to set up event handlers for characteristics and update respective values.
139
+ * In practice, it never is. It simply collects previously created devices into an accessories map
140
+ * that is then used to setup devices after didFinishLaunching fires.
141
+ * @param {PlatformAccessory} accessory - Cached accessory
127
142
  */
128
- startStatusPolling () {
129
- // Poll every 60 seconds
130
- this.statusPollInterval = setInterval(async () => {
131
- try {
132
- await this.checkAllDevicesStatus()
133
- } catch (error) {
134
- this.log.error(LOG_PREFIX.PLATFORM, 'Failed to check device status:', error)
135
- }
136
- }, 60000)
137
- }
143
+ configureAccessory (accessory) {
144
+ const { log, accessories } = this
145
+ log.info('Loading accessory from cache:', accessory.displayName)
138
146
 
139
- /**
140
- * Stop status polling
141
- */
142
- stopStatusPolling () {
143
- if (this.statusPollInterval) {
144
- clearInterval(this.statusPollInterval)
145
- this.statusPollInterval = null
146
- }
147
+ // Add to our tracking map (cast to our typed version)
148
+ accessories.set(accessory.UUID, /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory))
147
149
  }
148
150
 
149
151
  /**
150
- * Check all devices' online status and notify accessories
151
- * @returns {Promise<void>}
152
+ * Start YotoAccount - discovers devices and creates device models
152
153
  */
153
- async checkAllDevicesStatus () {
154
- try {
155
- // Fetch fresh device list from API (single call for all devices)
156
- const devices = await this.yotoApi.getDevices()
157
-
158
- // Update each accessory with fresh device info
159
- for (const device of devices) {
160
- const uuid = this.api.hap.uuid.generate(device.deviceId)
161
- const accessory = this.accessories.get(uuid)
162
-
163
- if (accessory) {
164
- const wasOnline = accessory.context.device.online
165
- const isNowOnline = device.online
166
-
167
- // Update device info in context
168
- accessory.context.device = device
169
-
170
- // Notify accessory handler if status changed
171
- const handler = this.accessoryHandlers.get(uuid)
172
- if (handler && wasOnline !== isNowOnline) {
173
- handler.handleOnlineStatusChange(isNowOnline, wasOnline)
174
- }
175
- }
176
- }
177
- } catch (error) {
178
- this.log.error(LOG_PREFIX.PLATFORM, 'Error checking device status:', error)
154
+ async startAccount () {
155
+ if (!this.yotoAccount) {
156
+ this.log.error('Cannot start account - YotoAccount not initialized')
157
+ return
179
158
  }
180
- }
181
159
 
182
- /**
183
- * Fetch user's owned MYO cards and cache their IDs
184
- * @returns {Promise<void>}
185
- */
186
- async fetchOwnedCards () {
187
160
  try {
188
- this.log.debug(LOG_PREFIX.PLATFORM, 'Fetching user\'s owned cards...')
189
- const myContent = await this.yotoApi.getMyContent()
190
-
191
- // Cache card IDs
192
- if (myContent.cards && Array.isArray(myContent.cards)) {
193
- this.ownedCardIds.clear()
194
- for (const card of myContent.cards) {
195
- if (card.cardId) {
196
- this.ownedCardIds.add(card.cardId)
197
- }
198
- }
199
- this.log.info(LOG_PREFIX.PLATFORM, `✓ Cached ${this.ownedCardIds.size} owned card(s)`)
200
- }
201
- } catch (error) {
202
- this.log.warn(LOG_PREFIX.PLATFORM, 'Failed to fetch owned cards, card details may be limited:', error)
203
- }
204
- }
161
+ this.log.info('Starting Yoto account...')
205
162
 
206
- /**
207
- * Check if a card is owned by the user
208
- * @param {string} cardId - Card ID to check
209
- * @returns {boolean}
210
- */
211
- isCardOwned (cardId) {
212
- return this.ownedCardIds.has(cardId)
213
- }
214
-
215
- /**
216
- * Handle token refresh - update config
217
- * Perform device authorization flow
218
- * @returns {Promise<void>}
219
- */
220
- async performDeviceFlow () {
221
- this.log.warn(LOG_PREFIX.PLATFORM, ERROR_MESSAGES.NO_AUTH)
222
- this.log.debug(LOG_PREFIX.PLATFORM, 'Starting OAuth flow...')
163
+ // Listen for devices being added
164
+ this.yotoAccount.on('deviceAdded', async ({ deviceId }) => {
165
+ const deviceModel = this.yotoAccount?.getDevice(deviceId)
166
+ if (!deviceModel) {
167
+ this.log.warn(`Device added but no model found for ${deviceId}`)
168
+ return
169
+ }
223
170
 
224
- const tokenResponse = await this.auth.authorize()
171
+ const device = deviceModel.device
172
+ this.log.info(`Device discovered: ${device.name} (${deviceId})`)
173
+ await this.registerDevice(device, deviceModel)
174
+ })
225
175
 
226
- // Store tokens in config
227
- this.config.accessToken = tokenResponse.access_token
228
- this.config.refreshToken = tokenResponse.refresh_token || ''
229
- this.config.tokenExpiresAt = this.auth.calculateExpiresAt(tokenResponse.expires_in)
176
+ this.yotoAccount.on('deviceRemoved', ({ deviceId }) => {
177
+ this.log.info(`Device removed: ${deviceId}`)
178
+ this.removeStaleAccessories()
179
+ })
230
180
 
231
- // Save tokens to persistent storage
232
- await this.saveTokens()
181
+ this.yotoAccount.on('online', ({ deviceId, metadata }) => {
182
+ const reason = metadata?.reason ? ` (${metadata.reason})` : ''
183
+ this.log.info(`Device online: ${deviceId}${reason}`)
184
+ })
233
185
 
234
- this.log.info(LOG_PREFIX.PLATFORM, '✓ Authentication successful and saved!')
235
- }
186
+ this.yotoAccount.on('offline', ({ deviceId, metadata }) => {
187
+ const reason = metadata?.reason ? ` (${metadata.reason})` : ''
188
+ this.log.info(`Device offline: ${deviceId}${reason}`)
189
+ })
236
190
 
237
- /**
238
- * Clear invalid tokens and restart authentication
239
- * @returns {Promise<void>}
240
- */
241
- async clearTokensAndReauth () {
242
- // Clear tokens from config
243
- this.config.accessToken = ''
244
- this.config.refreshToken = ''
245
- this.config.tokenExpiresAt = 0
191
+ this.yotoAccount.on('statusUpdate', ({ deviceId, source, changedFields }) => {
192
+ const fields = Array.from(changedFields).join(', ')
193
+ this.log.debug(`Status update [${deviceId} ${source}]: ${fields}`)
194
+ })
246
195
 
247
- // Save cleared tokens
248
- await this.saveTokens()
196
+ this.yotoAccount.on('configUpdate', ({ deviceId, changedFields }) => {
197
+ const fields = Array.from(changedFields).join(', ')
198
+ this.log.debug(`Config update [${deviceId}]: ${fields}`)
199
+ })
249
200
 
250
- // Restart initialization
251
- await this.initialize()
252
- }
201
+ this.yotoAccount.on('playbackUpdate', ({ deviceId, changedFields }) => {
202
+ const fields = Array.from(changedFields).join(', ')
203
+ this.log.debug(`Playback update [${deviceId}]: ${fields}`)
204
+ })
253
205
 
254
- /**
255
- * Handle token refresh - update config
256
- * @param {string} accessToken - New access token
257
- * @param {string} refreshToken - New refresh token
258
- * @param {number} expiresAt - New expiration timestamp
259
- */
260
- handleTokenRefresh (accessToken, refreshToken, expiresAt) {
261
- this.log.debug(LOG_PREFIX.PLATFORM, 'Token refreshed')
262
- this.config.accessToken = accessToken
263
- this.config.refreshToken = refreshToken
264
- this.config.tokenExpiresAt = expiresAt
265
-
266
- // Save updated tokens to persistent storage
267
- this.saveTokens().catch(error => {
268
- this.log.error(LOG_PREFIX.PLATFORM, 'Failed to save refreshed tokens:', error)
269
- })
206
+ this.yotoAccount.on('mqttConnect', ({ deviceId }) => {
207
+ this.log.info(`MQTT connected: ${deviceId}`)
208
+ })
270
209
 
271
- // Note: MQTT reconnection is handled by each accessory's own MQTT client
272
- }
210
+ this.yotoAccount.on('mqttDisconnect', ({ deviceId, metadata }) => {
211
+ const reasonCode = metadata?.packet?.reasonCode
212
+ const reason = typeof reasonCode === 'number' ? ` (code ${reasonCode})` : ''
213
+ this.log.info(`MQTT disconnected: ${deviceId}${reason}`)
214
+ })
273
215
 
274
- /**
275
- * Load tokens from config or persistent storage
276
- * Priority: config.json > persistent storage file
277
- * @returns {Promise<void>}
278
- */
279
- async loadTokens () {
280
- // First check if tokens are in config.json
281
- if (this.config.accessToken && this.config.refreshToken) {
282
- this.log.debug(LOG_PREFIX.PLATFORM, 'Using tokens from config.json')
283
- return
284
- }
216
+ this.yotoAccount.on('mqttClose', ({ deviceId, metadata }) => {
217
+ const reason = metadata?.reason ? ` (${metadata.reason})` : ''
218
+ this.log.debug(`MQTT closed: ${deviceId}${reason}`)
219
+ })
285
220
 
286
- // Fall back to persistent storage file
287
- try {
288
- const data = await readFile(this.tokenFilePath, 'utf-8')
289
- const tokens = JSON.parse(data)
290
-
291
- if (tokens.accessToken && tokens.refreshToken) {
292
- this.config.accessToken = tokens.accessToken
293
- this.config.refreshToken = tokens.refreshToken
294
- this.config.tokenExpiresAt = tokens.tokenExpiresAt
295
- this.log.debug(LOG_PREFIX.PLATFORM, 'Loaded tokens from persistent storage')
296
- }
297
- } catch (error) {
298
- // File doesn't exist or is invalid - not an error on first run
299
- this.log.debug(LOG_PREFIX.PLATFORM, 'No saved tokens found in storage')
300
- }
301
- }
221
+ this.yotoAccount.on('mqttReconnect', ({ deviceId }) => {
222
+ this.log.debug(`MQTT reconnecting: ${deviceId}`)
223
+ })
302
224
 
303
- /**
304
- * Save tokens to persistent storage
305
- * @returns {Promise<void>}
306
- */
307
- async saveTokens () {
308
- try {
309
- // Ensure storage directory exists
310
- await mkdir(this.storagePath, { recursive: true })
225
+ this.yotoAccount.on('mqttOffline', ({ deviceId }) => {
226
+ this.log.debug(`MQTT offline: ${deviceId}`)
227
+ })
311
228
 
312
- const tokens = {
313
- accessToken: this.config.accessToken || '',
314
- refreshToken: this.config.refreshToken || '',
315
- tokenExpiresAt: this.config.tokenExpiresAt || 0
316
- }
229
+ this.yotoAccount.on('mqttEnd', ({ deviceId }) => {
230
+ this.log.debug(`MQTT ended: ${deviceId}`)
231
+ })
317
232
 
318
- await writeFile(this.tokenFilePath, JSON.stringify(tokens, null, 2), 'utf-8')
319
- this.log.debug(LOG_PREFIX.PLATFORM, 'Saved tokens to persistent storage')
320
- } catch (error) {
321
- this.log.error(LOG_PREFIX.PLATFORM, 'Failed to save tokens:', error)
322
- throw error
323
- }
324
- }
233
+ this.yotoAccount.on('mqttStatus', ({ deviceId, topic }) => {
234
+ this.log.debug(`MQTT status [${deviceId}]: ${topic}`)
235
+ })
325
236
 
326
- /**
327
- * Restore cached accessories from disk
328
- * This is called by Homebridge on startup for each cached accessory
329
- * @param {PlatformAccessory<YotoAccessoryContext>} accessory - Cached accessory
330
- */
331
- configureAccessory (accessory) {
332
- this.log.debug(LOG_PREFIX.PLATFORM, 'Loading accessory from cache:', accessory.displayName)
237
+ this.yotoAccount.on('mqttEvents', ({ deviceId, topic }) => {
238
+ this.log.debug(`MQTT events [${deviceId}]: ${topic}`)
239
+ })
333
240
 
334
- // Add to our tracking map
335
- this.accessories.set(accessory.UUID, accessory)
336
- }
241
+ this.yotoAccount.on('mqttStatusLegacy', ({ deviceId, topic }) => {
242
+ this.log.debug(`MQTT legacy status [${deviceId}]: ${topic}`)
243
+ })
337
244
 
338
- /**
339
- * Discover Yoto devices and register as accessories
340
- * @returns {Promise<void>}
341
- */
342
- async discoverDevices () {
343
- try {
344
- this.log.debug(LOG_PREFIX.PLATFORM, 'Discovering Yoto devices...')
245
+ this.yotoAccount.on('mqttResponse', ({ deviceId, topic }) => {
246
+ this.log.debug(`MQTT response [${deviceId}]: ${topic}`)
247
+ })
345
248
 
346
- // Fetch devices from API
347
- const devices = await this.yotoApi.getDevices()
249
+ this.yotoAccount.on('mqttUnknown', ({ deviceId, topic }) => {
250
+ this.log.debug(`MQTT unknown [${deviceId}]: ${topic}`)
251
+ })
348
252
 
349
- if (devices.length === 0) {
350
- this.log.warn(LOG_PREFIX.PLATFORM, 'No Yoto devices found in account')
351
- return
352
- }
253
+ // Start the account (discovers devices, creates device models, starts MQTT)
254
+ await this.yotoAccount.start()
353
255
 
354
- // Process each device
355
- for (const device of devices) {
356
- this.log.info(LOG_PREFIX.PLATFORM, 'Device from API:', JSON.stringify(device, null, 2))
357
- await this.registerDevice(device)
358
- }
256
+ this.log.info(`✓ Yoto account started with ${this.yotoAccount.devices.size} device(s)`)
359
257
 
360
- // Remove accessories that are no longer present
258
+ // Remove stale accessories after all devices are registered
361
259
  this.removeStaleAccessories()
362
-
363
- this.log.info(LOG_PREFIX.PLATFORM, `✓ Discovered ${devices.length} device(s)`)
364
260
  } catch (error) {
365
- this.log.error(LOG_PREFIX.PLATFORM, 'Failed to discover devices:', error)
366
- throw error
261
+ this.log.error('Failed to start account:', error instanceof Error ? error.message : String(error))
367
262
  }
368
263
  }
369
264
 
370
265
  /**
371
266
  * Register a device as a platform accessory
372
267
  * @param {YotoDevice} device - Device to register
373
- * @returns {Promise<void>}
268
+ * @param {YotoDeviceModel} deviceModel - Device model instance
269
+ * @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
374
270
  */
375
- async registerDevice (device) {
271
+ async registerDevice (device, deviceModel) {
376
272
  // Generate UUID for this device
377
273
  const uuid = this.api.hap.uuid.generate(device.deviceId)
378
- this.discoveredUUIDs.push(uuid)
274
+ const sanitizedDeviceName = sanitizeName(device.name)
275
+ const accessoryCategory = this.api.hap.Categories.SPEAKER
379
276
 
380
277
  // Check if accessory already exists
381
278
  const existingAccessory = this.accessories.get(uuid)
382
279
 
383
280
  if (existingAccessory) {
384
281
  // Accessory exists - update it
385
- // Use description (player name) for display
386
- const displayName = device.description || device.name
387
- this.log.debug(LOG_PREFIX.PLATFORM, 'Restoring existing accessory:', displayName)
282
+ this.log.info('Restoring existing accessory from cache:', device.name)
388
283
 
389
- // Update display name and AccessoryInformation if it has changed
390
- if (existingAccessory.displayName !== displayName) {
391
- existingAccessory.displayName = displayName
284
+ // Update display name if it has changed
285
+ if (existingAccessory.displayName !== sanitizedDeviceName) {
286
+ existingAccessory.updateDisplayName(sanitizedDeviceName)
392
287
  const infoService = existingAccessory.getService(this.api.hap.Service.AccessoryInformation)
393
288
  if (infoService) {
289
+ // Only update Name, preserve user's ConfiguredName customization
290
+ // ConfiguredName is intentionally NOT updated here because:
291
+ // - It allows users to rename accessories in the Home app
292
+ // - Their custom names should survive Homebridge restarts and Yoto device name changes
293
+ // - Name stays in sync with Yoto's device name for plugin identification
394
294
  infoService
395
- .setCharacteristic(this.api.hap.Characteristic.Name, displayName)
396
- .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, displayName)
295
+ .setCharacteristic(this.api.hap.Characteristic.Name, sanitizedDeviceName)
296
+ .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, sanitizedDeviceName)
397
297
  }
398
298
  }
399
299
 
400
300
  // Update context with fresh device data
401
- const typedAccessory = /** @type {PlatformAccessory<YotoAccessoryContext>} */ (existingAccessory)
402
- typedAccessory.context.device = device
403
- typedAccessory.context.lastUpdate = Date.now()
301
+ existingAccessory.context = {
302
+ ...existingAccessory.context,
303
+ device,
304
+ }
305
+
306
+ // Ensure category matches our current service model
307
+ if (existingAccessory.category !== accessoryCategory) {
308
+ existingAccessory.category = accessoryCategory
309
+ }
404
310
 
405
311
  // Update accessory information
406
312
  this.api.updatePlatformAccessories([existingAccessory])
407
313
 
408
- // Create handler for this accessory
409
- const handler = new YotoPlayerAccessory(this, typedAccessory)
314
+ // Create handler for this accessory with device model
315
+ const handler = new YotoPlayerAccessory({
316
+ platform: this,
317
+ accessory: existingAccessory,
318
+ deviceModel,
319
+ })
410
320
 
411
- // Track handler for status updates
321
+ // Track handler
412
322
  this.accessoryHandlers.set(uuid, handler)
413
323
 
414
- // Initialize accessory (connect MQTT, etc.)
415
- handler.initialize().catch(error => {
416
- this.log.error(LOG_PREFIX.PLATFORM, `Failed to initialize ${device.name}:`, error)
417
- })
324
+ // Initialize accessory (setup services and event listeners)
325
+ await handler.setup()
326
+
327
+ return { success: true }
418
328
  } else {
419
329
  // Create new accessory
420
- // Use description (player name) for display
421
- const displayName = device.description || device.name
422
- this.log.debug(LOG_PREFIX.PLATFORM, 'Adding new accessory:', displayName)
330
+ this.log.info('Adding new accessory:', device.name)
423
331
 
424
332
  // Create platform accessory
333
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */
425
334
  // eslint-disable-next-line new-cap
426
- const accessory = new this.api.platformAccessory(displayName, uuid)
335
+ const accessory = new this.api.platformAccessory(sanitizedDeviceName, uuid, accessoryCategory)
427
336
 
428
337
  // Set Name and ConfiguredName on AccessoryInformation service
429
338
  const infoService = accessory.getService(this.api.hap.Service.AccessoryInformation)
430
339
  if (infoService) {
431
340
  infoService
432
- .setCharacteristic(this.api.hap.Characteristic.Name, displayName)
433
- .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, displayName)
341
+ .setCharacteristic(this.api.hap.Characteristic.Name, sanitizedDeviceName)
342
+ .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, sanitizedDeviceName)
434
343
  }
435
344
 
436
- // Create typed accessory with context
437
- const typedAccessory = /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory)
438
-
439
345
  // Set accessory context
440
- typedAccessory.context = {
346
+ accessory.context = {
441
347
  device,
442
- lastStatus: null,
443
- lastEvents: null,
444
- lastUpdate: Date.now()
445
348
  }
446
349
 
447
- // Create handler for this accessory
448
- const handler = new YotoPlayerAccessory(this, typedAccessory)
350
+ // Create handler for this accessory with device model
351
+ const handler = new YotoPlayerAccessory({
352
+ platform: this,
353
+ accessory,
354
+ deviceModel,
355
+ })
449
356
 
450
- // Track handler for status updates
357
+ // Track handler
451
358
  this.accessoryHandlers.set(uuid, handler)
452
359
 
453
- // Initialize accessory (connect MQTT, etc.)
454
- handler.initialize().catch(error => {
455
- this.log.error(LOG_PREFIX.PLATFORM, `Failed to initialize ${device.name}:`, error)
456
- })
360
+ // Initialize accessory (setup services and event listeners)
361
+ await handler.setup()
457
362
 
458
- // Register with Homebridge
363
+ // Register as a platform accessory (bridged).
364
+ this.log.info(`Registering new accessory: ${device.name}`)
459
365
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
460
366
 
461
- // Add to our tracking map
462
- this.accessories.set(uuid, typedAccessory)
367
+ // Add to our tracking map (cast to typed version)
368
+ this.accessories.set(uuid, accessory)
369
+
370
+ return { success: true }
463
371
  }
464
372
  }
465
373
 
@@ -467,48 +375,79 @@ export class YotoPlatform {
467
375
  * Remove accessories that are no longer present in the account
468
376
  */
469
377
  removeStaleAccessories () {
470
- const staleAccessories = []
471
-
472
- for (const [uuid, accessory] of this.accessories) {
473
- if (!this.discoveredUUIDs.includes(uuid)) {
474
- this.log.debug(LOG_PREFIX.PLATFORM, 'Removing stale accessory:', accessory.displayName)
475
- staleAccessories.push(accessory)
476
- }
378
+ if (!this.yotoAccount) {
379
+ return
477
380
  }
478
381
 
479
- if (staleAccessories.length > 0) {
480
- this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, staleAccessories)
382
+ // Get current device IDs from account
383
+ const currentDeviceIds = this.yotoAccount.getDeviceIds()
384
+ const currentUUIDs = currentDeviceIds.map(id => this.api.hap.uuid.generate(id))
385
+
386
+ for (const [uuid, accessory] of this.accessories) {
387
+ if (!currentUUIDs.includes(uuid)) {
388
+ this.log.info('Removing existing accessory from cache:', accessory.displayName)
481
389
 
482
- // Remove from tracking map and handlers
483
- for (const accessory of staleAccessories) {
484
- const handler = this.accessoryHandlers.get(accessory.UUID)
390
+ // Stop handler if it exists
391
+ const handler = this.accessoryHandlers.get(uuid)
485
392
  if (handler) {
486
- handler.destroy().catch(error => {
487
- this.log.error(LOG_PREFIX.PLATFORM, 'Error destroying accessory handler:', error)
393
+ handler.stop().catch(error => {
394
+ this.log.error(`Failed to stop handler for ${accessory.displayName}:`, error)
488
395
  })
489
- this.accessoryHandlers.delete(accessory.UUID)
396
+ this.accessoryHandlers.delete(uuid)
490
397
  }
491
- this.accessories.delete(accessory.UUID)
398
+
399
+ // Unregister from Homebridge
400
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
401
+
402
+ // Remove from our tracking map
403
+ this.accessories.delete(uuid)
492
404
  }
493
405
  }
494
406
  }
495
407
 
496
408
  /**
497
- * Shutdown handler - cleanup connections
409
+ * Shutdown platform - cleanup all handlers and stop account
498
410
  */
499
411
  async shutdown () {
500
- this.log.debug(LOG_PREFIX.PLATFORM, 'Shutting down...')
412
+ this.log.info('Shutting down Yoto platform...')
413
+
414
+ // Stop all accessory handlers
415
+ const stopPromises = []
416
+ for (const [uuid, handler] of this.accessoryHandlers) {
417
+ stopPromises.push(
418
+ handler.stop().catch(error => {
419
+ this.log.error(`Failed to stop handler for ${uuid}:`, error)
420
+ })
421
+ )
422
+ }
501
423
 
502
- // Stop status polling
503
- this.stopStatusPolling()
424
+ // Wait for all handlers to cleanup
425
+ await Promise.all(stopPromises)
426
+ this.accessoryHandlers.clear()
504
427
 
505
- // Cleanup all accessory handlers
506
- for (const [, handler] of this.accessoryHandlers) {
507
- try {
508
- await handler.destroy()
509
- } catch (error) {
510
- this.log.error(LOG_PREFIX.PLATFORM, 'Error shutting down accessory:', error)
511
- }
428
+ // Stop the YotoAccount (disconnects all device models and MQTT)
429
+ if (this.yotoAccount) {
430
+ await this.yotoAccount.stop()
431
+ this.yotoAccount = null
432
+ }
433
+
434
+ this.log.info('✓ Yoto platform shutdown complete')
435
+ }
436
+
437
+ /**
438
+ * Update Homebridge config.json file
439
+ * @param {(configContents: string) => string} updateFn - Function to update config contents
440
+ */
441
+ async updateHomebridgeConfig (updateFn) {
442
+ const configPath = this.api.user.configPath()
443
+
444
+ try {
445
+ const configContents = await readFile(configPath, 'utf8')
446
+ const updatedContents = updateFn(configContents)
447
+ await writeFile(configPath, updatedContents, 'utf8')
448
+ this.log.debug('Updated config.json with new tokens')
449
+ } catch (error) {
450
+ this.log.error('Failed to update config.json:', error)
512
451
  }
513
452
  }
514
453
  }