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.
@@ -0,0 +1,428 @@
1
+ /// <reference lib="dom" />
2
+ /* eslint-env browser */
3
+
4
+ /**
5
+ * @fileoverview Client-side UI logic for Yoto Homebridge plugin OAuth authentication
6
+ */
7
+
8
+ /** @import {IHomebridgePluginUi} from '@homebridge/plugin-ui-utils/ui.interface' */
9
+ /** @import { AuthConfigResponse, AuthStartResponse, AuthPollResponse, AuthPollSlowDownResponse } from '../server.js' */
10
+
11
+ /**
12
+ * @global
13
+ * @type {IHomebridgePluginUi}
14
+ */
15
+ const homebridge = window.homebridge
16
+
17
+ /**
18
+ * @typedef {Object} YotoConfig
19
+ * @property {string} [clientId] - OAuth client ID
20
+ * @property {string} [refreshToken] - Stored refresh token
21
+ * @property {string} [accessToken] - Stored access token
22
+ * @property {number} [tokenExpiresAt] - Token expiration timestamp
23
+ */
24
+
25
+ // State variables
26
+ /** @type {ReturnType<typeof setInterval> | null} */
27
+ let pollingInterval = null
28
+ /** @type {ReturnType<typeof setInterval> | null} */
29
+ let countdownInterval = null
30
+ /** @type {string | null} */
31
+ let deviceCode = null
32
+ /** @type {string | null} */
33
+ let clientId = null
34
+ let pollIntervalSeconds = 5
35
+ /** @type {YotoConfig[]} */
36
+ let pluginConfig = []
37
+ /** @type {string | null} */
38
+ let defaultClientId = null
39
+
40
+ /**
41
+ * Initialize UI when ready
42
+ */
43
+ async function initializeUI () {
44
+ // Button click handlers
45
+ const startAuthBtn = document.getElementById('startAuthButton')
46
+ const openUrlBtn = document.getElementById('openUrlButton')
47
+ const retryBtn = document.getElementById('retryButton')
48
+ const logoutBtn = document.getElementById('logoutButton')
49
+
50
+ if (startAuthBtn) startAuthBtn.addEventListener('click', startDeviceFlow)
51
+ if (openUrlBtn) openUrlBtn.addEventListener('click', openVerificationUrl)
52
+ if (retryBtn) retryBtn.addEventListener('click', retryAuth)
53
+ if (logoutBtn) logoutBtn.addEventListener('click', logout)
54
+
55
+ // Show schema-based config form below custom UI
56
+ homebridge.showSchemaForm()
57
+
58
+ // Load auth config and check authentication status
59
+ await loadAuthConfig()
60
+ await checkAuthStatus()
61
+ }
62
+
63
+ // Initialize on ready
64
+ homebridge.addEventListener('ready', initializeUI)
65
+
66
+ /**
67
+ * Show a specific UI section and hide all others
68
+ * @param {string} sectionToShow - ID of section to show
69
+ * @param {Object} [options] - Optional parameters
70
+ * @param {string} [options.errorMessage] - Error message to display (for errorSection)
71
+ */
72
+ function showSection (sectionToShow, options = {}) {
73
+ const sections = [
74
+ 'statusMessage',
75
+ 'authRequired',
76
+ 'deviceCodeSection',
77
+ 'authSuccess',
78
+ 'errorSection'
79
+ ]
80
+
81
+ for (const sectionId of sections) {
82
+ const el = document.getElementById(sectionId)
83
+ if (el) {
84
+ el.style.display = sectionId === sectionToShow ? 'block' : 'none'
85
+ }
86
+ }
87
+
88
+ // Set error message if provided
89
+ if (options.errorMessage) {
90
+ const errorMessageEl = document.getElementById('errorMessage')
91
+ if (errorMessageEl) errorMessageEl.textContent = options.errorMessage
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Show authentication required section
97
+ */
98
+ function showAuthRequired () {
99
+ showSection('authRequired')
100
+ }
101
+
102
+ /**
103
+ * Show authentication success
104
+ */
105
+ function showAuthSuccess () {
106
+ showSection('authSuccess')
107
+ }
108
+
109
+ /**
110
+ * Show error message
111
+ * @param {string} message - Error message to display
112
+ */
113
+ function showError (message) {
114
+ showSection('errorSection', { errorMessage: message })
115
+ }
116
+
117
+ /**
118
+ * Load authentication configuration from server
119
+ * @returns {Promise<void>}
120
+ */
121
+ async function loadAuthConfig () {
122
+ try {
123
+ // Load plugin config first
124
+ pluginConfig = await homebridge.getPluginConfig()
125
+ if (!pluginConfig.length) {
126
+ pluginConfig.push({})
127
+ }
128
+
129
+ /** @type {AuthConfigResponse} */
130
+ const config = await homebridge.request('/auth/config')
131
+ defaultClientId = config.defaultClientId
132
+
133
+ // Populate the client ID input field
134
+ const clientIdInput = /** @type {HTMLInputElement | null} */ (document.getElementById('clientIdInput'))
135
+ const defaultClientIdDisplay = document.getElementById('defaultClientIdDisplay')
136
+
137
+ if (clientIdInput) {
138
+ // Use configured value or default
139
+ const currentConfig = pluginConfig[0] || {}
140
+ clientIdInput.value = currentConfig.clientId || defaultClientId
141
+ clientIdInput.placeholder = defaultClientId
142
+ }
143
+
144
+ if (defaultClientIdDisplay) {
145
+ defaultClientIdDisplay.textContent = defaultClientId
146
+ }
147
+ } catch (error) {
148
+ console.error('Failed to load auth config:', error)
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Start OAuth device flow
154
+ * @returns {Promise<void>}
155
+ */
156
+ async function startDeviceFlow () {
157
+ try {
158
+ homebridge.showSpinner()
159
+
160
+ // Get client ID from input field (which may have been edited by user)
161
+ const clientIdInput = /** @type {HTMLInputElement | null} */ (document.getElementById('clientIdInput'))
162
+ const clientIdToUse = clientIdInput?.value || defaultClientId || undefined
163
+
164
+ // Save the client ID to config if it's different from what's stored
165
+ const config = pluginConfig[0] || {}
166
+ if (clientIdToUse && clientIdToUse !== config.clientId) {
167
+ if (!pluginConfig[0]) pluginConfig[0] = {}
168
+ pluginConfig[0].clientId = clientIdToUse
169
+ await homebridge.updatePluginConfig(pluginConfig)
170
+ await homebridge.savePluginConfig()
171
+ }
172
+
173
+ /** @type {AuthStartResponse} */
174
+ const response = await homebridge.request('/auth/start', {
175
+ clientId: clientIdToUse
176
+ })
177
+
178
+ // Store device code and client ID for polling
179
+ deviceCode = response.device_code
180
+ clientId = response.client_id
181
+ pollIntervalSeconds = response.interval
182
+
183
+ // Show device code section
184
+ showSection('deviceCodeSection')
185
+
186
+ // Set verification URL (complete with code)
187
+ const verificationUrlCompleteEl = /** @type {HTMLInputElement | null} */ (document.getElementById('verificationUrlComplete'))
188
+ if (verificationUrlCompleteEl) verificationUrlCompleteEl.value = response.verification_uri_complete
189
+
190
+ // Set user code
191
+ const userCodeEl = /** @type {HTMLInputElement | null} */ (document.getElementById('userCode'))
192
+ if (userCodeEl) userCodeEl.value = response.user_code
193
+
194
+ // Start countdown
195
+ startCountdown(response.expires_in)
196
+
197
+ // Start polling for token
198
+ startPolling()
199
+
200
+ homebridge.hideSpinner()
201
+ } catch (error) {
202
+ homebridge.hideSpinner()
203
+
204
+ // Debug: Log the raw error
205
+ console.error('Start auth error (raw):', error)
206
+ console.error('Start auth error (type):', typeof error)
207
+ console.error('Start auth error (keys):', error && typeof error === 'object' ? Object.keys(error) : 'N/A')
208
+
209
+ // Extract error message from various error formats
210
+ let errorMessage = 'Failed to start authentication'
211
+ if (error && typeof error === 'object') {
212
+ if ('message' in error && error.message) {
213
+ errorMessage = String(error.message)
214
+ } else if ('error' in error && error.error) {
215
+ errorMessage = String(error.error)
216
+ } else {
217
+ errorMessage = JSON.stringify(error)
218
+ }
219
+ } else if (error) {
220
+ errorMessage = String(error)
221
+ }
222
+
223
+ console.error('Start auth error (extracted message):', errorMessage)
224
+
225
+ homebridge.toast.error('Failed to start authentication', errorMessage)
226
+ showError(errorMessage)
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Start countdown timer
232
+ * @param {number} expiresIn - Seconds until expiration
233
+ */
234
+ function startCountdown (expiresIn) {
235
+ let remaining = expiresIn
236
+ const totalTime = expiresIn
237
+
238
+ countdownInterval = setInterval(() => {
239
+ remaining--
240
+
241
+ const minutes = Math.floor(remaining / 60)
242
+ const seconds = remaining % 60
243
+ const percentage = (remaining / totalTime) * 100
244
+
245
+ const countdownTextEl = document.getElementById('countdownText')
246
+ const countdownBarEl = /** @type {HTMLElement | null} */ (document.getElementById('countdownBar'))
247
+
248
+ if (countdownTextEl) countdownTextEl.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`
249
+ if (countdownBarEl) countdownBarEl.style.width = percentage + '%'
250
+
251
+ if (percentage < 30) {
252
+ const barEl = document.getElementById('countdownBar')
253
+ if (barEl) barEl.className = 'progress-bar bg-danger'
254
+ } else if (percentage < 60) {
255
+ const barEl = document.getElementById('countdownBar')
256
+ if (barEl) barEl.className = 'progress-bar bg-warning'
257
+ }
258
+
259
+ if (remaining <= 0) {
260
+ if (countdownInterval) clearInterval(countdownInterval)
261
+ if (pollingInterval) clearInterval(pollingInterval)
262
+ showError('The authorization code has expired. Please try again.')
263
+ }
264
+ }, 1000)
265
+ }
266
+
267
+ /**
268
+ * Start polling for token
269
+ */
270
+ function startPolling () {
271
+ pollingInterval = setInterval(async () => {
272
+ try {
273
+ /** @type {AuthPollResponse} */
274
+ const result = await homebridge.request('/auth/poll', {
275
+ deviceCode: deviceCode || '',
276
+ clientId: clientId || ''
277
+ })
278
+
279
+ // Type guard: check if success response
280
+ if ('success' in result && result.success) {
281
+ // Success! Update config with tokens
282
+ if (pollingInterval) clearInterval(pollingInterval)
283
+ if (countdownInterval) clearInterval(countdownInterval)
284
+
285
+ // Update plugin config with new tokens
286
+ if (!pluginConfig[0]) pluginConfig[0] = {}
287
+ // Type narrowing: result.success is true, so we have AuthPollSuccessResponse
288
+ pluginConfig[0].refreshToken = result.refreshToken
289
+ pluginConfig[0].accessToken = result.accessToken
290
+ pluginConfig[0].tokenExpiresAt = result.tokenExpiresAt
291
+
292
+ // Ensure clientId is set (use the one we got from the flow)
293
+ if (!pluginConfig[0].clientId && clientId) {
294
+ pluginConfig[0].clientId = clientId
295
+ }
296
+
297
+ // Save to Homebridge config
298
+ await homebridge.updatePluginConfig(pluginConfig)
299
+ await homebridge.savePluginConfig()
300
+
301
+ // Refresh the schema form to show updated token fields
302
+ homebridge.hideSchemaForm()
303
+ setTimeout(() => {
304
+ homebridge.showSchemaForm()
305
+ }, 100)
306
+
307
+ homebridge.toast.success('Authentication successful!')
308
+ homebridge.toast.info('Please restart the plugin for changes to take effect', 'Restart Required')
309
+ showAuthSuccess()
310
+ } else if ('slow_down' in result && result.slow_down) {
311
+ // Type guard: check if slow_down response
312
+ // Use the interval from the server response, or increase by 1.5x as fallback
313
+ if (pollingInterval) clearInterval(pollingInterval)
314
+ const slowDownResult = /** @type {AuthPollSlowDownResponse} */ (result)
315
+ pollIntervalSeconds = slowDownResult.interval || (pollIntervalSeconds * 1.5)
316
+ console.log('[Client] Slowing down polling to', pollIntervalSeconds, 'seconds')
317
+ startPolling()
318
+ }
319
+ // If pending (has 'pending' property), just continue polling
320
+ } catch (error) {
321
+ // Stop polling on error
322
+ if (pollingInterval) clearInterval(pollingInterval)
323
+ if (countdownInterval) clearInterval(countdownInterval)
324
+
325
+ // Debug: Log the raw error
326
+ console.error('Poll error (raw):', error)
327
+ console.error('Poll error (type):', typeof error)
328
+ console.error('Poll error (keys):', error && typeof error === 'object' ? Object.keys(error) : 'N/A')
329
+
330
+ // Extract error message from various error formats
331
+ let errorMessage = 'Authentication failed'
332
+ if (error && typeof error === 'object') {
333
+ if ('message' in error && error.message) {
334
+ errorMessage = String(error.message)
335
+ } else if ('error' in error && error.error) {
336
+ errorMessage = String(error.error)
337
+ } else {
338
+ errorMessage = JSON.stringify(error)
339
+ }
340
+ } else if (error) {
341
+ errorMessage = String(error)
342
+ }
343
+
344
+ console.error('Poll error (extracted message):', errorMessage)
345
+
346
+ homebridge.toast.error('Authentication failed', errorMessage)
347
+ showError(errorMessage)
348
+ }
349
+ }, pollIntervalSeconds * 1000)
350
+ }
351
+
352
+ /**
353
+ * Open verification URL in new window
354
+ */
355
+ function openVerificationUrl () {
356
+ const urlEl = /** @type {HTMLInputElement | null} */ (document.getElementById('verificationUrlComplete'))
357
+ if (urlEl && urlEl.value) {
358
+ window.open(urlEl.value, '_blank')
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Retry authentication
364
+ */
365
+ function retryAuth () {
366
+ showAuthRequired()
367
+ }
368
+
369
+ /**
370
+ * Logout - clear tokens and restart auth flow
371
+ */
372
+ async function logout () {
373
+ try {
374
+ homebridge.showSpinner()
375
+
376
+ // Clear tokens from config
377
+ if (pluginConfig[0]) {
378
+ delete pluginConfig[0].refreshToken
379
+ delete pluginConfig[0].accessToken
380
+ delete pluginConfig[0].tokenExpiresAt
381
+ }
382
+
383
+ // Save cleared config
384
+ await homebridge.updatePluginConfig(pluginConfig)
385
+ await homebridge.savePluginConfig()
386
+
387
+ homebridge.hideSpinner()
388
+ homebridge.toast.success('Logged out successfully')
389
+
390
+ // Show auth required screen
391
+ showAuthRequired()
392
+ } catch (error) {
393
+ homebridge.hideSpinner()
394
+ const errorMessage = error && typeof error === 'object' && 'message' in error
395
+ ? String(error.message)
396
+ : String(error)
397
+ homebridge.toast.error('Logout failed', errorMessage)
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Check initial authentication status
403
+ * @returns {Promise<void>}
404
+ */
405
+ async function checkAuthStatus () {
406
+ try {
407
+ // pluginConfig is already loaded by loadAuthConfig
408
+ const config = pluginConfig[0]
409
+
410
+ // Check if we have tokens configured (don't validate them, just check they exist)
411
+ const hasRefreshToken = !!config?.refreshToken
412
+ const hasAccessToken = !!config?.accessToken
413
+
414
+ if (hasRefreshToken && hasAccessToken) {
415
+ showAuthSuccess()
416
+ } else {
417
+ showAuthRequired()
418
+ // Populate client ID field if we're showing auth required
419
+ const clientIdInput = /** @type {HTMLInputElement | null} */ (document.getElementById('clientIdInput'))
420
+ if (clientIdInput && defaultClientId) {
421
+ clientIdInput.value = config?.clientId || defaultClientId
422
+ }
423
+ }
424
+ } catch (error) {
425
+ console.error('Failed to check auth status:', error)
426
+ showAuthRequired()
427
+ }
428
+ }
@@ -0,0 +1,138 @@
1
+ <!-- Load client script as module -->
2
+ <script type="module" src="client.js"></script>
3
+
4
+ <div class="card card-body">
5
+ <!-- Authentication Status -->
6
+ <div id="authStatus">
7
+ <h4>Authentication Status</h4>
8
+ <div id="statusMessage" class="alert alert-info">
9
+ Checking authentication status...
10
+ </div>
11
+ </div>
12
+
13
+ <!-- Authentication Required Section -->
14
+ <div id="authRequired" style="display: none">
15
+ <div class="alert alert-warning">
16
+ <h5>Yoto Authentication Required</h5>
17
+ <p>
18
+ To connect your Yoto devices to HomeKit, please authorize this plugin:
19
+ </p>
20
+
21
+ <ol>
22
+ <li>Click the button below to start authentication</li>
23
+ <li>Visit the Yoto website when prompted</li>
24
+ <li>Enter the code shown</li>
25
+ <li>Wait for confirmation</li>
26
+ </ol>
27
+ </div>
28
+
29
+ <!-- Advanced Settings -->
30
+ <details class="card mb-3">
31
+ <summary class="card-header" style="cursor: pointer">
32
+ <strong>Advanced Settings</strong>
33
+ <small class="text-muted">(optional)</small>
34
+ </summary>
35
+ <div class="card-body">
36
+ <div class="form-group">
37
+ <label for="clientIdInput">OAuth Client ID</label>
38
+ <input
39
+ type="text"
40
+ class="form-control"
41
+ id="clientIdInput"
42
+ placeholder="Loading..."
43
+ />
44
+ <small class="grey-text help-block small" id="clientIdHelp">
45
+ Default: <code id="defaultClientIdDisplay">Loading...</code>. Only
46
+ change this if you have your own Yoto OAuth application.
47
+ </small>
48
+ </div>
49
+ </div>
50
+ </details>
51
+
52
+ <div class="text-center">
53
+ <button id="startAuthButton" type="button" class="btn btn-primary btn-lg">
54
+ Start Authentication
55
+ </button>
56
+ </div>
57
+ </div>
58
+
59
+ <!-- Device Code Display -->
60
+ <div id="deviceCodeSection" style="display: none">
61
+ <div class="alert alert-info">
62
+ <h5>Complete Authentication</h5>
63
+
64
+ <div class="form-group">
65
+ <label>Click to authorize (code included in URL)</label>
66
+ <div class="input-group">
67
+ <input
68
+ type="text"
69
+ class="form-control"
70
+ id="verificationUrlComplete"
71
+ readonly
72
+ />
73
+ <div class="input-group-append">
74
+ <button class="btn btn-primary" type="button" id="openUrlButton">
75
+ Open &amp; Authorize
76
+ </button>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <div class="form-group">
82
+ <label>Or manually enter this code</label>
83
+ <input
84
+ type="text"
85
+ class="form-control form-control-lg text-center"
86
+ id="userCode"
87
+ readonly
88
+ style="font-family: monospace; font-weight: bold"
89
+ />
90
+ </div>
91
+
92
+ <div class="form-group">
93
+ <label>Time remaining</label>
94
+ <div class="progress">
95
+ <div
96
+ id="countdownBar"
97
+ class="progress-bar"
98
+ role="progressbar"
99
+ style="width: 100%"
100
+ >
101
+ <span id="countdownText">15:00</span>
102
+ </div>
103
+ </div>
104
+ </div>
105
+
106
+ <p class="text-center">
107
+ <small>Waiting for authorization...</small>
108
+ </p>
109
+ </div>
110
+ </div>
111
+
112
+ <!-- Success Message -->
113
+ <div id="authSuccess" style="display: none">
114
+ <div class="alert alert-success">
115
+ <h5>✓ Authentication Successful!</h5>
116
+ <p>
117
+ Your Yoto account is connected. The plugin will now discover your
118
+ devices.
119
+ </p>
120
+ </div>
121
+ <div class="text-center">
122
+ <button id="logoutButton" type="button" class="btn btn-outline-danger">
123
+ Logout
124
+ </button>
125
+ </div>
126
+ </div>
127
+
128
+ <!-- Error Display -->
129
+ <div id="errorSection" style="display: none">
130
+ <div class="alert alert-danger">
131
+ <h5>Authentication Error</h5>
132
+ <p id="errorMessage"></p>
133
+ <button id="retryButton" type="button" class="btn btn-danger">
134
+ Try Again
135
+ </button>
136
+ </div>
137
+ </div>
138
+ </div>