homebridge-yoto 0.0.5 → 0.0.7

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 CHANGED
@@ -36,22 +36,34 @@ export class YotoAuth {
36
36
  */
37
37
  async initiateDeviceFlow () {
38
38
  this.log.info(LOG_PREFIX.AUTH, 'Initiating device authorization flow...')
39
+ this.log.debug(LOG_PREFIX.AUTH, `Client ID: ${this.clientId}`)
40
+ this.log.debug(LOG_PREFIX.AUTH, `Endpoint: ${YOTO_OAUTH_DEVICE_CODE_URL}`)
41
+ this.log.debug(LOG_PREFIX.AUTH, `Scope: ${OAUTH_SCOPE}`)
42
+ this.log.debug(LOG_PREFIX.AUTH, `Audience: ${OAUTH_AUDIENCE}`)
39
43
 
40
44
  try {
45
+ const params = new URLSearchParams({
46
+ client_id: this.clientId,
47
+ scope: OAUTH_SCOPE,
48
+ audience: OAUTH_AUDIENCE
49
+ })
50
+ this.log.debug(LOG_PREFIX.AUTH, 'Request body:', params.toString())
51
+
41
52
  const response = await fetch(YOTO_OAUTH_DEVICE_CODE_URL, {
42
53
  method: 'POST',
43
54
  headers: {
44
- 'Content-Type': 'application/json'
55
+ 'Content-Type': 'application/x-www-form-urlencoded'
45
56
  },
46
- body: JSON.stringify({
47
- client_id: this.clientId,
48
- scope: OAUTH_SCOPE,
49
- audience: OAUTH_AUDIENCE
50
- })
57
+ body: params
51
58
  })
52
59
 
60
+ this.log.debug(LOG_PREFIX.AUTH, `Response status: ${response.status}`)
61
+ this.log.debug(LOG_PREFIX.AUTH, 'Response headers:', Object.fromEntries(response.headers.entries()))
62
+
53
63
  if (!response.ok) {
54
64
  const errorText = await response.text()
65
+ this.log.error(LOG_PREFIX.AUTH, `Device code request failed with status ${response.status}`)
66
+ this.log.error(LOG_PREFIX.AUTH, `Error response: ${errorText}`)
55
67
  throw new Error(`Device code request failed: ${response.status} ${errorText}`)
56
68
  }
57
69
 
@@ -89,16 +101,19 @@ export class YotoAuth {
89
101
 
90
102
  while (Date.now() - startTime < timeout) {
91
103
  try {
104
+ const params = new URLSearchParams({
105
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
106
+ device_code: deviceCode,
107
+ client_id: this.clientId,
108
+ audience: OAUTH_AUDIENCE
109
+ })
110
+
92
111
  const response = await fetch(YOTO_OAUTH_TOKEN_URL, {
93
112
  method: 'POST',
94
113
  headers: {
95
- 'Content-Type': 'application/json'
114
+ 'Content-Type': 'application/x-www-form-urlencoded'
96
115
  },
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
- })
116
+ body: params
102
117
  })
103
118
 
104
119
  if (response.ok) {
@@ -153,16 +168,18 @@ export class YotoAuth {
153
168
  this.log.debug(LOG_PREFIX.AUTH, 'Refreshing access token...')
154
169
 
155
170
  try {
171
+ const params = new URLSearchParams({
172
+ grant_type: 'refresh_token',
173
+ refresh_token: refreshToken,
174
+ client_id: this.clientId
175
+ })
176
+
156
177
  const response = await fetch(YOTO_OAUTH_TOKEN_URL, {
157
178
  method: 'POST',
158
179
  headers: {
159
- 'Content-Type': 'application/json'
180
+ 'Content-Type': 'application/x-www-form-urlencoded'
160
181
  },
161
- body: JSON.stringify({
162
- grant_type: 'refresh_token',
163
- refresh_token: refreshToken,
164
- client_id: this.clientId
165
- })
182
+ body: params
166
183
  })
167
184
 
168
185
  if (!response.ok) {
package/lib/constants.js CHANGED
@@ -12,40 +12,42 @@ export const PLUGIN_NAME = 'homebridge-yoto'
12
12
  * Yoto API endpoints
13
13
  */
14
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`
15
+ export const YOTO_OAUTH_BASE_URL = 'https://login.yotoplay.com'
16
+ export const YOTO_OAUTH_AUTHORIZE_URL = `${YOTO_OAUTH_BASE_URL}/authorize`
17
+ export const YOTO_OAUTH_TOKEN_URL = `${YOTO_OAUTH_BASE_URL}/oauth/token`
18
+ export const YOTO_OAUTH_DEVICE_CODE_URL = `${YOTO_OAUTH_BASE_URL}/oauth/device/code`
18
19
 
19
20
  /**
20
21
  * MQTT configuration
21
22
  */
22
23
  export const YOTO_MQTT_BROKER_URL = 'wss://aqrphjqbp3u2z-ats.iot.eu-west-2.amazonaws.com'
24
+ export const YOTO_MQTT_AUTH_NAME = 'PublicJWTAuthorizer'
23
25
  export const MQTT_RECONNECT_PERIOD = 5000 // milliseconds
24
26
  export const MQTT_CONNECT_TIMEOUT = 30000 // milliseconds
25
27
 
26
28
  /**
27
29
  * MQTT topic templates
28
30
  */
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'
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'
42
44
 
43
45
  /**
44
46
  * OAuth configuration
45
47
  */
46
48
  export const OAUTH_CLIENT_ID = 'Y4HJ8BFqRQ24GQoLzgOzZ2KSqWmFG8LI'
47
49
  export const OAUTH_AUDIENCE = 'https://api.yotoplay.com'
48
- export const OAUTH_SCOPE = 'profile offline_access openid'
50
+ export const OAUTH_SCOPE = 'openid profile offline_access'
49
51
  export const OAUTH_POLLING_INTERVAL = 5000 // milliseconds
50
52
  export const OAUTH_DEVICE_CODE_TIMEOUT = 300000 // 5 minutes
51
53
 
package/lib/platform.js CHANGED
@@ -71,23 +71,7 @@ export class YotoPlatform {
71
71
  try {
72
72
  // Check if we have stored credentials
73
73
  if (!this.config.accessToken || !this.config.refreshToken) {
74
- this.log.warn(LOG_PREFIX.PLATFORM, ERROR_MESSAGES.NO_AUTH)
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))
74
+ await this.performDeviceFlow()
91
75
  }
92
76
 
93
77
  // Set tokens in API client
@@ -97,16 +81,70 @@ export class YotoPlatform {
97
81
  this.config.tokenExpiresAt || 0
98
82
  )
99
83
 
100
- // Connect to MQTT
101
- await this.yotoMqtt.connect(this.config.accessToken)
102
-
103
84
  // Discover and register devices
104
- await this.discoverDevices()
85
+ try {
86
+ await this.discoverDevices()
87
+ } catch (error) {
88
+ // Check if this is an auth error that requires re-authentication
89
+ if (error instanceof Error && error.message.includes('TOKEN_REFRESH_FAILED')) {
90
+ this.log.warn(LOG_PREFIX.PLATFORM, 'Token refresh failed, clearing tokens and restarting auth flow...')
91
+ await this.clearTokensAndReauth()
92
+ return
93
+ }
94
+ throw error
95
+ }
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
105
  } catch (error) {
106
106
  this.log.error(LOG_PREFIX.PLATFORM, 'Initialization failed:', error)
107
107
  }
108
108
  }
109
109
 
110
+ /**
111
+ * Perform device authorization flow
112
+ * @returns {Promise<void>}
113
+ */
114
+ async performDeviceFlow () {
115
+ this.log.warn(LOG_PREFIX.PLATFORM, ERROR_MESSAGES.NO_AUTH)
116
+ this.log.info(LOG_PREFIX.PLATFORM, 'Starting OAuth flow...')
117
+
118
+ const tokenResponse = await this.auth.authorize()
119
+
120
+ // Store tokens in config
121
+ this.config.accessToken = tokenResponse.access_token
122
+ this.config.refreshToken = tokenResponse.refresh_token || ''
123
+ this.config.tokenExpiresAt = this.auth.calculateExpiresAt(tokenResponse.expires_in)
124
+
125
+ // Save tokens to config file
126
+ await this.saveConfig()
127
+
128
+ this.log.info(LOG_PREFIX.PLATFORM, '✓ Authentication successful and saved to config!')
129
+ }
130
+
131
+ /**
132
+ * Clear invalid tokens and restart authentication
133
+ * @returns {Promise<void>}
134
+ */
135
+ async clearTokensAndReauth () {
136
+ // Clear tokens from config
137
+ this.config.accessToken = ''
138
+ this.config.refreshToken = ''
139
+ this.config.tokenExpiresAt = 0
140
+
141
+ // Save cleared config
142
+ await this.saveConfig()
143
+
144
+ // Restart initialization
145
+ await this.initialize()
146
+ }
147
+
110
148
  /**
111
149
  * Handle token refresh - update config
112
150
  * @param {string} accessToken - New access token
@@ -114,17 +152,50 @@ export class YotoPlatform {
114
152
  * @param {number} expiresAt - New expiration timestamp
115
153
  */
116
154
  handleTokenRefresh (accessToken, refreshToken, expiresAt) {
117
- this.log.info(LOG_PREFIX.PLATFORM, 'Token refreshed, please update your config')
155
+ this.log.info(LOG_PREFIX.PLATFORM, 'Token refreshed')
118
156
  this.config.accessToken = accessToken
119
157
  this.config.refreshToken = refreshToken
120
158
  this.config.tokenExpiresAt = expiresAt
121
159
 
122
- // Update MQTT connection with new token
123
- this.yotoMqtt.disconnect().then(() => {
124
- return this.yotoMqtt.connect(accessToken)
125
- }).catch(error => {
126
- this.log.error(LOG_PREFIX.PLATFORM, 'Failed to reconnect MQTT after token refresh:', error)
160
+ // Save updated tokens to config file
161
+ this.saveConfig().catch(error => {
162
+ this.log.error(LOG_PREFIX.PLATFORM, 'Failed to save refreshed tokens:', error)
127
163
  })
164
+
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
+ })
175
+ }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Save config to disk using Homebridge API
181
+ * @returns {Promise<void>}
182
+ */
183
+ async saveConfig () {
184
+ 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)')
194
+ }
195
+ } catch (error) {
196
+ this.log.error(LOG_PREFIX.PLATFORM, 'Failed to save config:', error)
197
+ throw error
198
+ }
128
199
  }
129
200
 
130
201
  /**
@@ -93,8 +93,8 @@ export class YotoPlayerAccessory {
93
93
  this.activeContentInfo = null
94
94
  }
95
95
 
96
- // Subscribe to MQTT updates
97
- this.subscribeMqtt()
96
+ // Connect MQTT for this device
97
+ this.connectMqtt()
98
98
  }
99
99
 
100
100
  /**
@@ -385,6 +385,30 @@ export class YotoPlayerAccessory {
385
385
  .onSet(this.setAmbientLightBrightness.bind(this))
386
386
  }
387
387
 
388
+ /**
389
+ * Connect MQTT for this device
390
+ */
391
+ async connectMqtt () {
392
+ try {
393
+ // Ensure we have an access token
394
+ if (!this.platform.config.accessToken) {
395
+ this.log.warn(LOG_PREFIX.ACCESSORY, `[${this.device.name}] No access token available for MQTT connection`)
396
+ return
397
+ }
398
+
399
+ // Connect MQTT with device ID and access token
400
+ await this.platform.yotoMqtt.connect(
401
+ this.platform.config.accessToken,
402
+ this.device.deviceId
403
+ )
404
+
405
+ // 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
+
388
412
  /**
389
413
  * Subscribe to MQTT updates for this device
390
414
  */
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
- throw error
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
- username: accessToken,
67
- password: '',
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
- clean: true
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('/data/status')) {
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('/data/events')) {
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.5",
4
+ "version": "0.0.7",
5
5
  "author": "Bret Comnes <bcomnes@gmail.com> (https://bret.io)",
6
6
  "bugs": {
7
7
  "url": "https://github.com/bcomnes/homebridge-yoto/issues"