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 +70 -50
- package/lib/playerAccessory.js +32 -29
- package/package.json +1 -1
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
|
|
126
|
-
await this.
|
|
123
|
+
// Save tokens to persistent storage
|
|
124
|
+
await this.saveTokens()
|
|
127
125
|
|
|
128
|
-
this.log.info(LOG_PREFIX.PLATFORM, '✓ Authentication successful and saved
|
|
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
|
|
142
|
-
await this.
|
|
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
|
|
161
|
-
this.
|
|
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
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
196
|
+
* Save tokens to persistent storage
|
|
181
197
|
* @returns {Promise<void>}
|
|
182
198
|
*/
|
|
183
|
-
async
|
|
199
|
+
async saveTokens () {
|
|
184
200
|
try {
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
this.
|
|
191
|
-
|
|
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
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
}
|
package/lib/playerAccessory.js
CHANGED
|
@@ -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
|
-
//
|
|
97
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
687
|
+
await this.mqtt.resumeCard(this.device.deviceId)
|
|
685
688
|
} else if (value === this.platform.Characteristic.TargetMediaState.PAUSE) {
|
|
686
|
-
await this.
|
|
689
|
+
await this.mqtt.pauseCard(this.device.deviceId)
|
|
687
690
|
} else if (value === this.platform.Characteristic.TargetMediaState.STOP) {
|
|
688
|
-
await this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1044
|
+
await this.mqtt.setSleepTimer(this.device.deviceId, 0)
|
|
1042
1045
|
} else {
|
|
1043
1046
|
// Turn on with default duration (30 minutes)
|
|
1044
|
-
await this.
|
|
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.
|
|
1086
|
+
await this.mqtt.setSleepTimer(this.device.deviceId, 0)
|
|
1084
1087
|
} else {
|
|
1085
1088
|
// Set timer with specified duration
|
|
1086
|
-
await this.
|
|
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.
|
|
1235
|
+
await this.mqtt.setAmbientLight(this.device.deviceId, 255, 255, 255)
|
|
1233
1236
|
} else {
|
|
1234
1237
|
// Turn off - set to black
|
|
1235
|
-
await this.
|
|
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.
|
|
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.
|
|
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"
|