homebridge-yoto 0.0.28 → 0.0.32
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/README.md +18 -314
- package/config.schema.cjs +3 -0
- package/config.schema.json +19 -155
- package/homebridge-ui/public/client.js +428 -0
- package/homebridge-ui/public/index.html +138 -0
- package/homebridge-ui/server.js +264 -0
- package/index.js +1 -1
- package/lib/accessory.js +1870 -0
- package/lib/constants.js +8 -149
- package/lib/platform.js +303 -363
- package/lib/sanitize-name.js +49 -0
- package/lib/settings.js +16 -0
- package/lib/sync-service-names.js +34 -0
- package/package.json +28 -22
- package/.github/dependabot.yml +0 -18
- package/.github/funding.yml +0 -4
- package/.github/workflows/release.yml +0 -41
- package/.github/workflows/tests.yml +0 -37
- package/AGENTS.md +0 -253
- package/CHANGELOG.md +0 -8
- package/CONTRIBUTING.md +0 -34
- package/PLAN.md +0 -609
- package/declaration.tsconfig.json +0 -15
- package/eslint.config.js +0 -7
- package/index.test.js +0 -7
- package/lib/auth.js +0 -237
- package/lib/playerAccessory.js +0 -1724
- package/lib/types.js +0 -253
- package/lib/yotoApi.js +0 -270
- package/lib/yotoMqtt.js +0 -570
- package/tsconfig.json +0 -14
package/lib/auth.js
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview OAuth2 Device Authorization Flow implementation for Yoto API
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/** @import { Logger } from 'homebridge' */
|
|
6
|
-
/** @import { YotoApiTokenResponse, YotoApiDeviceCodeResponse } from './types.js' */
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
YOTO_OAUTH_DEVICE_CODE_URL,
|
|
10
|
-
YOTO_OAUTH_TOKEN_URL,
|
|
11
|
-
OAUTH_CLIENT_ID,
|
|
12
|
-
OAUTH_AUDIENCE,
|
|
13
|
-
OAUTH_SCOPE,
|
|
14
|
-
OAUTH_POLLING_INTERVAL,
|
|
15
|
-
OAUTH_DEVICE_CODE_TIMEOUT,
|
|
16
|
-
ERROR_MESSAGES,
|
|
17
|
-
LOG_PREFIX
|
|
18
|
-
} from './constants.js'
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* OAuth2 authentication handler for Yoto API
|
|
22
|
-
*/
|
|
23
|
-
export class YotoAuth {
|
|
24
|
-
/**
|
|
25
|
-
* @param {Logger} log - Homebridge logger
|
|
26
|
-
* @param {string} [clientId] - OAuth client ID (uses default if not provided)
|
|
27
|
-
*/
|
|
28
|
-
constructor (log, clientId) {
|
|
29
|
-
this.log = log
|
|
30
|
-
this.clientId = clientId || OAUTH_CLIENT_ID
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Initiate device authorization flow
|
|
35
|
-
* @returns {Promise<YotoApiDeviceCodeResponse>}
|
|
36
|
-
*/
|
|
37
|
-
async initiateDeviceFlow () {
|
|
38
|
-
this.log.debug(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}`)
|
|
43
|
-
|
|
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
|
-
|
|
52
|
-
const response = await fetch(YOTO_OAUTH_DEVICE_CODE_URL, {
|
|
53
|
-
method: 'POST',
|
|
54
|
-
headers: {
|
|
55
|
-
'Content-Type': 'application/x-www-form-urlencoded'
|
|
56
|
-
},
|
|
57
|
-
body: params
|
|
58
|
-
})
|
|
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
|
-
|
|
63
|
-
if (!response.ok) {
|
|
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}`)
|
|
67
|
-
throw new Error(`Device code request failed: ${response.status} ${errorText}`)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const data = /** @type {YotoApiDeviceCodeResponse} */ (await response.json())
|
|
71
|
-
|
|
72
|
-
this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
|
|
73
|
-
this.log.info(LOG_PREFIX.AUTH, 'YOTO AUTHENTICATION REQUIRED')
|
|
74
|
-
this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
|
|
75
|
-
this.log.info(LOG_PREFIX.AUTH, '')
|
|
76
|
-
this.log.info(LOG_PREFIX.AUTH, `1. Visit: ${data.verification_uri}`)
|
|
77
|
-
this.log.info(LOG_PREFIX.AUTH, `2. Enter code: ${data.user_code}`)
|
|
78
|
-
this.log.info(LOG_PREFIX.AUTH, '')
|
|
79
|
-
this.log.info(LOG_PREFIX.AUTH, `Or visit: ${data.verification_uri_complete}`)
|
|
80
|
-
this.log.info(LOG_PREFIX.AUTH, '')
|
|
81
|
-
this.log.info(LOG_PREFIX.AUTH, `Code expires in ${Math.floor(data.expires_in / 60)} minutes`)
|
|
82
|
-
this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
|
|
83
|
-
|
|
84
|
-
return data
|
|
85
|
-
} catch (error) {
|
|
86
|
-
this.log.error(LOG_PREFIX.AUTH, 'Failed to initiate device flow:', error)
|
|
87
|
-
throw error
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Poll for authorization completion
|
|
93
|
-
* @param {string} deviceCode - Device code from initiation
|
|
94
|
-
* @returns {Promise<YotoApiTokenResponse>}
|
|
95
|
-
*/
|
|
96
|
-
async pollForAuthorization (deviceCode) {
|
|
97
|
-
const startTime = Date.now()
|
|
98
|
-
const timeout = OAUTH_DEVICE_CODE_TIMEOUT
|
|
99
|
-
|
|
100
|
-
this.log.debug(LOG_PREFIX.AUTH, 'Waiting for user authorization...')
|
|
101
|
-
|
|
102
|
-
while (Date.now() - startTime < timeout) {
|
|
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
|
-
|
|
111
|
-
const response = await fetch(YOTO_OAUTH_TOKEN_URL, {
|
|
112
|
-
method: 'POST',
|
|
113
|
-
headers: {
|
|
114
|
-
'Content-Type': 'application/x-www-form-urlencoded'
|
|
115
|
-
},
|
|
116
|
-
body: params
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
if (response.ok) {
|
|
120
|
-
const tokenData = /** @type {YotoApiTokenResponse} */ (await response.json())
|
|
121
|
-
this.log.info(LOG_PREFIX.AUTH, '✓ Authorization successful!')
|
|
122
|
-
return tokenData
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const errorData = /** @type {any} */ (await response.json().catch(() => ({})))
|
|
126
|
-
|
|
127
|
-
// Handle specific OAuth errors
|
|
128
|
-
if (errorData.error === 'authorization_pending') {
|
|
129
|
-
// Still waiting for user to authorize
|
|
130
|
-
await this.sleep(OAUTH_POLLING_INTERVAL)
|
|
131
|
-
continue
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (errorData.error === 'slow_down') {
|
|
135
|
-
// Server wants us to slow down polling
|
|
136
|
-
await this.sleep(OAUTH_POLLING_INTERVAL * 2)
|
|
137
|
-
continue
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (errorData.error === 'expired_token') {
|
|
141
|
-
throw new Error('Device code expired. Please restart the authorization process.')
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (errorData.error === 'access_denied') {
|
|
145
|
-
throw new Error('Authorization was denied by the user.')
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Unknown error
|
|
149
|
-
throw new Error(`Authorization failed: ${errorData.error || response.statusText}`)
|
|
150
|
-
} catch (error) {
|
|
151
|
-
if (error instanceof Error && error.message.includes('expired')) {
|
|
152
|
-
throw error
|
|
153
|
-
}
|
|
154
|
-
this.log.debug(LOG_PREFIX.AUTH, 'Polling error:', error)
|
|
155
|
-
await this.sleep(OAUTH_POLLING_INTERVAL)
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
throw new Error('Authorization timed out. Please try again.')
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Refresh access token using refresh token
|
|
164
|
-
* @param {string} refreshToken - Refresh token
|
|
165
|
-
* @returns {Promise<YotoApiTokenResponse>}
|
|
166
|
-
*/
|
|
167
|
-
async refreshAccessToken (refreshToken) {
|
|
168
|
-
this.log.debug(LOG_PREFIX.AUTH, 'Refreshing access token...')
|
|
169
|
-
|
|
170
|
-
try {
|
|
171
|
-
const params = new URLSearchParams({
|
|
172
|
-
grant_type: 'refresh_token',
|
|
173
|
-
refresh_token: refreshToken,
|
|
174
|
-
client_id: this.clientId
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
const response = await fetch(YOTO_OAUTH_TOKEN_URL, {
|
|
178
|
-
method: 'POST',
|
|
179
|
-
headers: {
|
|
180
|
-
'Content-Type': 'application/x-www-form-urlencoded'
|
|
181
|
-
},
|
|
182
|
-
body: params
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
if (!response.ok) {
|
|
186
|
-
const errorText = await response.text()
|
|
187
|
-
throw new Error(`Token refresh failed: ${response.status} ${errorText}`)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const tokenData = /** @type {YotoApiTokenResponse} */ (await response.json())
|
|
191
|
-
this.log.info(LOG_PREFIX.AUTH, '✓ Token refreshed successfully')
|
|
192
|
-
return tokenData
|
|
193
|
-
} catch (error) {
|
|
194
|
-
this.log.error(LOG_PREFIX.AUTH, ERROR_MESSAGES.TOKEN_REFRESH_FAILED, error)
|
|
195
|
-
throw error
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Complete device authorization flow
|
|
201
|
-
* @returns {Promise<YotoApiTokenResponse>}
|
|
202
|
-
*/
|
|
203
|
-
async authorize () {
|
|
204
|
-
const deviceCodeResponse = await this.initiateDeviceFlow()
|
|
205
|
-
const tokenResponse = await this.pollForAuthorization(deviceCodeResponse.device_code)
|
|
206
|
-
return tokenResponse
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Check if token is expired or expiring soon
|
|
211
|
-
* @param {number} expiresAt - Token expiration timestamp (seconds since epoch)
|
|
212
|
-
* @param {number} bufferSeconds - Seconds before expiry to consider expired
|
|
213
|
-
* @returns {boolean}
|
|
214
|
-
*/
|
|
215
|
-
isTokenExpired (expiresAt, bufferSeconds = 300) {
|
|
216
|
-
const now = Math.floor(Date.now() / 1000)
|
|
217
|
-
return expiresAt <= now + bufferSeconds
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Calculate token expiration timestamp
|
|
222
|
-
* @param {number} expiresIn - Seconds until token expires
|
|
223
|
-
* @returns {number} - Unix timestamp when token expires
|
|
224
|
-
*/
|
|
225
|
-
calculateExpiresAt (expiresIn) {
|
|
226
|
-
return Math.floor(Date.now() / 1000) + expiresIn
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Sleep for specified milliseconds
|
|
231
|
-
* @param {number} ms - Milliseconds to sleep
|
|
232
|
-
* @returns {Promise<void>}
|
|
233
|
-
*/
|
|
234
|
-
sleep (ms) {
|
|
235
|
-
return new Promise(resolve => setTimeout(resolve, ms))
|
|
236
|
-
}
|
|
237
|
-
}
|