homebridge-yoto 0.0.27 → 0.0.31
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/AGENTS.md +42 -157
- package/CHANGELOG.md +13 -5
- package/NOTES.md +87 -0
- package/PLAN.md +320 -504
- package/README.md +18 -314
- package/config.schema.cjs +3 -0
- package/config.schema.json +19 -155
- package/homebridge-ui/server.js +264 -0
- package/index.js +1 -1
- package/index.test.js +1 -1
- package/lib/accessory.js +1870 -0
- package/lib/constants.js +8 -149
- package/lib/platform.js +303 -364
- package/lib/sanitize-name.js +49 -0
- package/lib/settings.js +16 -0
- package/lib/sync-service-names.js +34 -0
- package/logo.png +0 -0
- package/package.json +17 -22
- package/pnpm-workspace.yaml +4 -0
- package/declaration.tsconfig.json +0 -15
- 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
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Custom UI server for Yoto Homebridge plugin OAuth authentication
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { HomebridgePluginUiServer, RequestError } from '@homebridge/plugin-ui-utils'
|
|
6
|
+
import { YotoClient } from 'yoto-nodejs-client'
|
|
7
|
+
import { DEFAULT_CLIENT_ID } from '../lib/settings.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Custom UI server for Yoto plugin OAuth authentication
|
|
11
|
+
* @extends {HomebridgePluginUiServer}
|
|
12
|
+
*/
|
|
13
|
+
class YotoUiServer extends HomebridgePluginUiServer {
|
|
14
|
+
constructor () {
|
|
15
|
+
// super() MUST be called first
|
|
16
|
+
super()
|
|
17
|
+
|
|
18
|
+
// Register OAuth endpoints
|
|
19
|
+
this.onRequest('/auth/config', getAuthConfig)
|
|
20
|
+
this.onRequest('/auth/start', startDeviceFlow)
|
|
21
|
+
this.onRequest('/auth/poll', pollForToken)
|
|
22
|
+
|
|
23
|
+
// this MUST be called when you are ready to accept requests
|
|
24
|
+
this.ready()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Create and start the server
|
|
29
|
+
(() => new YotoUiServer())()
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Response from /auth/config endpoint
|
|
33
|
+
* @typedef {Object} AuthConfigResponse
|
|
34
|
+
* @property {string} defaultClientId - The default OAuth client ID
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get authentication configuration
|
|
39
|
+
* @returns {Promise<AuthConfigResponse>}
|
|
40
|
+
*/
|
|
41
|
+
async function getAuthConfig () {
|
|
42
|
+
return {
|
|
43
|
+
defaultClientId: DEFAULT_CLIENT_ID
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Request payload for /auth/start endpoint
|
|
49
|
+
* @typedef {Object} AuthStartRequest
|
|
50
|
+
* @property {string} [clientId] - OAuth client ID from config (optional, falls back to DEFAULT_CLIENT_ID)
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Response from /auth/start endpoint
|
|
55
|
+
* @typedef {Object} AuthStartResponse
|
|
56
|
+
* @property {string} verification_uri - Base URL for user verification (e.g., https://yotoplay.com/activate)
|
|
57
|
+
* @property {string} verification_uri_complete - Complete URL with code pre-filled
|
|
58
|
+
* @property {string} user_code - User code to enter at verification_uri
|
|
59
|
+
* @property {string} device_code - Device code for polling (not shown to user)
|
|
60
|
+
* @property {number} expires_in - Seconds until code expires (typically 900)
|
|
61
|
+
* @property {number} interval - Recommended polling interval in seconds
|
|
62
|
+
* @property {string} client_id - OAuth client ID used for this flow
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Start OAuth device flow
|
|
67
|
+
* @param {AuthStartRequest} payload - Request with optional client ID
|
|
68
|
+
* @returns {Promise<AuthStartResponse>}
|
|
69
|
+
*/
|
|
70
|
+
async function startDeviceFlow (payload) {
|
|
71
|
+
console.log('[Server] startDeviceFlow called with payload:', JSON.stringify(redactSensitive(payload), null, 2))
|
|
72
|
+
try {
|
|
73
|
+
const clientId = payload.clientId || DEFAULT_CLIENT_ID
|
|
74
|
+
console.log('[Server] Using clientId:', clientId)
|
|
75
|
+
|
|
76
|
+
// Request device code from Yoto
|
|
77
|
+
console.log('[Server] Requesting device code from Yoto API...')
|
|
78
|
+
const deviceCodeResponse = await YotoClient.requestDeviceCode({
|
|
79
|
+
clientId,
|
|
80
|
+
scope: 'openid profile offline_access',
|
|
81
|
+
audience: 'https://api.yotoplay.com'
|
|
82
|
+
})
|
|
83
|
+
console.log('[Server] Device code response:', JSON.stringify(redactSensitive(deviceCodeResponse), null, 2))
|
|
84
|
+
|
|
85
|
+
// Return the device flow info to the UI
|
|
86
|
+
const result = {
|
|
87
|
+
verification_uri: deviceCodeResponse.verification_uri || '',
|
|
88
|
+
verification_uri_complete: deviceCodeResponse.verification_uri_complete || '',
|
|
89
|
+
user_code: deviceCodeResponse.user_code || '',
|
|
90
|
+
device_code: deviceCodeResponse.device_code || '',
|
|
91
|
+
expires_in: deviceCodeResponse.expires_in || 900,
|
|
92
|
+
interval: deviceCodeResponse.interval || 5,
|
|
93
|
+
client_id: clientId
|
|
94
|
+
}
|
|
95
|
+
console.log('[Server] startDeviceFlow returning:', JSON.stringify(result, null, 2))
|
|
96
|
+
return result
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error('[Server] startDeviceFlow error:', error)
|
|
99
|
+
throw new RequestError('Failed to start device flow', {
|
|
100
|
+
message: error instanceof Error ? error.message : 'Unknown error'
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Request payload for /auth/poll endpoint
|
|
107
|
+
* @typedef {Object} AuthPollRequest
|
|
108
|
+
* @property {string} deviceCode - Device code from AuthStartResponse
|
|
109
|
+
* @property {string} clientId - OAuth client ID used in auth/start
|
|
110
|
+
*/
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Response from /auth/poll endpoint when still pending
|
|
114
|
+
* @typedef {Object} AuthPollPendingResponse
|
|
115
|
+
* @property {true} pending - Indicates authorization still pending
|
|
116
|
+
* @property {string} message - Status message (e.g., "Waiting for authorization...")
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Response from /auth/poll endpoint when need to slow down
|
|
121
|
+
* @typedef {Object} AuthPollSlowDownResponse
|
|
122
|
+
* @property {true} slow_down - Indicates polling too fast
|
|
123
|
+
* @property {string} message - Status message about slowing down
|
|
124
|
+
* @property {number} [interval] - Updated polling interval (in seconds)
|
|
125
|
+
*/
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Response from /auth/poll endpoint on success
|
|
129
|
+
* @typedef {Object} AuthPollSuccessResponse
|
|
130
|
+
* @property {true} success - Indicates successful authentication
|
|
131
|
+
* @property {string} message - Success message
|
|
132
|
+
* @property {string} refreshToken - OAuth refresh token (long-lived)
|
|
133
|
+
* @property {string} accessToken - OAuth access token (short-lived)
|
|
134
|
+
* @property {number} tokenExpiresAt - Unix timestamp when access token expires
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Union type for all possible /auth/poll responses
|
|
139
|
+
* @typedef {AuthPollPendingResponse | AuthPollSlowDownResponse | AuthPollSuccessResponse} AuthPollResponse
|
|
140
|
+
*/
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Poll for token exchange
|
|
144
|
+
* @param {AuthPollRequest} payload - Request payload with device code and client ID
|
|
145
|
+
* @returns {Promise<AuthPollResponse>}
|
|
146
|
+
*/
|
|
147
|
+
async function pollForToken (payload) {
|
|
148
|
+
console.log('[Server] pollForToken called with payload:', JSON.stringify(redactSensitive(payload), null, 2))
|
|
149
|
+
const { deviceCode, clientId } = payload
|
|
150
|
+
|
|
151
|
+
if (!deviceCode || !clientId) {
|
|
152
|
+
console.error('[Server] Missing deviceCode or clientId')
|
|
153
|
+
throw new RequestError('Missing required parameters', {
|
|
154
|
+
message: 'deviceCode and clientId are required'
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
// Poll for device token using helper function
|
|
160
|
+
console.log('[Server] Polling for device token...')
|
|
161
|
+
const pollResult = await YotoClient.pollForDeviceToken({
|
|
162
|
+
deviceCode,
|
|
163
|
+
clientId,
|
|
164
|
+
audience: 'https://api.yotoplay.com'
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// Check if authorization is still pending
|
|
168
|
+
if (pollResult.status === 'pending') {
|
|
169
|
+
console.log('[Server] Authorization still pending...')
|
|
170
|
+
/** @type {AuthPollPendingResponse} */
|
|
171
|
+
const pendingResult = {
|
|
172
|
+
pending: true,
|
|
173
|
+
message: 'Waiting for authorization...'
|
|
174
|
+
}
|
|
175
|
+
return pendingResult
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Check if we need to slow down polling
|
|
179
|
+
if (pollResult.status === 'slow_down') {
|
|
180
|
+
const intervalSeconds = pollResult.interval / 1000 // pollResult.interval is in milliseconds, convert to seconds
|
|
181
|
+
console.log('[Server] Polling too fast, slowing down to interval:', intervalSeconds, 'seconds')
|
|
182
|
+
/** @type {AuthPollSlowDownResponse} */
|
|
183
|
+
const slowDownResult = {
|
|
184
|
+
slow_down: true,
|
|
185
|
+
message: 'Polling too fast, slowing down...',
|
|
186
|
+
interval: intervalSeconds // Client expects seconds
|
|
187
|
+
}
|
|
188
|
+
return slowDownResult
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Success - we got tokens (status === 'success')
|
|
192
|
+
console.log('[Server] Token exchange successful!')
|
|
193
|
+
|
|
194
|
+
// Validate required token fields
|
|
195
|
+
if (!pollResult.tokens.refresh_token || !pollResult.tokens.access_token) {
|
|
196
|
+
throw new Error('Token response missing required fields')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Calculate token expiration
|
|
200
|
+
const tokenExpiresAt = Date.now() + (pollResult.tokens.expires_in * 1000)
|
|
201
|
+
|
|
202
|
+
// Return tokens to client for saving
|
|
203
|
+
/** @type {AuthPollSuccessResponse} */
|
|
204
|
+
const result = {
|
|
205
|
+
success: true,
|
|
206
|
+
message: 'Authentication successful!',
|
|
207
|
+
refreshToken: pollResult.tokens.refresh_token,
|
|
208
|
+
accessToken: pollResult.tokens.access_token,
|
|
209
|
+
tokenExpiresAt
|
|
210
|
+
}
|
|
211
|
+
console.log('[Server] pollForToken success, returning tokens (redacted)')
|
|
212
|
+
return result
|
|
213
|
+
} catch (error) {
|
|
214
|
+
// Handle errors from pollForDeviceToken
|
|
215
|
+
const err = /** @type {any} */ (error)
|
|
216
|
+
const errorCode = err.body?.error
|
|
217
|
+
const errorDescription = err.body?.error_description
|
|
218
|
+
|
|
219
|
+
if (errorCode === 'expired_token') {
|
|
220
|
+
console.error('[Server] Device code expired')
|
|
221
|
+
throw new RequestError('Device code expired', {
|
|
222
|
+
message: 'The authorization code has expired. Please start over.'
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (errorCode === 'access_denied') {
|
|
227
|
+
console.error('[Server] Access denied by user')
|
|
228
|
+
throw new RequestError('Access denied', {
|
|
229
|
+
message: 'Authorization was denied. Please try again.'
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Unexpected error - log full details
|
|
234
|
+
console.error('[Server] Unexpected error during token poll:', error)
|
|
235
|
+
console.error('[Server] Error code:', errorCode)
|
|
236
|
+
console.error('[Server] Error description:', errorDescription)
|
|
237
|
+
const errorMessage = errorDescription || (error instanceof Error ? error.message : String(error))
|
|
238
|
+
throw new RequestError('Token exchange failed', {
|
|
239
|
+
message: errorMessage || 'Unknown error occurred'
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Redact sensitive data from objects for logging
|
|
246
|
+
* @param {any} obj - Object to redact
|
|
247
|
+
* @returns {any} Redacted copy
|
|
248
|
+
*/
|
|
249
|
+
function redactSensitive (obj) {
|
|
250
|
+
if (!obj || typeof obj !== 'object') return obj
|
|
251
|
+
|
|
252
|
+
const redacted = Array.isArray(obj) ? [...obj] : { ...obj }
|
|
253
|
+
const sensitiveKeys = ['accessToken', 'refreshToken', 'access_token', 'refresh_token', 'token', 'deviceCode', 'device_code']
|
|
254
|
+
|
|
255
|
+
for (const key in redacted) {
|
|
256
|
+
if (sensitiveKeys.includes(key) && redacted[key]) {
|
|
257
|
+
redacted[key] = '[REDACTED]'
|
|
258
|
+
} else if (key === 'config' && typeof redacted[key] === 'object') {
|
|
259
|
+
redacted[key] = redactSensitive(redacted[key])
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return redacted
|
|
264
|
+
}
|
package/index.js
CHANGED
package/index.test.js
CHANGED