homebridge-yoto 0.0.1 → 0.0.3

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/auth.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @fileoverview OAuth2 Device Authorization Flow implementation for Yoto API
3
+ */
4
+
5
+ /** @import { Logger } from 'homebridge' */
6
+ /** @import { YotoApiTokenResponse, YotoApiDeviceCodeResponse } from './types.js' */
7
+
8
+ import {
9
+ YOTO_OAUTH_DEVICE_CODE_URL,
10
+ YOTO_OAUTH_TOKEN_URL,
11
+ OAUTH_CLIENT_ID,
12
+ OAUTH_AUDIENCE,
13
+ OAUTH_SCOPE,
14
+ OAUTH_POLLING_INTERVAL,
15
+ OAUTH_DEVICE_CODE_TIMEOUT,
16
+ ERROR_MESSAGES,
17
+ LOG_PREFIX
18
+ } from './constants.js'
19
+
20
+ /**
21
+ * OAuth2 authentication handler for Yoto API
22
+ */
23
+ export class YotoAuth {
24
+ /**
25
+ * @param {Logger} log - Homebridge logger
26
+ * @param {string} [clientId] - OAuth client ID (uses default if not provided)
27
+ */
28
+ constructor (log, clientId) {
29
+ this.log = log
30
+ this.clientId = clientId || OAUTH_CLIENT_ID
31
+ }
32
+
33
+ /**
34
+ * Initiate device authorization flow
35
+ * @returns {Promise<YotoApiDeviceCodeResponse>}
36
+ */
37
+ async initiateDeviceFlow () {
38
+ this.log.info(LOG_PREFIX.AUTH, 'Initiating device authorization flow...')
39
+
40
+ try {
41
+ const response = await fetch(YOTO_OAUTH_DEVICE_CODE_URL, {
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json'
45
+ },
46
+ body: JSON.stringify({
47
+ client_id: this.clientId,
48
+ scope: OAUTH_SCOPE,
49
+ audience: OAUTH_AUDIENCE
50
+ })
51
+ })
52
+
53
+ if (!response.ok) {
54
+ const errorText = await response.text()
55
+ throw new Error(`Device code request failed: ${response.status} ${errorText}`)
56
+ }
57
+
58
+ const data = /** @type {YotoApiDeviceCodeResponse} */ (await response.json())
59
+
60
+ this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
61
+ this.log.info(LOG_PREFIX.AUTH, 'YOTO AUTHENTICATION REQUIRED')
62
+ this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
63
+ this.log.info(LOG_PREFIX.AUTH, '')
64
+ this.log.info(LOG_PREFIX.AUTH, `1. Visit: ${data.verification_uri}`)
65
+ this.log.info(LOG_PREFIX.AUTH, `2. Enter code: ${data.user_code}`)
66
+ this.log.info(LOG_PREFIX.AUTH, '')
67
+ this.log.info(LOG_PREFIX.AUTH, `Or visit: ${data.verification_uri_complete}`)
68
+ this.log.info(LOG_PREFIX.AUTH, '')
69
+ this.log.info(LOG_PREFIX.AUTH, `Code expires in ${Math.floor(data.expires_in / 60)} minutes`)
70
+ this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
71
+
72
+ return data
73
+ } catch (error) {
74
+ this.log.error(LOG_PREFIX.AUTH, 'Failed to initiate device flow:', error)
75
+ throw error
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Poll for authorization completion
81
+ * @param {string} deviceCode - Device code from initiation
82
+ * @returns {Promise<YotoApiTokenResponse>}
83
+ */
84
+ async pollForAuthorization (deviceCode) {
85
+ const startTime = Date.now()
86
+ const timeout = OAUTH_DEVICE_CODE_TIMEOUT
87
+
88
+ this.log.info(LOG_PREFIX.AUTH, 'Waiting for user authorization...')
89
+
90
+ while (Date.now() - startTime < timeout) {
91
+ try {
92
+ const response = await fetch(YOTO_OAUTH_TOKEN_URL, {
93
+ method: 'POST',
94
+ headers: {
95
+ 'Content-Type': 'application/json'
96
+ },
97
+ body: JSON.stringify({
98
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
99
+ device_code: deviceCode,
100
+ client_id: this.clientId
101
+ })
102
+ })
103
+
104
+ if (response.ok) {
105
+ const tokenData = /** @type {YotoApiTokenResponse} */ (await response.json())
106
+ this.log.info(LOG_PREFIX.AUTH, '✓ Authorization successful!')
107
+ return tokenData
108
+ }
109
+
110
+ const errorData = /** @type {any} */ (await response.json().catch(() => ({})))
111
+
112
+ // Handle specific OAuth errors
113
+ if (errorData.error === 'authorization_pending') {
114
+ // Still waiting for user to authorize
115
+ await this.sleep(OAUTH_POLLING_INTERVAL)
116
+ continue
117
+ }
118
+
119
+ if (errorData.error === 'slow_down') {
120
+ // Server wants us to slow down polling
121
+ await this.sleep(OAUTH_POLLING_INTERVAL * 2)
122
+ continue
123
+ }
124
+
125
+ if (errorData.error === 'expired_token') {
126
+ throw new Error('Device code expired. Please restart the authorization process.')
127
+ }
128
+
129
+ if (errorData.error === 'access_denied') {
130
+ throw new Error('Authorization was denied by the user.')
131
+ }
132
+
133
+ // Unknown error
134
+ throw new Error(`Authorization failed: ${errorData.error || response.statusText}`)
135
+ } catch (error) {
136
+ if (error instanceof Error && error.message.includes('expired')) {
137
+ throw error
138
+ }
139
+ this.log.debug(LOG_PREFIX.AUTH, 'Polling error:', error)
140
+ await this.sleep(OAUTH_POLLING_INTERVAL)
141
+ }
142
+ }
143
+
144
+ throw new Error('Authorization timed out. Please try again.')
145
+ }
146
+
147
+ /**
148
+ * Refresh access token using refresh token
149
+ * @param {string} refreshToken - Refresh token
150
+ * @returns {Promise<YotoApiTokenResponse>}
151
+ */
152
+ async refreshAccessToken (refreshToken) {
153
+ this.log.debug(LOG_PREFIX.AUTH, 'Refreshing access token...')
154
+
155
+ try {
156
+ const response = await fetch(YOTO_OAUTH_TOKEN_URL, {
157
+ method: 'POST',
158
+ headers: {
159
+ 'Content-Type': 'application/json'
160
+ },
161
+ body: JSON.stringify({
162
+ grant_type: 'refresh_token',
163
+ refresh_token: refreshToken,
164
+ client_id: this.clientId
165
+ })
166
+ })
167
+
168
+ if (!response.ok) {
169
+ const errorText = await response.text()
170
+ throw new Error(`Token refresh failed: ${response.status} ${errorText}`)
171
+ }
172
+
173
+ const tokenData = /** @type {YotoApiTokenResponse} */ (await response.json())
174
+ this.log.info(LOG_PREFIX.AUTH, '✓ Token refreshed successfully')
175
+ return tokenData
176
+ } catch (error) {
177
+ this.log.error(LOG_PREFIX.AUTH, ERROR_MESSAGES.TOKEN_REFRESH_FAILED, error)
178
+ throw error
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Complete device authorization flow
184
+ * @returns {Promise<YotoApiTokenResponse>}
185
+ */
186
+ async authorize () {
187
+ const deviceCodeResponse = await this.initiateDeviceFlow()
188
+ const tokenResponse = await this.pollForAuthorization(deviceCodeResponse.device_code)
189
+ return tokenResponse
190
+ }
191
+
192
+ /**
193
+ * Check if token is expired or expiring soon
194
+ * @param {number} expiresAt - Token expiration timestamp (seconds since epoch)
195
+ * @param {number} bufferSeconds - Seconds before expiry to consider expired
196
+ * @returns {boolean}
197
+ */
198
+ isTokenExpired (expiresAt, bufferSeconds = 300) {
199
+ const now = Math.floor(Date.now() / 1000)
200
+ return expiresAt <= now + bufferSeconds
201
+ }
202
+
203
+ /**
204
+ * Calculate token expiration timestamp
205
+ * @param {number} expiresIn - Seconds until token expires
206
+ * @returns {number} - Unix timestamp when token expires
207
+ */
208
+ calculateExpiresAt (expiresIn) {
209
+ return Math.floor(Date.now() / 1000) + expiresIn
210
+ }
211
+
212
+ /**
213
+ * Sleep for specified milliseconds
214
+ * @param {number} ms - Milliseconds to sleep
215
+ * @returns {Promise<void>}
216
+ */
217
+ sleep (ms) {
218
+ return new Promise(resolve => setTimeout(resolve, ms))
219
+ }
220
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @fileoverview Constants and default values for the Yoto Homebridge plugin
3
+ */
4
+
5
+ /**
6
+ * Plugin identification constants
7
+ */
8
+ export const PLATFORM_NAME = 'Yoto'
9
+ export const PLUGIN_NAME = 'homebridge-yoto'
10
+
11
+ /**
12
+ * Yoto API endpoints
13
+ */
14
+ export const YOTO_API_BASE_URL = 'https://api.yotoplay.com'
15
+ export const YOTO_OAUTH_AUTHORIZE_URL = `${YOTO_API_BASE_URL}/authorize`
16
+ export const YOTO_OAUTH_TOKEN_URL = `${YOTO_API_BASE_URL}/oauth/token`
17
+ export const YOTO_OAUTH_DEVICE_CODE_URL = `${YOTO_API_BASE_URL}/oauth/device/code`
18
+
19
+ /**
20
+ * MQTT configuration
21
+ */
22
+ export const YOTO_MQTT_BROKER_URL = 'mqtt://mqtt.yotoplay.com:1883'
23
+ export const MQTT_RECONNECT_PERIOD = 5000 // milliseconds
24
+ export const MQTT_CONNECT_TIMEOUT = 30000 // milliseconds
25
+
26
+ /**
27
+ * MQTT topic templates
28
+ */
29
+ export const MQTT_TOPIC_DATA_STATUS = '/device/{deviceId}/data/status'
30
+ export const MQTT_TOPIC_DATA_EVENTS = '/device/{deviceId}/data/events'
31
+ export const MQTT_TOPIC_RESPONSE = '/device/{deviceId}/response'
32
+ export const MQTT_TOPIC_COMMAND_STATUS_REQUEST = '/device/{deviceId}/command/status/request'
33
+ export const MQTT_TOPIC_COMMAND_EVENTS_REQUEST = '/device/{deviceId}/command/events/request'
34
+ export const MQTT_TOPIC_COMMAND_VOLUME_SET = '/device/{deviceId}/command/volume/set'
35
+ export const MQTT_TOPIC_COMMAND_CARD_START = '/device/{deviceId}/command/card/start'
36
+ export const MQTT_TOPIC_COMMAND_CARD_STOP = '/device/{deviceId}/command/card/stop'
37
+ export const MQTT_TOPIC_COMMAND_CARD_PAUSE = '/device/{deviceId}/command/card/pause'
38
+ export const MQTT_TOPIC_COMMAND_CARD_RESUME = '/device/{deviceId}/command/card/resume'
39
+ export const MQTT_TOPIC_COMMAND_SLEEP_TIMER = '/device/{deviceId}/command/sleep-timer/set'
40
+ export const MQTT_TOPIC_COMMAND_AMBIENTS_SET = '/device/{deviceId}/command/ambients/set'
41
+ export const MQTT_TOPIC_COMMAND_REBOOT = '/device/{deviceId}/command/reboot'
42
+
43
+ /**
44
+ * OAuth configuration
45
+ */
46
+ export const OAUTH_CLIENT_ID = 'Y4HJ8BFqRQ24GQoLzgOzZ2KSqWmFG8LI'
47
+ export const OAUTH_AUDIENCE = 'https://api.yotoplay.com'
48
+ export const OAUTH_SCOPE = 'profile offline_access openid'
49
+ export const OAUTH_POLLING_INTERVAL = 5000 // milliseconds
50
+ export const OAUTH_DEVICE_CODE_TIMEOUT = 300000 // 5 minutes
51
+
52
+ /**
53
+ * Device polling and timeouts
54
+ */
55
+ export const DEFAULT_STATUS_TIMEOUT_SECONDS = 120 // Mark device offline after no updates
56
+ export const TOKEN_REFRESH_BUFFER_SECONDS = 300 // Refresh token 5 minutes before expiry
57
+ export const INITIAL_STATUS_REQUEST_DELAY = 2000 // milliseconds after MQTT connect
58
+
59
+ /**
60
+ * HomeKit service configuration
61
+ */
62
+ export const DEFAULT_MANUFACTURER = 'Yoto'
63
+ export const DEFAULT_MODEL = 'Yoto Player'
64
+ export const LOW_BATTERY_THRESHOLD = 20 // percentage
65
+
66
+ /**
67
+ * Volume configuration
68
+ */
69
+ export const MIN_VOLUME = 0
70
+ export const MAX_VOLUME = 100
71
+ export const YOTO_MAX_VOLUME_LIMIT = 16 // Yoto's internal max volume scale
72
+
73
+ /**
74
+ * Playback status mapping
75
+ */
76
+ export const PLAYBACK_STATUS = {
77
+ PLAYING: 'playing',
78
+ PAUSED: 'paused',
79
+ STOPPED: 'stopped'
80
+ }
81
+
82
+ /**
83
+ * Card insertion states
84
+ */
85
+ export const CARD_INSERTION_STATE = {
86
+ NONE: 0,
87
+ PHYSICAL: 1,
88
+ REMOTE: 2
89
+ }
90
+
91
+ /**
92
+ * Day mode states
93
+ */
94
+ export const DAY_MODE = {
95
+ UNKNOWN: -1,
96
+ NIGHT: 0,
97
+ DAY: 1
98
+ }
99
+
100
+ /**
101
+ * Power source states
102
+ */
103
+ export const POWER_SOURCE = {
104
+ BATTERY: 0,
105
+ V2_DOCK: 1,
106
+ USB_C: 2,
107
+ QI_DOCK: 3
108
+ }
109
+
110
+ /**
111
+ * Default configuration values
112
+ */
113
+ export const DEFAULT_CONFIG = {
114
+ platform: PLATFORM_NAME,
115
+ name: PLATFORM_NAME,
116
+ clientId: OAUTH_CLIENT_ID,
117
+ mqttBroker: YOTO_MQTT_BROKER_URL,
118
+ statusTimeoutSeconds: DEFAULT_STATUS_TIMEOUT_SECONDS,
119
+ exposeTemperature: true,
120
+ exposeBattery: true,
121
+ exposeAdvancedControls: false,
122
+ exposeConnectionStatus: true,
123
+ exposeCardDetection: false,
124
+ exposeDisplayBrightness: true,
125
+ exposeSleepTimer: false,
126
+ exposeVolumeLimits: false,
127
+ exposeAmbientLight: false,
128
+ exposeActiveContent: true,
129
+ updateAccessoryName: false,
130
+ volumeControlType: 'speaker',
131
+ debug: false
132
+ }
133
+
134
+ /**
135
+ * Error messages
136
+ */
137
+ export const ERROR_MESSAGES = {
138
+ NO_AUTH: 'No authentication credentials found. Please complete OAuth setup.',
139
+ TOKEN_EXPIRED: 'Access token expired. Attempting to refresh...',
140
+ TOKEN_REFRESH_FAILED: 'Failed to refresh access token. Please re-authenticate.',
141
+ MQTT_CONNECTION_FAILED: 'Failed to connect to MQTT broker.',
142
+ MQTT_DISCONNECTED: 'MQTT connection lost. Attempting to reconnect...',
143
+ DEVICE_OFFLINE: 'Device appears to be offline.',
144
+ API_ERROR: 'API request failed',
145
+ INVALID_CONFIG: 'Invalid configuration'
146
+ }
147
+
148
+ /**
149
+ * Log prefixes
150
+ */
151
+ export const LOG_PREFIX = {
152
+ AUTH: '[Auth]',
153
+ API: '[API]',
154
+ MQTT: '[MQTT]',
155
+ PLATFORM: '[Platform]',
156
+ ACCESSORY: '[Accessory]'
157
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * @fileoverview Main platform implementation for Yoto Homebridge plugin
3
+ */
4
+
5
+ /** @import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig } from 'homebridge' */
6
+ /** @import { YotoDevice, YotoPlatformConfig, YotoAccessoryContext } from './types.js' */
7
+
8
+ import { YotoAuth } from './auth.js'
9
+ import { YotoApi } from './yotoApi.js'
10
+ import { YotoMqtt } from './yotoMqtt.js'
11
+ import { YotoPlayerAccessory } from './playerAccessory.js'
12
+ import {
13
+ PLATFORM_NAME,
14
+ PLUGIN_NAME,
15
+ DEFAULT_CONFIG,
16
+ ERROR_MESSAGES,
17
+ LOG_PREFIX
18
+ } from './constants.js'
19
+
20
+ /**
21
+ * Yoto Platform implementation
22
+ * @implements {DynamicPlatformPlugin}
23
+ */
24
+ export class YotoPlatform {
25
+ /**
26
+ * @param {Logger} log - Homebridge logger
27
+ * @param {PlatformConfig & YotoPlatformConfig} config - Platform configuration
28
+ * @param {API} api - Homebridge API
29
+ */
30
+ constructor (log, config, api) {
31
+ this.log = log
32
+ this.config = /** @type {YotoPlatformConfig} */ ({ ...DEFAULT_CONFIG, ...config })
33
+ this.api = api
34
+
35
+ // Homebridge service and characteristic references
36
+ this.Service = api.hap.Service
37
+ this.Characteristic = api.hap.Characteristic
38
+
39
+ // Track registered accessories
40
+ /** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */
41
+ this.accessories = new Map()
42
+
43
+ // Track discovered device UUIDs
44
+ /** @type {string[]} */
45
+ this.discoveredUUIDs = []
46
+
47
+ // Initialize API clients
48
+ this.auth = new YotoAuth(log, this.config.clientId)
49
+ this.yotoApi = new YotoApi(log, this.auth)
50
+ this.yotoMqtt = new YotoMqtt(log, {
51
+ brokerUrl: this.config.mqttBroker
52
+ })
53
+
54
+ // Set up token refresh callback
55
+ this.yotoApi.setTokenRefreshCallback(this.handleTokenRefresh.bind(this))
56
+
57
+ this.log.debug(LOG_PREFIX.PLATFORM, 'Initializing platform:', this.config.name)
58
+
59
+ // Wait for Homebridge to finish launching
60
+ this.api.on('didFinishLaunching', () => {
61
+ this.log.debug(LOG_PREFIX.PLATFORM, 'Executed didFinishLaunching callback')
62
+ this.initialize().catch(error => {
63
+ this.log.error(LOG_PREFIX.PLATFORM, 'Failed to initialize:', error)
64
+ })
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Initialize the platform - authenticate and discover devices
70
+ * @returns {Promise<void>}
71
+ */
72
+ async initialize () {
73
+ try {
74
+ // Check if we have stored credentials
75
+ if (!this.config.accessToken || !this.config.refreshToken) {
76
+ this.log.warn(LOG_PREFIX.PLATFORM, ERROR_MESSAGES.NO_AUTH)
77
+ this.log.info(LOG_PREFIX.PLATFORM, 'Starting OAuth flow...')
78
+
79
+ const tokenResponse = await this.auth.authorize()
80
+
81
+ // Store tokens in config
82
+ this.config.accessToken = tokenResponse.access_token
83
+ this.config.refreshToken = tokenResponse.refresh_token || ''
84
+ this.config.tokenExpiresAt = this.auth.calculateExpiresAt(tokenResponse.expires_in)
85
+
86
+ this.log.info(LOG_PREFIX.PLATFORM, '✓ Authentication successful!')
87
+ this.log.warn(LOG_PREFIX.PLATFORM, 'IMPORTANT: Please update your Homebridge config with the following:')
88
+ this.log.warn(LOG_PREFIX.PLATFORM, JSON.stringify({
89
+ accessToken: this.config.accessToken,
90
+ refreshToken: this.config.refreshToken,
91
+ tokenExpiresAt: this.config.tokenExpiresAt
92
+ }, null, 2))
93
+ }
94
+
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
+ // Connect to MQTT
103
+ await this.yotoMqtt.connect(this.config.accessToken)
104
+
105
+ // Discover and register devices
106
+ await this.discoverDevices()
107
+ } catch (error) {
108
+ this.log.error(LOG_PREFIX.PLATFORM, 'Initialization failed:', error)
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Handle token refresh - update config
114
+ * @param {string} accessToken - New access token
115
+ * @param {string} refreshToken - New refresh token
116
+ * @param {number} expiresAt - New expiration timestamp
117
+ */
118
+ handleTokenRefresh (accessToken, refreshToken, expiresAt) {
119
+ this.log.info(LOG_PREFIX.PLATFORM, 'Token refreshed, please update your config')
120
+ this.config.accessToken = accessToken
121
+ this.config.refreshToken = refreshToken
122
+ this.config.tokenExpiresAt = expiresAt
123
+
124
+ // Update MQTT connection with new token
125
+ this.yotoMqtt.disconnect().then(() => {
126
+ return this.yotoMqtt.connect(accessToken)
127
+ }).catch(error => {
128
+ this.log.error(LOG_PREFIX.PLATFORM, 'Failed to reconnect MQTT after token refresh:', error)
129
+ })
130
+ }
131
+
132
+ /**
133
+ * Restore cached accessories from disk
134
+ * This is called by Homebridge on startup for each cached accessory
135
+ * @param {PlatformAccessory<YotoAccessoryContext>} accessory - Cached accessory
136
+ */
137
+ configureAccessory (accessory) {
138
+ this.log.info(LOG_PREFIX.PLATFORM, 'Loading accessory from cache:', accessory.displayName)
139
+
140
+ // Add to our tracking map
141
+ this.accessories.set(accessory.UUID, accessory)
142
+ }
143
+
144
+ /**
145
+ * Discover Yoto devices and register as accessories
146
+ * @returns {Promise<void>}
147
+ */
148
+ async discoverDevices () {
149
+ try {
150
+ this.log.info(LOG_PREFIX.PLATFORM, 'Discovering Yoto devices...')
151
+
152
+ // Fetch devices from API
153
+ const devices = await this.yotoApi.getDevices()
154
+
155
+ if (devices.length === 0) {
156
+ this.log.warn(LOG_PREFIX.PLATFORM, 'No Yoto devices found in account')
157
+ return
158
+ }
159
+
160
+ // Process each device
161
+ for (const device of devices) {
162
+ await this.registerDevice(device)
163
+ }
164
+
165
+ // Remove accessories that are no longer present
166
+ this.removeStaleAccessories()
167
+
168
+ this.log.info(LOG_PREFIX.PLATFORM, `✓ Discovered ${devices.length} device(s)`)
169
+ } catch (error) {
170
+ this.log.error(LOG_PREFIX.PLATFORM, 'Failed to discover devices:', error)
171
+ throw error
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Register a device as a platform accessory
177
+ * @param {YotoDevice} device - Device to register
178
+ * @returns {Promise<void>}
179
+ */
180
+ async registerDevice (device) {
181
+ // Generate UUID for this device
182
+ const uuid = this.api.hap.uuid.generate(device.deviceId)
183
+ this.discoveredUUIDs.push(uuid)
184
+
185
+ // Check if accessory already exists
186
+ const existingAccessory = this.accessories.get(uuid)
187
+
188
+ if (existingAccessory) {
189
+ // Accessory exists - update it
190
+ this.log.info(LOG_PREFIX.PLATFORM, 'Restoring existing accessory:', device.name)
191
+
192
+ // Update context with fresh device data
193
+ const typedAccessory = /** @type {PlatformAccessory<YotoAccessoryContext>} */ (existingAccessory)
194
+ typedAccessory.context.device = device
195
+ typedAccessory.context.lastUpdate = Date.now()
196
+
197
+ // Update accessory information
198
+ this.api.updatePlatformAccessories([existingAccessory])
199
+
200
+ // Create handler for this accessory
201
+ // eslint-disable-next-line no-new
202
+ new YotoPlayerAccessory(this, typedAccessory)
203
+ } else {
204
+ // Create new accessory
205
+ this.log.info(LOG_PREFIX.PLATFORM, 'Adding new accessory:', device.name)
206
+
207
+ // Create platform accessory
208
+ // eslint-disable-next-line new-cap
209
+ const accessory = new this.api.platformAccessory(device.name, uuid)
210
+
211
+ // Create typed accessory with context
212
+ const typedAccessory = /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory)
213
+
214
+ // Set accessory context
215
+ typedAccessory.context = {
216
+ device,
217
+ lastStatus: null,
218
+ lastEvents: null,
219
+ lastUpdate: Date.now()
220
+ }
221
+
222
+ // Create handler for this accessory
223
+ // eslint-disable-next-line no-new
224
+ new YotoPlayerAccessory(this, typedAccessory)
225
+
226
+ // Register with Homebridge
227
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
228
+
229
+ // Add to our tracking map
230
+ this.accessories.set(uuid, typedAccessory)
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Remove accessories that are no longer present in the account
236
+ */
237
+ removeStaleAccessories () {
238
+ const staleAccessories = []
239
+
240
+ for (const [uuid, accessory] of this.accessories) {
241
+ if (!this.discoveredUUIDs.includes(uuid)) {
242
+ this.log.info(LOG_PREFIX.PLATFORM, 'Removing stale accessory:', accessory.displayName)
243
+ staleAccessories.push(accessory)
244
+ }
245
+ }
246
+
247
+ if (staleAccessories.length > 0) {
248
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, staleAccessories)
249
+
250
+ // Remove from tracking map
251
+ for (const accessory of staleAccessories) {
252
+ this.accessories.delete(accessory.UUID)
253
+ }
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Shutdown handler - cleanup connections
259
+ */
260
+ async shutdown () {
261
+ this.log.info(LOG_PREFIX.PLATFORM, 'Shutting down...')
262
+
263
+ try {
264
+ await this.yotoMqtt.disconnect()
265
+ } catch (error) {
266
+ this.log.error(LOG_PREFIX.PLATFORM, 'Error during shutdown:', error)
267
+ }
268
+ }
269
+ }