homebridge-yoto 0.0.6 → 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/constants.js +15 -14
- package/lib/platform.js +130 -39
- package/lib/playerAccessory.js +46 -19
- package/lib/yotoApi.js +2 -1
- package/lib/yotoMqtt.js +15 -6
- package/package.json +1 -1
package/lib/constants.js
CHANGED
|
@@ -21,32 +21,33 @@ export const YOTO_OAUTH_DEVICE_CODE_URL = `${YOTO_OAUTH_BASE_URL}/oauth/device/c
|
|
|
21
21
|
* MQTT configuration
|
|
22
22
|
*/
|
|
23
23
|
export const YOTO_MQTT_BROKER_URL = 'wss://aqrphjqbp3u2z-ats.iot.eu-west-2.amazonaws.com'
|
|
24
|
+
export const YOTO_MQTT_AUTH_NAME = 'PublicJWTAuthorizer'
|
|
24
25
|
export const MQTT_RECONNECT_PERIOD = 5000 // milliseconds
|
|
25
26
|
export const MQTT_CONNECT_TIMEOUT = 30000 // milliseconds
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* MQTT topic templates
|
|
29
30
|
*/
|
|
30
|
-
export const MQTT_TOPIC_DATA_STATUS = '
|
|
31
|
-
export const MQTT_TOPIC_DATA_EVENTS = '
|
|
32
|
-
export const MQTT_TOPIC_RESPONSE = '
|
|
33
|
-
export const MQTT_TOPIC_COMMAND_STATUS_REQUEST = '
|
|
34
|
-
export const MQTT_TOPIC_COMMAND_EVENTS_REQUEST = '
|
|
35
|
-
export const MQTT_TOPIC_COMMAND_VOLUME_SET = '
|
|
36
|
-
export const MQTT_TOPIC_COMMAND_CARD_START = '
|
|
37
|
-
export const MQTT_TOPIC_COMMAND_CARD_STOP = '
|
|
38
|
-
export const MQTT_TOPIC_COMMAND_CARD_PAUSE = '
|
|
39
|
-
export const MQTT_TOPIC_COMMAND_CARD_RESUME = '
|
|
40
|
-
export const MQTT_TOPIC_COMMAND_SLEEP_TIMER = '
|
|
41
|
-
export const MQTT_TOPIC_COMMAND_AMBIENTS_SET = '
|
|
42
|
-
export const MQTT_TOPIC_COMMAND_REBOOT = '
|
|
31
|
+
export const MQTT_TOPIC_DATA_STATUS = 'device/{deviceId}/status'
|
|
32
|
+
export const MQTT_TOPIC_DATA_EVENTS = 'device/{deviceId}/events'
|
|
33
|
+
export const MQTT_TOPIC_RESPONSE = 'device/{deviceId}/response'
|
|
34
|
+
export const MQTT_TOPIC_COMMAND_STATUS_REQUEST = 'device/{deviceId}/command/status/request'
|
|
35
|
+
export const MQTT_TOPIC_COMMAND_EVENTS_REQUEST = 'device/{deviceId}/command/events/request'
|
|
36
|
+
export const MQTT_TOPIC_COMMAND_VOLUME_SET = 'device/{deviceId}/command/volume/set'
|
|
37
|
+
export const MQTT_TOPIC_COMMAND_CARD_START = 'device/{deviceId}/command/card/start'
|
|
38
|
+
export const MQTT_TOPIC_COMMAND_CARD_STOP = 'device/{deviceId}/command/card/stop'
|
|
39
|
+
export const MQTT_TOPIC_COMMAND_CARD_PAUSE = 'device/{deviceId}/command/card/pause'
|
|
40
|
+
export const MQTT_TOPIC_COMMAND_CARD_RESUME = 'device/{deviceId}/command/card/resume'
|
|
41
|
+
export const MQTT_TOPIC_COMMAND_SLEEP_TIMER = 'device/{deviceId}/command/sleep-timer/set'
|
|
42
|
+
export const MQTT_TOPIC_COMMAND_AMBIENTS_SET = 'device/{deviceId}/command/ambients/set'
|
|
43
|
+
export const MQTT_TOPIC_COMMAND_REBOOT = 'device/{deviceId}/command/reboot'
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* OAuth configuration
|
|
46
47
|
*/
|
|
47
48
|
export const OAUTH_CLIENT_ID = 'Y4HJ8BFqRQ24GQoLzgOzZ2KSqWmFG8LI'
|
|
48
49
|
export const OAUTH_AUDIENCE = 'https://api.yotoplay.com'
|
|
49
|
-
export const OAUTH_SCOPE = 'profile offline_access'
|
|
50
|
+
export const OAUTH_SCOPE = 'openid profile offline_access'
|
|
50
51
|
export const OAUTH_POLLING_INTERVAL = 5000 // milliseconds
|
|
51
52
|
export const OAUTH_DEVICE_CODE_TIMEOUT = 300000 // 5 minutes
|
|
52
53
|
|
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,25 +73,12 @@ 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
|
-
this.
|
|
75
|
-
this.log.info(LOG_PREFIX.PLATFORM, 'Starting OAuth flow...')
|
|
76
|
-
|
|
77
|
-
const tokenResponse = await this.auth.authorize()
|
|
78
|
-
|
|
79
|
-
// Store tokens in config
|
|
80
|
-
this.config.accessToken = tokenResponse.access_token
|
|
81
|
-
this.config.refreshToken = tokenResponse.refresh_token || ''
|
|
82
|
-
this.config.tokenExpiresAt = this.auth.calculateExpiresAt(tokenResponse.expires_in)
|
|
83
|
-
|
|
84
|
-
this.log.info(LOG_PREFIX.PLATFORM, '✓ Authentication successful!')
|
|
85
|
-
this.log.warn(LOG_PREFIX.PLATFORM, 'IMPORTANT: Please update your Homebridge config with the following:')
|
|
86
|
-
this.log.warn(LOG_PREFIX.PLATFORM, JSON.stringify({
|
|
87
|
-
accessToken: this.config.accessToken,
|
|
88
|
-
refreshToken: this.config.refreshToken,
|
|
89
|
-
tokenExpiresAt: this.config.tokenExpiresAt
|
|
90
|
-
}, null, 2))
|
|
81
|
+
await this.performDeviceFlow()
|
|
91
82
|
}
|
|
92
83
|
|
|
93
84
|
// Set tokens in API client
|
|
@@ -97,16 +88,61 @@ export class YotoPlatform {
|
|
|
97
88
|
this.config.tokenExpiresAt || 0
|
|
98
89
|
)
|
|
99
90
|
|
|
100
|
-
// Connect to MQTT
|
|
101
|
-
await this.yotoMqtt.connect(this.config.accessToken)
|
|
102
|
-
|
|
103
91
|
// Discover and register devices
|
|
104
|
-
|
|
92
|
+
try {
|
|
93
|
+
await this.discoverDevices()
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// Check if this is an auth error that requires re-authentication
|
|
96
|
+
if (error instanceof Error && error.message.includes('TOKEN_REFRESH_FAILED')) {
|
|
97
|
+
this.log.warn(LOG_PREFIX.PLATFORM, 'Token refresh failed, clearing tokens and restarting auth flow...')
|
|
98
|
+
await this.clearTokensAndReauth()
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
105
103
|
} catch (error) {
|
|
106
104
|
this.log.error(LOG_PREFIX.PLATFORM, 'Initialization failed:', error)
|
|
107
105
|
}
|
|
108
106
|
}
|
|
109
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Perform device authorization flow
|
|
110
|
+
* @returns {Promise<void>}
|
|
111
|
+
*/
|
|
112
|
+
async performDeviceFlow () {
|
|
113
|
+
this.log.warn(LOG_PREFIX.PLATFORM, ERROR_MESSAGES.NO_AUTH)
|
|
114
|
+
this.log.info(LOG_PREFIX.PLATFORM, 'Starting OAuth flow...')
|
|
115
|
+
|
|
116
|
+
const tokenResponse = await this.auth.authorize()
|
|
117
|
+
|
|
118
|
+
// Store tokens in config
|
|
119
|
+
this.config.accessToken = tokenResponse.access_token
|
|
120
|
+
this.config.refreshToken = tokenResponse.refresh_token || ''
|
|
121
|
+
this.config.tokenExpiresAt = this.auth.calculateExpiresAt(tokenResponse.expires_in)
|
|
122
|
+
|
|
123
|
+
// Save tokens to persistent storage
|
|
124
|
+
await this.saveTokens()
|
|
125
|
+
|
|
126
|
+
this.log.info(LOG_PREFIX.PLATFORM, '✓ Authentication successful and saved!')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Clear invalid tokens and restart authentication
|
|
131
|
+
* @returns {Promise<void>}
|
|
132
|
+
*/
|
|
133
|
+
async clearTokensAndReauth () {
|
|
134
|
+
// Clear tokens from config
|
|
135
|
+
this.config.accessToken = ''
|
|
136
|
+
this.config.refreshToken = ''
|
|
137
|
+
this.config.tokenExpiresAt = 0
|
|
138
|
+
|
|
139
|
+
// Save cleared tokens
|
|
140
|
+
await this.saveTokens()
|
|
141
|
+
|
|
142
|
+
// Restart initialization
|
|
143
|
+
await this.initialize()
|
|
144
|
+
}
|
|
145
|
+
|
|
110
146
|
/**
|
|
111
147
|
* Handle token refresh - update config
|
|
112
148
|
* @param {string} accessToken - New access token
|
|
@@ -114,17 +150,69 @@ export class YotoPlatform {
|
|
|
114
150
|
* @param {number} expiresAt - New expiration timestamp
|
|
115
151
|
*/
|
|
116
152
|
handleTokenRefresh (accessToken, refreshToken, expiresAt) {
|
|
117
|
-
this.log.info(LOG_PREFIX.PLATFORM, 'Token refreshed
|
|
153
|
+
this.log.info(LOG_PREFIX.PLATFORM, 'Token refreshed')
|
|
118
154
|
this.config.accessToken = accessToken
|
|
119
155
|
this.config.refreshToken = refreshToken
|
|
120
156
|
this.config.tokenExpiresAt = expiresAt
|
|
121
157
|
|
|
122
|
-
//
|
|
123
|
-
this.
|
|
124
|
-
|
|
125
|
-
}).catch(error => {
|
|
126
|
-
this.log.error(LOG_PREFIX.PLATFORM, 'Failed to reconnect MQTT after token refresh:', error)
|
|
158
|
+
// Save updated tokens to persistent storage
|
|
159
|
+
this.saveTokens().catch(error => {
|
|
160
|
+
this.log.error(LOG_PREFIX.PLATFORM, 'Failed to save refreshed tokens:', error)
|
|
127
161
|
})
|
|
162
|
+
|
|
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')
|
|
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')
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Save tokens to persistent storage
|
|
197
|
+
* @returns {Promise<void>}
|
|
198
|
+
*/
|
|
199
|
+
async saveTokens () {
|
|
200
|
+
try {
|
|
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
|
|
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')
|
|
212
|
+
} catch (error) {
|
|
213
|
+
this.log.error(LOG_PREFIX.PLATFORM, 'Failed to save tokens:', error)
|
|
214
|
+
throw error
|
|
215
|
+
}
|
|
128
216
|
}
|
|
129
217
|
|
|
130
218
|
/**
|
|
@@ -196,8 +284,12 @@ export class YotoPlatform {
|
|
|
196
284
|
this.api.updatePlatformAccessories([existingAccessory])
|
|
197
285
|
|
|
198
286
|
// Create handler for this accessory
|
|
199
|
-
|
|
200
|
-
|
|
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
|
+
})
|
|
201
293
|
} else {
|
|
202
294
|
// Create new accessory
|
|
203
295
|
this.log.info(LOG_PREFIX.PLATFORM, 'Adding new accessory:', device.name)
|
|
@@ -218,8 +310,12 @@ export class YotoPlatform {
|
|
|
218
310
|
}
|
|
219
311
|
|
|
220
312
|
// Create handler for this accessory
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
})
|
|
223
319
|
|
|
224
320
|
// Register with Homebridge
|
|
225
321
|
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
|
|
@@ -257,11 +353,6 @@ export class YotoPlatform {
|
|
|
257
353
|
*/
|
|
258
354
|
async shutdown () {
|
|
259
355
|
this.log.info(LOG_PREFIX.PLATFORM, 'Shutting down...')
|
|
260
|
-
|
|
261
|
-
try {
|
|
262
|
-
await this.yotoMqtt.disconnect()
|
|
263
|
-
} catch (error) {
|
|
264
|
-
this.log.error(LOG_PREFIX.PLATFORM, 'Error during shutdown:', error)
|
|
265
|
-
}
|
|
356
|
+
// MQTT cleanup is handled by individual accessories
|
|
266
357
|
}
|
|
267
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
|
/**
|
|
@@ -386,17 +398,32 @@ export class YotoPlayerAccessory {
|
|
|
386
398
|
}
|
|
387
399
|
|
|
388
400
|
/**
|
|
389
|
-
*
|
|
401
|
+
* Connect MQTT for this device
|
|
390
402
|
*/
|
|
391
|
-
async
|
|
403
|
+
async connectMqtt () {
|
|
392
404
|
try {
|
|
393
|
-
|
|
405
|
+
// Ensure we have an access token
|
|
406
|
+
if (!this.platform.config.accessToken) {
|
|
407
|
+
this.log.warn(LOG_PREFIX.ACCESSORY, `[${this.device.name}] No access token available for MQTT connection`)
|
|
408
|
+
return
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Connect MQTT with device ID and access token
|
|
412
|
+
await this.mqtt.connect(
|
|
413
|
+
this.platform.config.accessToken,
|
|
414
|
+
this.device.deviceId
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
// Subscribe to device topics
|
|
418
|
+
await this.mqtt.subscribeToDevice(this.device.deviceId, {
|
|
394
419
|
onStatus: this.handleStatusUpdate.bind(this),
|
|
395
420
|
onEvents: this.handleEventsUpdate.bind(this),
|
|
396
421
|
onResponse: this.handleCommandResponse.bind(this)
|
|
397
422
|
})
|
|
423
|
+
|
|
424
|
+
this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.device.name}] MQTT connected and subscribed`)
|
|
398
425
|
} catch (error) {
|
|
399
|
-
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)
|
|
400
427
|
}
|
|
401
428
|
}
|
|
402
429
|
|
|
@@ -657,11 +684,11 @@ export class YotoPlayerAccessory {
|
|
|
657
684
|
|
|
658
685
|
try {
|
|
659
686
|
if (value === this.platform.Characteristic.TargetMediaState.PLAY) {
|
|
660
|
-
await this.
|
|
687
|
+
await this.mqtt.resumeCard(this.device.deviceId)
|
|
661
688
|
} else if (value === this.platform.Characteristic.TargetMediaState.PAUSE) {
|
|
662
|
-
await this.
|
|
689
|
+
await this.mqtt.pauseCard(this.device.deviceId)
|
|
663
690
|
} else if (value === this.platform.Characteristic.TargetMediaState.STOP) {
|
|
664
|
-
await this.
|
|
691
|
+
await this.mqtt.stopCard(this.device.deviceId)
|
|
665
692
|
}
|
|
666
693
|
} catch (error) {
|
|
667
694
|
this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set media state:`, error)
|
|
@@ -690,7 +717,7 @@ export class YotoPlayerAccessory {
|
|
|
690
717
|
this.log.debug(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Set volume:`, value)
|
|
691
718
|
|
|
692
719
|
try {
|
|
693
|
-
await this.
|
|
720
|
+
await this.mqtt.setVolume(this.device.deviceId, Number(value))
|
|
694
721
|
} catch (error) {
|
|
695
722
|
this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set volume:`, error)
|
|
696
723
|
throw new this.platform.api.hap.HapStatusError(
|
|
@@ -720,12 +747,12 @@ export class YotoPlayerAccessory {
|
|
|
720
747
|
try {
|
|
721
748
|
if (value) {
|
|
722
749
|
// Mute - set volume to 0
|
|
723
|
-
await this.
|
|
750
|
+
await this.mqtt.setVolume(this.device.deviceId, 0)
|
|
724
751
|
} else {
|
|
725
752
|
// Unmute - restore to a reasonable volume if currently 0
|
|
726
753
|
const currentVolume = this.currentStatus?.userVolume || 0
|
|
727
754
|
const targetVolume = currentVolume === 0 ? 50 : currentVolume
|
|
728
|
-
await this.
|
|
755
|
+
await this.mqtt.setVolume(this.device.deviceId, targetVolume)
|
|
729
756
|
}
|
|
730
757
|
} catch (error) {
|
|
731
758
|
this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set mute:`, error)
|
|
@@ -1014,10 +1041,10 @@ export class YotoPlayerAccessory {
|
|
|
1014
1041
|
try {
|
|
1015
1042
|
if (value === this.platform.Characteristic.Active.INACTIVE) {
|
|
1016
1043
|
// Turn off timer
|
|
1017
|
-
await this.
|
|
1044
|
+
await this.mqtt.setSleepTimer(this.device.deviceId, 0)
|
|
1018
1045
|
} else {
|
|
1019
1046
|
// Turn on with default duration (30 minutes)
|
|
1020
|
-
await this.
|
|
1047
|
+
await this.mqtt.setSleepTimer(this.device.deviceId, 30 * 60)
|
|
1021
1048
|
}
|
|
1022
1049
|
} catch (error) {
|
|
1023
1050
|
this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set sleep timer active:`, error)
|
|
@@ -1056,10 +1083,10 @@ export class YotoPlayerAccessory {
|
|
|
1056
1083
|
|
|
1057
1084
|
if (seconds === 0) {
|
|
1058
1085
|
// Turn off timer
|
|
1059
|
-
await this.
|
|
1086
|
+
await this.mqtt.setSleepTimer(this.device.deviceId, 0)
|
|
1060
1087
|
} else {
|
|
1061
1088
|
// Set timer with specified duration
|
|
1062
|
-
await this.
|
|
1089
|
+
await this.mqtt.setSleepTimer(this.device.deviceId, seconds)
|
|
1063
1090
|
}
|
|
1064
1091
|
} catch (error) {
|
|
1065
1092
|
this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set sleep timer:`, error)
|
|
@@ -1205,10 +1232,10 @@ export class YotoPlayerAccessory {
|
|
|
1205
1232
|
try {
|
|
1206
1233
|
if (value) {
|
|
1207
1234
|
// Turn on - set to white or previous color
|
|
1208
|
-
await this.
|
|
1235
|
+
await this.mqtt.setAmbientLight(this.device.deviceId, 255, 255, 255)
|
|
1209
1236
|
} else {
|
|
1210
1237
|
// Turn off - set to black
|
|
1211
|
-
await this.
|
|
1238
|
+
await this.mqtt.setAmbientLight(this.device.deviceId, 0, 0, 0)
|
|
1212
1239
|
}
|
|
1213
1240
|
} catch (error) {
|
|
1214
1241
|
this.log.error(LOG_PREFIX.ACCESSORY, `[${this.device.name}] Failed to set ambient light on:`, error)
|
|
@@ -1318,7 +1345,7 @@ export class YotoPlayerAccessory {
|
|
|
1318
1345
|
const { r, g, b } = this.hsvToRgb(h, s, v)
|
|
1319
1346
|
|
|
1320
1347
|
// Send MQTT command
|
|
1321
|
-
await this.
|
|
1348
|
+
await this.mqtt.setAmbientLight(this.device.deviceId, r, g, b)
|
|
1322
1349
|
|
|
1323
1350
|
// Clear pending values
|
|
1324
1351
|
this.pendingAmbientHue = undefined
|
package/lib/yotoApi.js
CHANGED
|
@@ -81,7 +81,8 @@ export class YotoApi {
|
|
|
81
81
|
}
|
|
82
82
|
} catch (error) {
|
|
83
83
|
this.log.error(LOG_PREFIX.API, 'Token refresh failed:', error)
|
|
84
|
-
|
|
84
|
+
// Throw specific error so platform can detect and restart auth flow
|
|
85
|
+
throw new Error('TOKEN_REFRESH_FAILED')
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
}
|
package/lib/yotoMqtt.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import mqtt from 'mqtt'
|
|
9
9
|
import {
|
|
10
10
|
YOTO_MQTT_BROKER_URL,
|
|
11
|
+
YOTO_MQTT_AUTH_NAME,
|
|
11
12
|
MQTT_RECONNECT_PERIOD,
|
|
12
13
|
MQTT_CONNECT_TIMEOUT,
|
|
13
14
|
MQTT_TOPIC_DATA_STATUS,
|
|
@@ -51,9 +52,10 @@ export class YotoMqtt {
|
|
|
51
52
|
/**
|
|
52
53
|
* Connect to MQTT broker
|
|
53
54
|
* @param {string} accessToken - Yoto access token for authentication
|
|
55
|
+
* @param {string} deviceId - Device ID for MQTT client identification
|
|
54
56
|
* @returns {Promise<void>}
|
|
55
57
|
*/
|
|
56
|
-
async connect (accessToken) {
|
|
58
|
+
async connect (accessToken, deviceId) {
|
|
57
59
|
if (this.client) {
|
|
58
60
|
this.log.warn(LOG_PREFIX.MQTT, 'Already connected, disconnecting first...')
|
|
59
61
|
await this.disconnect()
|
|
@@ -62,12 +64,19 @@ export class YotoMqtt {
|
|
|
62
64
|
return new Promise((resolve, reject) => {
|
|
63
65
|
this.log.info(LOG_PREFIX.MQTT, `Connecting to ${this.brokerUrl}...`)
|
|
64
66
|
|
|
67
|
+
const clientId = `homebridge-yoto-${deviceId}-${Date.now()}`
|
|
68
|
+
|
|
65
69
|
this.client = mqtt.connect(this.brokerUrl, {
|
|
66
|
-
|
|
67
|
-
|
|
70
|
+
keepalive: 300,
|
|
71
|
+
port: 443,
|
|
72
|
+
protocol: 'wss',
|
|
73
|
+
username: `${deviceId}?x-amz-customauthorizer-name=${YOTO_MQTT_AUTH_NAME}`,
|
|
74
|
+
password: accessToken,
|
|
68
75
|
reconnectPeriod: MQTT_RECONNECT_PERIOD,
|
|
69
76
|
connectTimeout: MQTT_CONNECT_TIMEOUT,
|
|
70
|
-
|
|
77
|
+
clientId,
|
|
78
|
+
clean: true,
|
|
79
|
+
ALPNProtocols: ['x-amzn-mqtt-ca']
|
|
71
80
|
})
|
|
72
81
|
|
|
73
82
|
this.client.on('connect', () => {
|
|
@@ -319,10 +328,10 @@ export class YotoMqtt {
|
|
|
319
328
|
return
|
|
320
329
|
}
|
|
321
330
|
|
|
322
|
-
if (topic.includes('/
|
|
331
|
+
if (topic.includes('/status')) {
|
|
323
332
|
this.log.debug(LOG_PREFIX.MQTT, `Status update for ${deviceId}`)
|
|
324
333
|
callbacks.onStatus?.(payload)
|
|
325
|
-
} else if (topic.includes('/
|
|
334
|
+
} else if (topic.includes('/events')) {
|
|
326
335
|
this.log.debug(LOG_PREFIX.MQTT, `Events update for ${deviceId}`)
|
|
327
336
|
callbacks.onEvents?.(payload)
|
|
328
337
|
} else if (topic.includes('/response')) {
|
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"
|