homebridge-yoto 0.0.7 → 0.0.8

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
@@ -5,9 +5,10 @@
5
5
  /** @import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig } from 'homebridge' */
6
6
  /** @import { YotoDevice, YotoPlatformConfig, YotoAccessoryContext } from './types.js' */
7
7
 
8
+ import { readFile, writeFile, mkdir } from 'fs/promises'
9
+ import { join } from 'path'
8
10
  import { YotoAuth } from './auth.js'
9
11
  import { YotoApi } from './yotoApi.js'
10
- import { YotoMqtt } from './yotoMqtt.js'
11
12
  import { YotoPlayerAccessory } from './playerAccessory.js'
12
13
  import {
13
14
  PLATFORM_NAME,
@@ -44,10 +45,13 @@ export class YotoPlatform {
44
45
  /** @type {string[]} */
45
46
  this.discoveredUUIDs = []
46
47
 
48
+ // Persistent storage path for tokens
49
+ this.storagePath = api.user.storagePath()
50
+ this.tokenFilePath = join(this.storagePath, 'homebridge-yoto-tokens.json')
51
+
47
52
  // Initialize API clients
48
53
  this.auth = new YotoAuth(log, this.config.clientId)
49
54
  this.yotoApi = new YotoApi(log, this.auth)
50
- this.yotoMqtt = new YotoMqtt(log)
51
55
 
52
56
  // Set up token refresh callback
53
57
  this.yotoApi.setTokenRefreshCallback(this.handleTokenRefresh.bind(this))
@@ -69,6 +73,9 @@ export class YotoPlatform {
69
73
  */
70
74
  async initialize () {
71
75
  try {
76
+ // Load tokens from persistent storage
77
+ await this.loadTokens()
78
+
72
79
  // Check if we have stored credentials
73
80
  if (!this.config.accessToken || !this.config.refreshToken) {
74
81
  await this.performDeviceFlow()
@@ -93,15 +100,6 @@ export class YotoPlatform {
93
100
  }
94
101
  throw error
95
102
  }
96
-
97
- // Connect to MQTT with first device ID
98
- if (this.accessories.size > 0) {
99
- const firstAccessory = Array.from(this.accessories.values())[0]
100
- if (firstAccessory) {
101
- const firstDeviceId = firstAccessory.context.device.deviceId
102
- await this.yotoMqtt.connect(this.config.accessToken || '', firstDeviceId)
103
- }
104
- }
105
103
  } catch (error) {
106
104
  this.log.error(LOG_PREFIX.PLATFORM, 'Initialization failed:', error)
107
105
  }
@@ -122,10 +120,10 @@ export class YotoPlatform {
122
120
  this.config.refreshToken = tokenResponse.refresh_token || ''
123
121
  this.config.tokenExpiresAt = this.auth.calculateExpiresAt(tokenResponse.expires_in)
124
122
 
125
- // Save tokens to config file
126
- await this.saveConfig()
123
+ // Save tokens to persistent storage
124
+ await this.saveTokens()
127
125
 
128
- this.log.info(LOG_PREFIX.PLATFORM, '✓ Authentication successful and saved to config!')
126
+ this.log.info(LOG_PREFIX.PLATFORM, '✓ Authentication successful and saved!')
129
127
  }
130
128
 
131
129
  /**
@@ -138,8 +136,8 @@ export class YotoPlatform {
138
136
  this.config.refreshToken = ''
139
137
  this.config.tokenExpiresAt = 0
140
138
 
141
- // Save cleared config
142
- await this.saveConfig()
139
+ // Save cleared tokens
140
+ await this.saveTokens()
143
141
 
144
142
  // Restart initialization
145
143
  await this.initialize()
@@ -157,43 +155,62 @@ export class YotoPlatform {
157
155
  this.config.refreshToken = refreshToken
158
156
  this.config.tokenExpiresAt = expiresAt
159
157
 
160
- // Save updated tokens to config file
161
- this.saveConfig().catch(error => {
158
+ // Save updated tokens to persistent storage
159
+ this.saveTokens().catch(error => {
162
160
  this.log.error(LOG_PREFIX.PLATFORM, 'Failed to save refreshed tokens:', error)
163
161
  })
164
162
 
165
- // Reconnect MQTT with new token
166
- if (this.accessories.size > 0) {
167
- const firstAccessory = Array.from(this.accessories.values())[0]
168
- if (firstAccessory) {
169
- const firstDeviceId = firstAccessory.context.device.deviceId
170
- this.yotoMqtt.disconnect().then(() => {
171
- return this.yotoMqtt.connect(accessToken, firstDeviceId)
172
- }).catch(error => {
173
- this.log.error(LOG_PREFIX.PLATFORM, 'Failed to reconnect MQTT after token refresh:', error)
174
- })
163
+ // Note: MQTT reconnection is handled by each accessory's own MQTT client
164
+ }
165
+
166
+ /**
167
+ * Load tokens from config or persistent storage
168
+ * Priority: config.json > persistent storage file
169
+ * @returns {Promise<void>}
170
+ */
171
+ async loadTokens () {
172
+ // First check if tokens are in config.json
173
+ if (this.config.accessToken && this.config.refreshToken) {
174
+ this.log.debug(LOG_PREFIX.PLATFORM, 'Using tokens from config.json')
175
+ return
176
+ }
177
+
178
+ // Fall back to persistent storage file
179
+ try {
180
+ const data = await readFile(this.tokenFilePath, 'utf-8')
181
+ const tokens = JSON.parse(data)
182
+
183
+ if (tokens.accessToken && tokens.refreshToken) {
184
+ this.config.accessToken = tokens.accessToken
185
+ this.config.refreshToken = tokens.refreshToken
186
+ this.config.tokenExpiresAt = tokens.tokenExpiresAt
187
+ this.log.debug(LOG_PREFIX.PLATFORM, 'Loaded tokens from persistent storage')
175
188
  }
189
+ } catch (error) {
190
+ // File doesn't exist or is invalid - not an error on first run
191
+ this.log.debug(LOG_PREFIX.PLATFORM, 'No saved tokens found in storage')
176
192
  }
177
193
  }
178
194
 
179
195
  /**
180
- * Save config to disk using Homebridge API
196
+ * Save tokens to persistent storage
181
197
  * @returns {Promise<void>}
182
198
  */
183
- async saveConfig () {
199
+ async saveTokens () {
184
200
  try {
185
- // Check if updatePlatformConfig is available (Homebridge 1.3.0+)
186
- // @ts-expect-error - updatePlatformConfig may not exist in older Homebridge versions
187
- if (typeof this.api.updatePlatformConfig === 'function') {
188
- // @ts-expect-error - updatePlatformConfig may not exist in older Homebridge versions
189
- await this.api.updatePlatformConfig([this.config])
190
- this.log.debug(LOG_PREFIX.PLATFORM, 'Config saved to disk')
191
- } else {
192
- // Fallback for older Homebridge versions
193
- this.log.debug(LOG_PREFIX.PLATFORM, 'Config updated in memory (restart Homebridge to persist)')
201
+ // Ensure storage directory exists
202
+ await mkdir(this.storagePath, { recursive: true })
203
+
204
+ const tokens = {
205
+ accessToken: this.config.accessToken || '',
206
+ refreshToken: this.config.refreshToken || '',
207
+ tokenExpiresAt: this.config.tokenExpiresAt || 0
194
208
  }
209
+
210
+ await writeFile(this.tokenFilePath, JSON.stringify(tokens, null, 2), 'utf-8')
211
+ this.log.debug(LOG_PREFIX.PLATFORM, 'Saved tokens to persistent storage')
195
212
  } catch (error) {
196
- this.log.error(LOG_PREFIX.PLATFORM, 'Failed to save config:', error)
213
+ this.log.error(LOG_PREFIX.PLATFORM, 'Failed to save tokens:', error)
197
214
  throw error
198
215
  }
199
216
  }
@@ -267,8 +284,12 @@ export class YotoPlatform {
267
284
  this.api.updatePlatformAccessories([existingAccessory])
268
285
 
269
286
  // Create handler for this accessory
270
- // eslint-disable-next-line no-new
271
- new YotoPlayerAccessory(this, typedAccessory)
287
+ const handler = new YotoPlayerAccessory(this, typedAccessory)
288
+
289
+ // Initialize accessory (connect MQTT, etc.)
290
+ handler.initialize().catch(error => {
291
+ this.log.error(LOG_PREFIX.PLATFORM, `Failed to initialize ${device.name}:`, error)
292
+ })
272
293
  } else {
273
294
  // Create new accessory
274
295
  this.log.info(LOG_PREFIX.PLATFORM, 'Adding new accessory:', device.name)
@@ -289,8 +310,12 @@ export class YotoPlatform {
289
310
  }
290
311
 
291
312
  // Create handler for this accessory
292
- // eslint-disable-next-line no-new
293
- new YotoPlayerAccessory(this, typedAccessory)
313
+ const handler = new YotoPlayerAccessory(this, typedAccessory)
314
+
315
+ // Initialize accessory (connect MQTT, etc.)
316
+ handler.initialize().catch(error => {
317
+ this.log.error(LOG_PREFIX.PLATFORM, `Failed to initialize ${device.name}:`, error)
318
+ })
294
319
 
295
320
  // Register with Homebridge
296
321
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
@@ -328,11 +353,6 @@ export class YotoPlatform {
328
353
  */
329
354
  async shutdown () {
330
355
  this.log.info(LOG_PREFIX.PLATFORM, 'Shutting down...')
331
-
332
- try {
333
- await this.yotoMqtt.disconnect()
334
- } catch (error) {
335
- this.log.error(LOG_PREFIX.PLATFORM, 'Error during shutdown:', error)
336
- }
356
+ // MQTT cleanup is handled by individual accessories
337
357
  }
338
358
  }
@@ -6,6 +6,7 @@
6
6
  /** @import { YotoPlatform } from './platform.js' */
7
7
  /** @import { YotoAccessoryContext, YotoDeviceStatus, YotoPlaybackEvents } from './types.js' */
8
8
 
9
+ import { YotoMqtt } from './yotoMqtt.js'
9
10
  import {
10
11
  DEFAULT_MANUFACTURER,
11
12
  DEFAULT_MODEL,
@@ -37,6 +38,9 @@ export class YotoPlayerAccessory {
37
38
  this.currentEvents = accessory.context.lastEvents || null
38
39
  this.lastUpdateTime = Date.now()
39
40
 
41
+ // Create dedicated MQTT client for this device
42
+ this.mqtt = new YotoMqtt(this.log)
43
+
40
44
  this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Initializing accessory`)
41
45
 
42
46
  // Set up services
@@ -93,8 +97,16 @@ export class YotoPlayerAccessory {
93
97
  this.activeContentInfo = null
94
98
  }
95
99
 
96
- // Connect MQTT for this device
97
- this.connectMqtt()
100
+ // MQTT connection will be initiated by platform after construction
101
+ }
102
+
103
+ /**
104
+ * Initialize the accessory - connect MQTT and subscribe to updates
105
+ * Called by platform after construction
106
+ * @returns {Promise<void>}
107
+ */
108
+ async initialize () {
109
+ await this.connectMqtt()
98
110
  }
99
111
 
100
112
  /**
@@ -397,30 +409,21 @@ export class YotoPlayerAccessory {
397
409
  }
398
410
 
399
411
  // Connect MQTT with device ID and access token
400
- await this.platform.yotoMqtt.connect(
412
+ await this.mqtt.connect(
401
413
  this.platform.config.accessToken,
402
414
  this.device.deviceId
403
415
  )
404
416
 
405
417
  // Subscribe to device topics
406
- await this.subscribeMqtt()
407
- } catch (error) {
408
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to connect MQTT:`, error)
409
- }
410
- }
411
-
412
- /**
413
- * Subscribe to MQTT updates for this device
414
- */
415
- async subscribeMqtt () {
416
- try {
417
- await this.platform.yotoMqtt.subscribeToDevice(this.device.deviceId, {
418
+ await this.mqtt.subscribeToDevice(this.device.deviceId, {
418
419
  onStatus: this.handleStatusUpdate.bind(this),
419
420
  onEvents: this.handleEventsUpdate.bind(this),
420
421
  onResponse: this.handleCommandResponse.bind(this)
421
422
  })
423
+
424
+ this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.device.name}] MQTT connected and subscribed`)
422
425
  } catch (error) {
423
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to subscribe to MQTT:`, error)
426
+ this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to connect MQTT:`, error)
424
427
  }
425
428
  }
426
429
 
@@ -681,11 +684,11 @@ export class YotoPlayerAccessory {
681
684
 
682
685
  try {
683
686
  if (value === this.platform.Characteristic.TargetMediaState.PLAY) {
684
- await this.platform.yotoMqtt.resumeCard(this.device.deviceId)
687
+ await this.mqtt.resumeCard(this.device.deviceId)
685
688
  } else if (value === this.platform.Characteristic.TargetMediaState.PAUSE) {
686
- await this.platform.yotoMqtt.pauseCard(this.device.deviceId)
689
+ await this.mqtt.pauseCard(this.device.deviceId)
687
690
  } else if (value === this.platform.Characteristic.TargetMediaState.STOP) {
688
- await this.platform.yotoMqtt.stopCard(this.device.deviceId)
691
+ await this.mqtt.stopCard(this.device.deviceId)
689
692
  }
690
693
  } catch (error) {
691
694
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set media state:`, error)
@@ -714,7 +717,7 @@ export class YotoPlayerAccessory {
714
717
  this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Set volume:`, value)
715
718
 
716
719
  try {
717
- await this.platform.yotoMqtt.setVolume(this.device.deviceId, Number(value))
720
+ await this.mqtt.setVolume(this.device.deviceId, Number(value))
718
721
  } catch (error) {
719
722
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set volume:`, error)
720
723
  throw new this.platform.api.hap.HapStatusError(
@@ -744,12 +747,12 @@ export class YotoPlayerAccessory {
744
747
  try {
745
748
  if (value) {
746
749
  // Mute - set volume to 0
747
- await this.platform.yotoMqtt.setVolume(this.device.deviceId, 0)
750
+ await this.mqtt.setVolume(this.device.deviceId, 0)
748
751
  } else {
749
752
  // Unmute - restore to a reasonable volume if currently 0
750
753
  const currentVolume = this.currentStatus?.userVolume || 0
751
754
  const targetVolume = currentVolume === 0 ? 50 : currentVolume
752
- await this.platform.yotoMqtt.setVolume(this.device.deviceId, targetVolume)
755
+ await this.mqtt.setVolume(this.device.deviceId, targetVolume)
753
756
  }
754
757
  } catch (error) {
755
758
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set mute:`, error)
@@ -1038,10 +1041,10 @@ export class YotoPlayerAccessory {
1038
1041
  try {
1039
1042
  if (value === this.platform.Characteristic.Active.INACTIVE) {
1040
1043
  // Turn off timer
1041
- await this.platform.yotoMqtt.setSleepTimer(this.device.deviceId, 0)
1044
+ await this.mqtt.setSleepTimer(this.device.deviceId, 0)
1042
1045
  } else {
1043
1046
  // Turn on with default duration (30 minutes)
1044
- await this.platform.yotoMqtt.setSleepTimer(this.device.deviceId, 30 * 60)
1047
+ await this.mqtt.setSleepTimer(this.device.deviceId, 30 * 60)
1045
1048
  }
1046
1049
  } catch (error) {
1047
1050
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set sleep timer active:`, error)
@@ -1080,10 +1083,10 @@ export class YotoPlayerAccessory {
1080
1083
 
1081
1084
  if (seconds === 0) {
1082
1085
  // Turn off timer
1083
- await this.platform.yotoMqtt.setSleepTimer(this.device.deviceId, 0)
1086
+ await this.mqtt.setSleepTimer(this.device.deviceId, 0)
1084
1087
  } else {
1085
1088
  // Set timer with specified duration
1086
- await this.platform.yotoMqtt.setSleepTimer(this.device.deviceId, seconds)
1089
+ await this.mqtt.setSleepTimer(this.device.deviceId, seconds)
1087
1090
  }
1088
1091
  } catch (error) {
1089
1092
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set sleep timer:`, error)
@@ -1229,10 +1232,10 @@ export class YotoPlayerAccessory {
1229
1232
  try {
1230
1233
  if (value) {
1231
1234
  // Turn on - set to white or previous color
1232
- await this.platform.yotoMqtt.setAmbientLight(this.device.deviceId, 255, 255, 255)
1235
+ await this.mqtt.setAmbientLight(this.device.deviceId, 255, 255, 255)
1233
1236
  } else {
1234
1237
  // Turn off - set to black
1235
- await this.platform.yotoMqtt.setAmbientLight(this.device.deviceId, 0, 0, 0)
1238
+ await this.mqtt.setAmbientLight(this.device.deviceId, 0, 0, 0)
1236
1239
  }
1237
1240
  } catch (error) {
1238
1241
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set ambient light on:`, error)
@@ -1342,7 +1345,7 @@ export class YotoPlayerAccessory {
1342
1345
  const { r, g, b } = this.hsvToRgb(h, s, v)
1343
1346
 
1344
1347
  // Send MQTT command
1345
- await this.platform.yotoMqtt.setAmbientLight(this.device.deviceId, r, g, b)
1348
+ await this.mqtt.setAmbientLight(this.device.deviceId, r, g, b)
1346
1349
 
1347
1350
  // Clear pending values
1348
1351
  this.pendingAmbientHue = undefined
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-yoto",
3
3
  "description": "Control your Yoto players through Apple HomeKit with real-time MQTT updates",
4
- "version": "0.0.7",
4
+ "version": "0.0.8",
5
5
  "author": "Bret Comnes <bcomnes@gmail.com> (https://bret.io)",
6
6
  "bugs": {
7
7
  "url": "https://github.com/bcomnes/homebridge-yoto/issues"