homebridge-yoto 0.0.28 → 0.0.32

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,463 +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
- await this.registerDevice(device)
357
- }
256
+ this.log.info(`✓ Yoto account started with ${this.yotoAccount.devices.size} device(s)`)
358
257
 
359
- // Remove accessories that are no longer present
258
+ // Remove stale accessories after all devices are registered
360
259
  this.removeStaleAccessories()
361
-
362
- this.log.info(LOG_PREFIX.PLATFORM, `✓ Discovered ${devices.length} device(s)`)
363
260
  } catch (error) {
364
- this.log.error(LOG_PREFIX.PLATFORM, 'Failed to discover devices:', error)
365
- throw error
261
+ this.log.error('Failed to start account:', error instanceof Error ? error.message : String(error))
366
262
  }
367
263
  }
368
264
 
369
265
  /**
370
266
  * Register a device as a platform accessory
371
267
  * @param {YotoDevice} device - Device to register
372
- * @returns {Promise<void>}
268
+ * @param {YotoDeviceModel} deviceModel - Device model instance
269
+ * @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
373
270
  */
374
- async registerDevice (device) {
271
+ async registerDevice (device, deviceModel) {
375
272
  // Generate UUID for this device
376
273
  const uuid = this.api.hap.uuid.generate(device.deviceId)
377
- this.discoveredUUIDs.push(uuid)
274
+ const sanitizedDeviceName = sanitizeName(device.name)
275
+ const accessoryCategory = this.api.hap.Categories.SPEAKER
378
276
 
379
277
  // Check if accessory already exists
380
278
  const existingAccessory = this.accessories.get(uuid)
381
279
 
382
280
  if (existingAccessory) {
383
281
  // Accessory exists - update it
384
- // Use device name for display
385
- const displayName = device.name
386
- this.log.debug(LOG_PREFIX.PLATFORM, 'Restoring existing accessory:', displayName)
282
+ this.log.info('Restoring existing accessory from cache:', device.name)
387
283
 
388
- // Update display name and AccessoryInformation if it has changed
389
- if (existingAccessory.displayName !== displayName) {
390
- existingAccessory.displayName = displayName
284
+ // Update display name if it has changed
285
+ if (existingAccessory.displayName !== sanitizedDeviceName) {
286
+ existingAccessory.updateDisplayName(sanitizedDeviceName)
391
287
  const infoService = existingAccessory.getService(this.api.hap.Service.AccessoryInformation)
392
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
393
294
  infoService
394
- .setCharacteristic(this.api.hap.Characteristic.Name, displayName)
395
- .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, displayName)
295
+ .setCharacteristic(this.api.hap.Characteristic.Name, sanitizedDeviceName)
296
+ .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, sanitizedDeviceName)
396
297
  }
397
298
  }
398
299
 
399
300
  // Update context with fresh device data
400
- const typedAccessory = /** @type {PlatformAccessory<YotoAccessoryContext>} */ (existingAccessory)
401
- typedAccessory.context.device = device
402
- 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
+ }
403
310
 
404
311
  // Update accessory information
405
312
  this.api.updatePlatformAccessories([existingAccessory])
406
313
 
407
- // Create handler for this accessory
408
- 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
+ })
409
320
 
410
- // Track handler for status updates
321
+ // Track handler
411
322
  this.accessoryHandlers.set(uuid, handler)
412
323
 
413
- // Initialize accessory (connect MQTT, etc.)
414
- handler.initialize().catch(error => {
415
- this.log.error(LOG_PREFIX.PLATFORM, `Failed to initialize ${device.name}:`, error)
416
- })
324
+ // Initialize accessory (setup services and event listeners)
325
+ await handler.setup()
326
+
327
+ return { success: true }
417
328
  } else {
418
329
  // Create new accessory
419
- // Use device name for display
420
- const displayName = device.name
421
- this.log.debug(LOG_PREFIX.PLATFORM, 'Adding new accessory:', displayName)
330
+ this.log.info('Adding new accessory:', device.name)
422
331
 
423
332
  // Create platform accessory
333
+ /** @type {PlatformAccessory<YotoAccessoryContext>} */
424
334
  // eslint-disable-next-line new-cap
425
- const accessory = new this.api.platformAccessory(displayName, uuid)
335
+ const accessory = new this.api.platformAccessory(sanitizedDeviceName, uuid, accessoryCategory)
426
336
 
427
337
  // Set Name and ConfiguredName on AccessoryInformation service
428
338
  const infoService = accessory.getService(this.api.hap.Service.AccessoryInformation)
429
339
  if (infoService) {
430
340
  infoService
431
- .setCharacteristic(this.api.hap.Characteristic.Name, displayName)
432
- .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, displayName)
341
+ .setCharacteristic(this.api.hap.Characteristic.Name, sanitizedDeviceName)
342
+ .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, sanitizedDeviceName)
433
343
  }
434
344
 
435
- // Create typed accessory with context
436
- const typedAccessory = /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory)
437
-
438
345
  // Set accessory context
439
- typedAccessory.context = {
346
+ accessory.context = {
440
347
  device,
441
- lastStatus: null,
442
- lastEvents: null,
443
- lastUpdate: Date.now()
444
348
  }
445
349
 
446
- // Create handler for this accessory
447
- 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
+ })
448
356
 
449
- // Track handler for status updates
357
+ // Track handler
450
358
  this.accessoryHandlers.set(uuid, handler)
451
359
 
452
- // Initialize accessory (connect MQTT, etc.)
453
- handler.initialize().catch(error => {
454
- this.log.error(LOG_PREFIX.PLATFORM, `Failed to initialize ${device.name}:`, error)
455
- })
360
+ // Initialize accessory (setup services and event listeners)
361
+ await handler.setup()
456
362
 
457
- // Register with Homebridge
363
+ // Register as a platform accessory (bridged).
364
+ this.log.info(`Registering new accessory: ${device.name}`)
458
365
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
459
366
 
460
- // Add to our tracking map
461
- 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 }
462
371
  }
463
372
  }
464
373
 
@@ -466,48 +375,79 @@ export class YotoPlatform {
466
375
  * Remove accessories that are no longer present in the account
467
376
  */
468
377
  removeStaleAccessories () {
469
- const staleAccessories = []
470
-
471
- for (const [uuid, accessory] of this.accessories) {
472
- if (!this.discoveredUUIDs.includes(uuid)) {
473
- this.log.debug(LOG_PREFIX.PLATFORM, 'Removing stale accessory:', accessory.displayName)
474
- staleAccessories.push(accessory)
475
- }
378
+ if (!this.yotoAccount) {
379
+ return
476
380
  }
477
381
 
478
- if (staleAccessories.length > 0) {
479
- 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)
480
389
 
481
- // Remove from tracking map and handlers
482
- for (const accessory of staleAccessories) {
483
- const handler = this.accessoryHandlers.get(accessory.UUID)
390
+ // Stop handler if it exists
391
+ const handler = this.accessoryHandlers.get(uuid)
484
392
  if (handler) {
485
- handler.destroy().catch(error => {
486
- 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)
487
395
  })
488
- this.accessoryHandlers.delete(accessory.UUID)
396
+ this.accessoryHandlers.delete(uuid)
489
397
  }
490
- 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)
491
404
  }
492
405
  }
493
406
  }
494
407
 
495
408
  /**
496
- * Shutdown handler - cleanup connections
409
+ * Shutdown platform - cleanup all handlers and stop account
497
410
  */
498
411
  async shutdown () {
499
- 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
+ }
500
423
 
501
- // Stop status polling
502
- this.stopStatusPolling()
424
+ // Wait for all handlers to cleanup
425
+ await Promise.all(stopPromises)
426
+ this.accessoryHandlers.clear()
503
427
 
504
- // Cleanup all accessory handlers
505
- for (const [, handler] of this.accessoryHandlers) {
506
- try {
507
- await handler.destroy()
508
- } catch (error) {
509
- this.log.error(LOG_PREFIX.PLATFORM, 'Error shutting down accessory:', error)
510
- }
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)
511
451
  }
512
452
  }
513
453
  }