homebridge-yoto 0.0.7 → 0.0.9

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
  /**
@@ -396,31 +408,28 @@ export class YotoPlayerAccessory {
396
408
  return
397
409
  }
398
410
 
411
+ // TEMPORARY: Debug logging for MQTT troubleshooting
412
+ this.log.warn(LOG_PREFIX.ACCESSORY, `[${this.device.name}] MQTT Connection Details:`)
413
+ this.log.warn(LOG_PREFIX.ACCESSORY, ` Device ID: ${this.device.deviceId}`)
414
+ this.log.warn(LOG_PREFIX.ACCESSORY, ` Access Token: ${this.platform.config.accessToken}`)
415
+ this.log.warn(LOG_PREFIX.ACCESSORY, ` Token Length: ${this.platform.config.accessToken.length}`)
416
+
399
417
  // Connect MQTT with device ID and access token
400
- await this.platform.yotoMqtt.connect(
418
+ await this.mqtt.connect(
401
419
  this.platform.config.accessToken,
402
420
  this.device.deviceId
403
421
  )
404
422
 
405
423
  // 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, {
424
+ await this.mqtt.subscribeToDevice(this.device.deviceId, {
418
425
  onStatus: this.handleStatusUpdate.bind(this),
419
426
  onEvents: this.handleEventsUpdate.bind(this),
420
427
  onResponse: this.handleCommandResponse.bind(this)
421
428
  })
429
+
430
+ this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.device.name}] MQTT connected and subscribed`)
422
431
  } catch (error) {
423
- this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to subscribe to MQTT:`, error)
432
+ this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to connect MQTT:`, error)
424
433
  }
425
434
  }
426
435
 
@@ -681,11 +690,11 @@ export class YotoPlayerAccessory {
681
690
 
682
691
  try {
683
692
  if (value === this.platform.Characteristic.TargetMediaState.PLAY) {
684
- await this.platform.yotoMqtt.resumeCard(this.device.deviceId)
693
+ await this.mqtt.resumeCard(this.device.deviceId)
685
694
  } else if (value === this.platform.Characteristic.TargetMediaState.PAUSE) {
686
- await this.platform.yotoMqtt.pauseCard(this.device.deviceId)
695
+ await this.mqtt.pauseCard(this.device.deviceId)
687
696
  } else if (value === this.platform.Characteristic.TargetMediaState.STOP) {
688
- await this.platform.yotoMqtt.stopCard(this.device.deviceId)
697
+ await this.mqtt.stopCard(this.device.deviceId)
689
698
  }
690
699
  } catch (error) {
691
700
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set media state:`, error)
@@ -714,7 +723,7 @@ export class YotoPlayerAccessory {
714
723
  this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Set volume:`, value)
715
724
 
716
725
  try {
717
- await this.platform.yotoMqtt.setVolume(this.device.deviceId, Number(value))
726
+ await this.mqtt.setVolume(this.device.deviceId, Number(value))
718
727
  } catch (error) {
719
728
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set volume:`, error)
720
729
  throw new this.platform.api.hap.HapStatusError(
@@ -744,12 +753,12 @@ export class YotoPlayerAccessory {
744
753
  try {
745
754
  if (value) {
746
755
  // Mute - set volume to 0
747
- await this.platform.yotoMqtt.setVolume(this.device.deviceId, 0)
756
+ await this.mqtt.setVolume(this.device.deviceId, 0)
748
757
  } else {
749
758
  // Unmute - restore to a reasonable volume if currently 0
750
759
  const currentVolume = this.currentStatus?.userVolume || 0
751
760
  const targetVolume = currentVolume === 0 ? 50 : currentVolume
752
- await this.platform.yotoMqtt.setVolume(this.device.deviceId, targetVolume)
761
+ await this.mqtt.setVolume(this.device.deviceId, targetVolume)
753
762
  }
754
763
  } catch (error) {
755
764
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set mute:`, error)
@@ -1038,10 +1047,10 @@ export class YotoPlayerAccessory {
1038
1047
  try {
1039
1048
  if (value === this.platform.Characteristic.Active.INACTIVE) {
1040
1049
  // Turn off timer
1041
- await this.platform.yotoMqtt.setSleepTimer(this.device.deviceId, 0)
1050
+ await this.mqtt.setSleepTimer(this.device.deviceId, 0)
1042
1051
  } else {
1043
1052
  // Turn on with default duration (30 minutes)
1044
- await this.platform.yotoMqtt.setSleepTimer(this.device.deviceId, 30 * 60)
1053
+ await this.mqtt.setSleepTimer(this.device.deviceId, 30 * 60)
1045
1054
  }
1046
1055
  } catch (error) {
1047
1056
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set sleep timer active:`, error)
@@ -1080,10 +1089,10 @@ export class YotoPlayerAccessory {
1080
1089
 
1081
1090
  if (seconds === 0) {
1082
1091
  // Turn off timer
1083
- await this.platform.yotoMqtt.setSleepTimer(this.device.deviceId, 0)
1092
+ await this.mqtt.setSleepTimer(this.device.deviceId, 0)
1084
1093
  } else {
1085
1094
  // Set timer with specified duration
1086
- await this.platform.yotoMqtt.setSleepTimer(this.device.deviceId, seconds)
1095
+ await this.mqtt.setSleepTimer(this.device.deviceId, seconds)
1087
1096
  }
1088
1097
  } catch (error) {
1089
1098
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set sleep timer:`, error)
@@ -1229,10 +1238,10 @@ export class YotoPlayerAccessory {
1229
1238
  try {
1230
1239
  if (value) {
1231
1240
  // Turn on - set to white or previous color
1232
- await this.platform.yotoMqtt.setAmbientLight(this.device.deviceId, 255, 255, 255)
1241
+ await this.mqtt.setAmbientLight(this.device.deviceId, 255, 255, 255)
1233
1242
  } else {
1234
1243
  // Turn off - set to black
1235
- await this.platform.yotoMqtt.setAmbientLight(this.device.deviceId, 0, 0, 0)
1244
+ await this.mqtt.setAmbientLight(this.device.deviceId, 0, 0, 0)
1236
1245
  }
1237
1246
  } catch (error) {
1238
1247
  this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set ambient light on:`, error)
@@ -1342,7 +1351,7 @@ export class YotoPlayerAccessory {
1342
1351
  const { r, g, b } = this.hsvToRgb(h, s, v)
1343
1352
 
1344
1353
  // Send MQTT command
1345
- await this.platform.yotoMqtt.setAmbientLight(this.device.deviceId, r, g, b)
1354
+ await this.mqtt.setAmbientLight(this.device.deviceId, r, g, b)
1346
1355
 
1347
1356
  // Clear pending values
1348
1357
  this.pendingAmbientHue = undefined
package/lib/yotoMqtt.js CHANGED
@@ -65,14 +65,31 @@ export class YotoMqtt {
65
65
  this.log.info(LOG_PREFIX.MQTT, `Connecting to ${this.brokerUrl}...`)
66
66
 
67
67
  const clientId = `homebridge-yoto-${deviceId}-${Date.now()}`
68
+ const username = `${deviceId}?x-amz-customauthorizer-name=${YOTO_MQTT_AUTH_NAME}`
69
+
70
+ // TEMPORARY: Detailed debug logging for MQTT troubleshooting
71
+ this.log.warn(LOG_PREFIX.MQTT, '='.repeat(60))
72
+ this.log.warn(LOG_PREFIX.MQTT, 'MQTT CONNECTION DETAILS')
73
+ this.log.warn(LOG_PREFIX.MQTT, '='.repeat(60))
74
+ this.log.warn(LOG_PREFIX.MQTT, `Broker URL: ${this.brokerUrl}`)
75
+ this.log.warn(LOG_PREFIX.MQTT, `Client ID: ${clientId}`)
76
+ this.log.warn(LOG_PREFIX.MQTT, `Username: ${username}`)
77
+ this.log.warn(LOG_PREFIX.MQTT, `Device ID: ${deviceId}`)
78
+ this.log.warn(LOG_PREFIX.MQTT, `Token: ${accessToken}`)
79
+ this.log.warn(LOG_PREFIX.MQTT, `Token length: ${accessToken.length}`)
80
+ this.log.warn(LOG_PREFIX.MQTT, 'Port: 443')
81
+ this.log.warn(LOG_PREFIX.MQTT, 'Protocol: wss')
82
+ this.log.warn(LOG_PREFIX.MQTT, 'Keepalive: 300')
83
+ this.log.warn(LOG_PREFIX.MQTT, `Connect Timeout: ${MQTT_CONNECT_TIMEOUT}ms`)
84
+ this.log.warn(LOG_PREFIX.MQTT, '='.repeat(60))
68
85
 
69
86
  this.client = mqtt.connect(this.brokerUrl, {
70
87
  keepalive: 300,
71
88
  port: 443,
72
89
  protocol: 'wss',
73
- username: `${deviceId}?x-amz-customauthorizer-name=${YOTO_MQTT_AUTH_NAME}`,
90
+ username,
74
91
  password: accessToken,
75
- reconnectPeriod: MQTT_RECONNECT_PERIOD,
92
+ reconnectPeriod: 0, // Disable auto-reconnect - we'll handle reconnection manually
76
93
  connectTimeout: MQTT_CONNECT_TIMEOUT,
77
94
  clientId,
78
95
  clean: true,
@@ -93,6 +110,7 @@ export class YotoMqtt {
93
110
 
94
111
  this.client.on('error', (error) => {
95
112
  this.log.error(LOG_PREFIX.MQTT, 'Connection error:', error)
113
+ this.log.error(LOG_PREFIX.MQTT, 'Error details:', JSON.stringify(error, null, 2))
96
114
  if (!this.connected) {
97
115
  reject(error)
98
116
  }
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.9",
5
5
  "author": "Bret Comnes <bcomnes@gmail.com> (https://bret.io)",
6
6
  "bugs": {
7
7
  "url": "https://github.com/bcomnes/homebridge-yoto/issues"