@mdjhnson/homebridge-yoto 0.1.6 → 0.1.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/README.md +1 -1
- package/homebridge-ui/public/client.js +36 -16
- package/homebridge-ui/public/index.html +17 -0
- package/homebridge-ui/server.js +30 -0
- package/lib/platform.js +129 -25
- package/lib/utils/login-status.js +99 -0
- package/lib/utils/token-config.js +6 -0
- package/lib/utils/unreachable.js +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ Using your own Yoto developer app? Make it a **Public Client**, add `http://127.
|
|
|
46
46
|
|
|
47
47
|
The plugin asks for access to view, configure and control your players, plus read-only access to your card library (used to name shortcut switches and to list your cards as TV inputs). If you signed in with an older version, sign in again so the new permissions apply.
|
|
48
48
|
|
|
49
|
-
The plugin refreshes its tokens on its own. If the login ever expires or is revoked, the
|
|
49
|
+
The plugin refreshes its tokens on its own, about once a day, and logs each refresh. If the login ever expires or is revoked, the plugin settings show **Yoto login expired** and the Home app shows the players as **Not Responding**. Sign in again from the plugin settings, then restart the child bridge.
|
|
50
50
|
|
|
51
51
|
## Settings
|
|
52
52
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/** @import {IHomebridgePluginUi} from '@homebridge/plugin-ui-utils/ui.interface' */
|
|
8
|
-
/** @import { AuthConfigResponse, AuthStartResponse, AuthExchangeResponse } from '../server.js' */
|
|
8
|
+
/** @import { AuthConfigResponse, AuthStartResponse, AuthExchangeResponse, AuthStatusResponse } from '../server.js' */
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* @global
|
|
@@ -55,6 +55,7 @@ async function initializeUI () {
|
|
|
55
55
|
document.getElementById('cancelAuthButton')?.addEventListener('click', showAuthRequired)
|
|
56
56
|
document.getElementById('retryButton')?.addEventListener('click', showAuthRequired)
|
|
57
57
|
document.getElementById('logoutButton')?.addEventListener('click', logout)
|
|
58
|
+
document.getElementById('reauthButton')?.addEventListener('click', startAuthorization)
|
|
58
59
|
getInput('callbackInput')?.addEventListener('keydown', (event) => {
|
|
59
60
|
if (event.key === 'Enter') finishAuthorization()
|
|
60
61
|
})
|
|
@@ -63,7 +64,7 @@ async function initializeUI () {
|
|
|
63
64
|
|
|
64
65
|
// Load auth config and check authentication status
|
|
65
66
|
await loadAuthConfig()
|
|
66
|
-
checkAuthStatus()
|
|
67
|
+
await checkAuthStatus()
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
// Initialize on ready
|
|
@@ -81,6 +82,7 @@ function showSection (sectionToShow, options = {}) {
|
|
|
81
82
|
'authRequired',
|
|
82
83
|
'authCodeSection',
|
|
83
84
|
'authSuccess',
|
|
85
|
+
'authExpired',
|
|
84
86
|
'errorSection'
|
|
85
87
|
]
|
|
86
88
|
|
|
@@ -284,13 +286,10 @@ async function finishAuthorization () {
|
|
|
284
286
|
config.accessToken = result.accessToken
|
|
285
287
|
config.tokenExpiresAt = result.tokenExpiresAt
|
|
286
288
|
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
} else {
|
|
292
|
-
delete config.clientId
|
|
293
|
-
}
|
|
289
|
+
// Always set the client ID. Deleting it isn't enough: the settings form
|
|
290
|
+
// keeps the value it loaded (e.g. a retired client ID) and saves it back.
|
|
291
|
+
// If the default ever changes, add the old one to LEGACY_CLIENT_IDS.
|
|
292
|
+
config.clientId = result.clientId
|
|
294
293
|
|
|
295
294
|
await homebridge.updatePluginConfig(pluginConfig)
|
|
296
295
|
await homebridge.savePluginConfig()
|
|
@@ -319,9 +318,11 @@ async function logout () {
|
|
|
319
318
|
try {
|
|
320
319
|
homebridge.showSpinner()
|
|
321
320
|
|
|
321
|
+
// Blank the tokens rather than deleting them: the settings form keeps
|
|
322
|
+
// values the client deletes and would save the old login back
|
|
322
323
|
const config = getPlatformBlock()
|
|
323
|
-
|
|
324
|
-
|
|
324
|
+
config.refreshToken = ''
|
|
325
|
+
config.accessToken = ''
|
|
325
326
|
delete config.tokenExpiresAt
|
|
326
327
|
|
|
327
328
|
await homebridge.updatePluginConfig(pluginConfig)
|
|
@@ -339,13 +340,32 @@ async function logout () {
|
|
|
339
340
|
}
|
|
340
341
|
|
|
341
342
|
/**
|
|
342
|
-
* Check initial authentication status
|
|
343
|
+
* Check initial authentication status. Saved tokens aren't proof of a working
|
|
344
|
+
* login, so ask whether the plugin found that Yoto rejected them.
|
|
345
|
+
* @returns {Promise<void>}
|
|
343
346
|
*/
|
|
344
|
-
function checkAuthStatus () {
|
|
347
|
+
async function checkAuthStatus () {
|
|
345
348
|
const config = pluginConfig[0]
|
|
346
|
-
if (config?.refreshToken
|
|
347
|
-
showSection('authSuccess')
|
|
348
|
-
} else {
|
|
349
|
+
if (!config?.refreshToken || !config?.accessToken) {
|
|
349
350
|
showAuthRequired()
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
/** @type {AuthStatusResponse} */
|
|
356
|
+
const status = await homebridge.request('/auth/status', { refreshToken: config.refreshToken })
|
|
357
|
+
if (status.expired) {
|
|
358
|
+
const detail = document.getElementById('authExpiredMessage')
|
|
359
|
+
if (detail) {
|
|
360
|
+
const when = status.at ? new Date(status.at).toLocaleString() : ''
|
|
361
|
+
detail.textContent = [when && `Since ${when}.`, status.message].filter(Boolean).join(' ')
|
|
362
|
+
}
|
|
363
|
+
showSection('authExpired')
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
} catch (error) {
|
|
367
|
+
// Older Homebridge UI versions or a read error: fall back to the saved tokens
|
|
368
|
+
console.error('Failed to check the login status:', error)
|
|
350
369
|
}
|
|
370
|
+
showSection('authSuccess')
|
|
351
371
|
}
|
|
@@ -114,6 +114,23 @@
|
|
|
114
114
|
</div>
|
|
115
115
|
</div>
|
|
116
116
|
|
|
117
|
+
<!-- Login Expired -->
|
|
118
|
+
<div id="authExpired" style="display: none">
|
|
119
|
+
<div class="alert alert-danger">
|
|
120
|
+
<h5>Yoto login expired</h5>
|
|
121
|
+
<p>
|
|
122
|
+
Yoto no longer accepts the saved login, so your players can't be
|
|
123
|
+
controlled from HomeKit. Sign in again, then restart the child bridge.
|
|
124
|
+
</p>
|
|
125
|
+
<p class="small mb-0" id="authExpiredMessage"></p>
|
|
126
|
+
</div>
|
|
127
|
+
<div class="text-center">
|
|
128
|
+
<button id="reauthButton" type="button" class="btn btn-primary btn-lg">
|
|
129
|
+
Sign in again
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
117
134
|
<!-- Error Display -->
|
|
118
135
|
<div id="errorSection" style="display: none">
|
|
119
136
|
<div class="alert alert-danger">
|
package/homebridge-ui/server.js
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
OAUTH_SCOPES,
|
|
18
18
|
} from '../lib/settings.js'
|
|
19
19
|
import { createOAuthState, createPkcePair, parseAuthorizationResponse } from '../lib/utils/oauth.js'
|
|
20
|
+
import { readLoginProblem } from '../lib/utils/login-status.js'
|
|
20
21
|
|
|
21
22
|
const AUDIENCE = 'https://api.yotoplay.com'
|
|
22
23
|
/** Sign-in attempts are discarded after this long */
|
|
@@ -47,6 +48,7 @@ class YotoUiServer extends HomebridgePluginUiServer {
|
|
|
47
48
|
this.onRequest('/auth/config', getAuthConfig)
|
|
48
49
|
this.onRequest('/auth/start', startAuthorization)
|
|
49
50
|
this.onRequest('/auth/exchange', exchangeAuthorizationCode)
|
|
51
|
+
this.onRequest('/auth/status', (/** @type {AuthStatusRequest} */ payload) => getLoginStatus(this.homebridgeStoragePath, payload))
|
|
50
52
|
|
|
51
53
|
// this MUST be called when you are ready to accept requests
|
|
52
54
|
this.ready()
|
|
@@ -78,6 +80,34 @@ async function getAuthConfig () {
|
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Request payload for /auth/status endpoint
|
|
85
|
+
* @typedef {Object} AuthStatusRequest
|
|
86
|
+
* @property {string} [refreshToken] - Refresh token in the settings being shown
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Response from /auth/status endpoint
|
|
91
|
+
* @typedef {Object} AuthStatusResponse
|
|
92
|
+
* @property {boolean} expired - True when the plugin found this login no longer works
|
|
93
|
+
* @property {string} [message] - What went wrong
|
|
94
|
+
* @property {string} [at] - When it happened (ISO 8601)
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Whether the plugin recorded that the saved login stopped working. The plugin
|
|
99
|
+
* records the refresh token's fingerprint, so a login replaced by signing in
|
|
100
|
+
* again isn't reported.
|
|
101
|
+
* @param {string | undefined} storagePath - Homebridge storage folder
|
|
102
|
+
* @param {AuthStatusRequest} payload
|
|
103
|
+
* @returns {Promise<AuthStatusResponse>}
|
|
104
|
+
*/
|
|
105
|
+
async function getLoginStatus (storagePath, payload) {
|
|
106
|
+
if (!storagePath) return { expired: false }
|
|
107
|
+
const problem = await readLoginProblem(storagePath, payload?.refreshToken)
|
|
108
|
+
return problem ? { expired: true, message: problem.message, at: problem.at } : { expired: false }
|
|
109
|
+
}
|
|
110
|
+
|
|
81
111
|
/**
|
|
82
112
|
* Request payload for /auth/start endpoint
|
|
83
113
|
* @typedef {Object} AuthStartRequest
|
package/lib/platform.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
/** @import { PlaybackAccessoryConfig } from './service-config.js' */
|
|
10
10
|
/** @import { CardControlConfig } from './card-controls.js' */
|
|
11
11
|
/** @import { LibraryCard } from './utils/library.js' */
|
|
12
|
+
/** @import { RefreshSuccessEvent } from 'yoto-nodejs-client/lib/token.js' */
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Context stored in PlatformAccessory for Yoto devices
|
|
@@ -47,6 +48,8 @@ import { applyTokenUpdate, findNewerSavedTokens } from './utils/token-config.js'
|
|
|
47
48
|
import { tolerateMissingStatusScope } from './utils/status-scope-fallback.js'
|
|
48
49
|
import { getTokenClientId } from './utils/token-client-id.js'
|
|
49
50
|
import { fetchFamilyLibrary } from './utils/library.js'
|
|
51
|
+
import { clearLoginProblem, writeLoginProblem } from './utils/login-status.js'
|
|
52
|
+
import { markAccessoryUnreachable } from './utils/unreachable.js'
|
|
50
53
|
|
|
51
54
|
/** First retry delay when the Yoto API can't be reached at startup; doubles each attempt */
|
|
52
55
|
const START_RETRY_BASE_MS = 30 * 1000
|
|
@@ -94,6 +97,8 @@ export class YotoPlatform {
|
|
|
94
97
|
/** @type {Map<string, Promise<string | null>>} */ cardTitleCache = new Map()
|
|
95
98
|
/** @type {{ fetchedAt: number, cards: Promise<LibraryCard[]> } | null} */ libraryCache = null
|
|
96
99
|
/** @type {boolean} */ authInvalid = false
|
|
100
|
+
/** @type {boolean} */ launched = false
|
|
101
|
+
/** @type {boolean} */ markOnLaunch = false
|
|
97
102
|
/** @type {number} */ startRetryCount = 0
|
|
98
103
|
/** @type {ReturnType<typeof setTimeout> | null} */ startRetryTimer = null
|
|
99
104
|
/** @type {boolean} */ shuttingDown = false
|
|
@@ -138,13 +143,14 @@ export class YotoPlatform {
|
|
|
138
143
|
// Check if we have authentication tokens
|
|
139
144
|
if (!refreshToken || !accessToken) {
|
|
140
145
|
log.warn('No authentication tokens found. Please configure the plugin through the Homebridge UI.')
|
|
146
|
+
// Cached accessories would otherwise show their last state and ignore taps
|
|
147
|
+
this.markAccessoriesUnreachable()
|
|
141
148
|
return
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
log.debug('Authentication tokens found, initializing Yoto account...')
|
|
145
152
|
|
|
146
153
|
const { sessionId } = this
|
|
147
|
-
const updateHomebridgeConfig = this.updateHomebridgeConfig.bind(this)
|
|
148
154
|
|
|
149
155
|
// Initialize YotoAccount with client and device options. It decodes the saved
|
|
150
156
|
// access token, which throws if config.json holds a malformed one; Homebridge
|
|
@@ -157,38 +163,21 @@ export class YotoPlatform {
|
|
|
157
163
|
clientId,
|
|
158
164
|
refreshToken,
|
|
159
165
|
accessToken,
|
|
160
|
-
onTokenRefresh:
|
|
161
|
-
// Client reports expiry in seconds; config.json stores milliseconds (matches the UI)
|
|
162
|
-
const tokenExpiresAt = updatedExpiresAt * 1000
|
|
163
|
-
log.debug('Access token refreshed, expires at:', new Date(tokenExpiresAt).toISOString())
|
|
164
|
-
|
|
165
|
-
// Keep the in-memory config in sync so later reads see current tokens
|
|
166
|
-
config['accessToken'] = updatedAccessToken
|
|
167
|
-
config['refreshToken'] = updatedRefreshToken
|
|
168
|
-
config['tokenExpiresAt'] = tokenExpiresAt
|
|
169
|
-
|
|
170
|
-
await updateHomebridgeConfig((configContents) => applyTokenUpdate(configContents, {
|
|
171
|
-
platform: PLATFORM_NAME,
|
|
172
|
-
accessToken: updatedAccessToken,
|
|
173
|
-
refreshToken: updatedRefreshToken,
|
|
174
|
-
tokenExpiresAt,
|
|
175
|
-
prevAccessToken,
|
|
176
|
-
prevRefreshToken,
|
|
177
|
-
}))
|
|
178
|
-
},
|
|
166
|
+
onTokenRefresh: (event) => this.saveRefreshedTokens(event),
|
|
179
167
|
onRefreshStart: () => {
|
|
180
|
-
|
|
168
|
+
// Logged at info, so a refresh cut short (e.g. by a restart) shows in the log
|
|
169
|
+
log.info('Refreshing the Yoto login...')
|
|
181
170
|
},
|
|
182
171
|
onRefreshError: (error) => {
|
|
183
172
|
log.warn('Yoto token refresh failed, will retry:', error.message)
|
|
184
173
|
log.debug('Token refresh error details:', formatError(error))
|
|
185
174
|
},
|
|
186
175
|
onInvalid: (error) => {
|
|
187
|
-
this.authInvalid = true
|
|
188
176
|
log.error(
|
|
189
177
|
'Yoto login has expired or been revoked. Open the plugin settings in the Homebridge UI and sign in again.',
|
|
190
178
|
error.message
|
|
191
179
|
)
|
|
180
|
+
this.handleLoginProblem(`Yoto rejected the saved login: ${error.message}`)
|
|
192
181
|
},
|
|
193
182
|
},
|
|
194
183
|
deviceOptions: {
|
|
@@ -203,6 +192,7 @@ export class YotoPlatform {
|
|
|
203
192
|
'Could not load the saved Yoto login. Open the plugin settings in the Homebridge UI and sign in again.',
|
|
204
193
|
errorMessage(error)
|
|
205
194
|
)
|
|
195
|
+
this.handleLoginProblem(`The saved login could not be read: ${errorMessage(error)}`)
|
|
206
196
|
return
|
|
207
197
|
}
|
|
208
198
|
this.yotoAccount = account
|
|
@@ -235,6 +225,7 @@ export class YotoPlatform {
|
|
|
235
225
|
// to start discovery of new accessories.
|
|
236
226
|
api.on('didFinishLaunching', async () => {
|
|
237
227
|
log.debug('Executed didFinishLaunching callback')
|
|
228
|
+
this.launched = true
|
|
238
229
|
// Start the YotoAccount which will discover and start all devices
|
|
239
230
|
await this.startAccount()
|
|
240
231
|
})
|
|
@@ -304,8 +295,9 @@ export class YotoPlatform {
|
|
|
304
295
|
listeners.on('deviceAdded', async ({ deviceId }) => {
|
|
305
296
|
// The account doesn't await this listener, so errors must not escape it
|
|
306
297
|
try {
|
|
307
|
-
// Homebridge may shut down while the account is still starting
|
|
308
|
-
|
|
298
|
+
// Homebridge may shut down while the account is still starting. With a dead
|
|
299
|
+
// login, a new handler would bring back tiles that show stale state.
|
|
300
|
+
if (this.shuttingDown || this.authInvalid || !this.yotoAccount) return
|
|
309
301
|
const deviceModel = this.yotoAccount.getDevice(deviceId)
|
|
310
302
|
if (!deviceModel) {
|
|
311
303
|
const label = this.formatDeviceLabel(deviceId)
|
|
@@ -469,6 +461,7 @@ export class YotoPlatform {
|
|
|
469
461
|
`Yoto rejected the saved login (HTTP ${statusCode}). Open the plugin settings in the Homebridge UI and sign in again.`,
|
|
470
462
|
formatError(error)
|
|
471
463
|
)
|
|
464
|
+
this.handleLoginProblem(`Yoto rejected the saved login (HTTP ${statusCode}): ${errorMessage(error)}`)
|
|
472
465
|
return
|
|
473
466
|
}
|
|
474
467
|
if (!isRetryableStartError(error)) {
|
|
@@ -495,7 +488,22 @@ export class YotoPlatform {
|
|
|
495
488
|
}
|
|
496
489
|
|
|
497
490
|
this.startRetryCount = 0
|
|
491
|
+
|
|
492
|
+
// YotoAccount.start() swallows device start errors, so the login can be
|
|
493
|
+
// rejected during a start that still resolves
|
|
494
|
+
if (this.authInvalid) {
|
|
495
|
+
await account.stop()
|
|
496
|
+
this.markAccessoriesUnreachable()
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
|
|
498
500
|
this.log.info(`✓ Yoto account started with ${account.devices.size} device(s)`)
|
|
501
|
+
const storagePath = this.getStoragePath()
|
|
502
|
+
if (storagePath) {
|
|
503
|
+
clearLoginProblem(storagePath, this.config['refreshToken']).catch(error => {
|
|
504
|
+
this.log.debug('Could not clear the recorded login problem:', errorMessage(error))
|
|
505
|
+
})
|
|
506
|
+
}
|
|
499
507
|
this.log.debug(
|
|
500
508
|
'Account devices:',
|
|
501
509
|
Array.from(account.devices.keys()).join(', ') || 'none'
|
|
@@ -1144,6 +1152,99 @@ export class YotoPlatform {
|
|
|
1144
1152
|
this.log.debug('✓ Yoto platform shutdown complete')
|
|
1145
1153
|
}
|
|
1146
1154
|
|
|
1155
|
+
/**
|
|
1156
|
+
* Save tokens the client just refreshed. Yoto's refresh tokens are single-use,
|
|
1157
|
+
* so a refreshed login that isn't saved is lost on the next restart.
|
|
1158
|
+
* @param {RefreshSuccessEvent} event
|
|
1159
|
+
* @returns {Promise<void>}
|
|
1160
|
+
*/
|
|
1161
|
+
async saveRefreshedTokens ({ clientId, updatedAccessToken, updatedRefreshToken, updatedExpiresAt, prevAccessToken, prevRefreshToken }) {
|
|
1162
|
+
// Client reports expiry in seconds; config.json stores milliseconds (matches the UI)
|
|
1163
|
+
const tokenExpiresAt = updatedExpiresAt * 1000
|
|
1164
|
+
this.log.debug('Access token refreshed, expires at:', new Date(tokenExpiresAt).toISOString())
|
|
1165
|
+
|
|
1166
|
+
// Keep the in-memory config in sync so later reads see current tokens
|
|
1167
|
+
this.config['accessToken'] = updatedAccessToken
|
|
1168
|
+
this.config['refreshToken'] = updatedRefreshToken
|
|
1169
|
+
this.config['tokenExpiresAt'] = tokenExpiresAt
|
|
1170
|
+
|
|
1171
|
+
const saved = await this.updateHomebridgeConfig((configContents) => applyTokenUpdate(configContents, {
|
|
1172
|
+
platform: PLATFORM_NAME,
|
|
1173
|
+
accessToken: updatedAccessToken,
|
|
1174
|
+
refreshToken: updatedRefreshToken,
|
|
1175
|
+
tokenExpiresAt,
|
|
1176
|
+
prevAccessToken,
|
|
1177
|
+
prevRefreshToken,
|
|
1178
|
+
clientId,
|
|
1179
|
+
}))
|
|
1180
|
+
if (saved) {
|
|
1181
|
+
this.log.info(`Refreshed the Yoto login and saved it to config.json. It's valid until ${new Date(tokenExpiresAt).toLocaleString()} and is refreshed shortly before then.`)
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* The saved Yoto login no longer works. Record it for the settings UI, and make
|
|
1187
|
+
* the accessories show "Not Responding" instead of their last known state.
|
|
1188
|
+
* @param {string} message - What went wrong, shown in the settings UI
|
|
1189
|
+
*/
|
|
1190
|
+
handleLoginProblem (message) {
|
|
1191
|
+
this.authInvalid = true
|
|
1192
|
+
this.markAccessoriesUnreachable()
|
|
1193
|
+
// Stop polling and MQTT: every call would fail until a restart, and pushed
|
|
1194
|
+
// updates would refresh tiles that should read Not Responding
|
|
1195
|
+
this.yotoAccount?.stop().catch(error => {
|
|
1196
|
+
this.log.debug('Could not stop the Yoto account:', errorMessage(error))
|
|
1197
|
+
})
|
|
1198
|
+
|
|
1199
|
+
const storagePath = this.getStoragePath()
|
|
1200
|
+
if (!storagePath) return
|
|
1201
|
+
writeLoginProblem(storagePath, { message, refreshToken: this.config['refreshToken'] }).catch(error => {
|
|
1202
|
+
this.log.debug('Could not record the login problem for the settings UI:', errorMessage(error))
|
|
1203
|
+
})
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Show every accessory this platform knows of as "Not Responding" in HomeKit.
|
|
1208
|
+
* Cached accessories are only known once Homebridge has finished launching,
|
|
1209
|
+
* so before then this waits for it.
|
|
1210
|
+
*/
|
|
1211
|
+
markAccessoriesUnreachable () {
|
|
1212
|
+
if (!this.launched) {
|
|
1213
|
+
if (!this.markOnLaunch) {
|
|
1214
|
+
this.markOnLaunch = true
|
|
1215
|
+
this.api.on('didFinishLaunching', () => {
|
|
1216
|
+
this.launched = true
|
|
1217
|
+
this.markAccessoriesUnreachable()
|
|
1218
|
+
})
|
|
1219
|
+
}
|
|
1220
|
+
return
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const accessories = [
|
|
1224
|
+
...this.accessories.values(),
|
|
1225
|
+
...this.cardAccessories.values(),
|
|
1226
|
+
...this.speakerAccessories.values(),
|
|
1227
|
+
...this.televisionAccessories.values(),
|
|
1228
|
+
]
|
|
1229
|
+
if (!accessories.length) return
|
|
1230
|
+
for (const accessory of accessories) {
|
|
1231
|
+
markAccessoryUnreachable(accessory, this.api.hap)
|
|
1232
|
+
}
|
|
1233
|
+
this.log.warn(`The Home app will show ${accessories.length} Yoto accessory(s) as Not Responding until you sign in again and restart the child bridge.`)
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Homebridge's storage folder, or null if it isn't known
|
|
1238
|
+
* @returns {string | null}
|
|
1239
|
+
*/
|
|
1240
|
+
getStoragePath () {
|
|
1241
|
+
try {
|
|
1242
|
+
return this.api.user.storagePath()
|
|
1243
|
+
} catch {
|
|
1244
|
+
return null
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1147
1248
|
/**
|
|
1148
1249
|
* Replace the tokens in `config` with newer ones saved in config.json, if any.
|
|
1149
1250
|
* @param {PlatformConfig} config
|
|
@@ -1171,6 +1272,7 @@ export class YotoPlatform {
|
|
|
1171
1272
|
/**
|
|
1172
1273
|
* Update Homebridge config.json file
|
|
1173
1274
|
* @param {(configContents: string) => string | null} updateFn - Returns updated contents, or null if it could not apply the update
|
|
1275
|
+
* @returns {Promise<boolean>} Whether config.json was updated
|
|
1174
1276
|
*/
|
|
1175
1277
|
async updateHomebridgeConfig (updateFn) {
|
|
1176
1278
|
const configPath = this.api.user.configPath()
|
|
@@ -1180,7 +1282,7 @@ export class YotoPlatform {
|
|
|
1180
1282
|
const updatedContents = updateFn(configContents)
|
|
1181
1283
|
if (updatedContents === null) {
|
|
1182
1284
|
this.log.warn(`Did not save refreshed tokens: no ${PLATFORM_NAME} platform block in ${configPath} holds the current tokens. If you signed in again or logged out in the plugin settings, restart Homebridge to use the new settings.`)
|
|
1183
|
-
return
|
|
1285
|
+
return false
|
|
1184
1286
|
}
|
|
1185
1287
|
// Write to a temp file and rename so a crash mid-write cannot corrupt config.json.
|
|
1186
1288
|
// Keep the original file mode, since config.json holds tokens.
|
|
@@ -1202,8 +1304,10 @@ export class YotoPlatform {
|
|
|
1202
1304
|
await writeFile(configPath, updatedContents, 'utf8')
|
|
1203
1305
|
}
|
|
1204
1306
|
this.log.debug('Updated config.json with new tokens')
|
|
1307
|
+
return true
|
|
1205
1308
|
} catch (error) {
|
|
1206
1309
|
this.log.error(`Failed to update config.json at ${configPath}. Refreshed tokens may not persist:`, errorMessage(error))
|
|
1310
|
+
return false
|
|
1207
1311
|
}
|
|
1208
1312
|
}
|
|
1209
1313
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Records a Yoto login that stopped working, so the settings UI can
|
|
3
|
+
* say so. The plugin runs in a child bridge and the settings UI in the Homebridge
|
|
4
|
+
* UI process, so they share this small file in the Homebridge storage folder.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
import { readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
export const LOGIN_STATUS_FILE = 'yoto-login-status.json'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} LoginProblem
|
|
15
|
+
* @property {string} message - What went wrong, for the settings UI
|
|
16
|
+
* @property {string} at - When it happened (ISO 8601)
|
|
17
|
+
* @property {string | null} tokenFingerprint - Fingerprint of the refresh token that failed
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A short, one-way fingerprint of a refresh token. It tells which login a
|
|
22
|
+
* problem belongs to without storing the token.
|
|
23
|
+
* @param {unknown} refreshToken
|
|
24
|
+
* @returns {string | null}
|
|
25
|
+
*/
|
|
26
|
+
export function tokenFingerprint (refreshToken) {
|
|
27
|
+
if (typeof refreshToken !== 'string' || !refreshToken) return null
|
|
28
|
+
return createHash('sha256').update(refreshToken).digest('hex').slice(0, 16)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} storagePath - Homebridge storage folder
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function loginStatusPath (storagePath) {
|
|
36
|
+
return join(storagePath, LOGIN_STATUS_FILE)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Record that the login holding `refreshToken` no longer works.
|
|
41
|
+
* @param {string} storagePath
|
|
42
|
+
* @param {{ message: string, refreshToken: unknown, now?: Date }} problem
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
export async function writeLoginProblem (storagePath, { message, refreshToken, now = new Date() }) {
|
|
46
|
+
/** @type {LoginProblem} */
|
|
47
|
+
const record = { message, at: now.toISOString(), tokenFingerprint: tokenFingerprint(refreshToken) }
|
|
48
|
+
const path = loginStatusPath(storagePath)
|
|
49
|
+
const tmpPath = `${path}.${process.pid}.tmp`
|
|
50
|
+
try {
|
|
51
|
+
await writeFile(tmpPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
|
|
52
|
+
await rename(tmpPath, path)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
await unlink(tmpPath).catch(() => {})
|
|
55
|
+
throw error
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Remove the problem recorded for the login holding `refreshToken`, once that
|
|
61
|
+
* login works after all. A problem recorded for another login (e.g. another
|
|
62
|
+
* Yoto platform block) is kept.
|
|
63
|
+
* @param {string} storagePath
|
|
64
|
+
* @param {unknown} refreshToken
|
|
65
|
+
* @returns {Promise<void>}
|
|
66
|
+
*/
|
|
67
|
+
export async function clearLoginProblem (storagePath, refreshToken) {
|
|
68
|
+
if (!await readLoginProblem(storagePath, refreshToken)) return
|
|
69
|
+
try {
|
|
70
|
+
await unlink(loginStatusPath(storagePath))
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'ENOENT') throw error
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The recorded problem for the login holding `refreshToken`, if any. A problem
|
|
78
|
+
* recorded for another login (e.g. one replaced by signing in again) is ignored.
|
|
79
|
+
* @param {string} storagePath
|
|
80
|
+
* @param {unknown} refreshToken
|
|
81
|
+
* @returns {Promise<LoginProblem | null>}
|
|
82
|
+
*/
|
|
83
|
+
export async function readLoginProblem (storagePath, refreshToken) {
|
|
84
|
+
const fingerprint = tokenFingerprint(refreshToken)
|
|
85
|
+
if (!fingerprint) return null
|
|
86
|
+
|
|
87
|
+
/** @type {unknown} */
|
|
88
|
+
let parsed
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(await readFile(loginStatusPath(storagePath), 'utf8'))
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
95
|
+
const record = /** @type {Record<string, unknown>} */ (parsed)
|
|
96
|
+
if (record['tokenFingerprint'] !== fingerprint) return null
|
|
97
|
+
if (typeof record['message'] !== 'string' || typeof record['at'] !== 'string') return null
|
|
98
|
+
return { message: record['message'], at: record['at'], tokenFingerprint: fingerprint }
|
|
99
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* @property {number} tokenExpiresAt - Expiry as a unix timestamp in milliseconds
|
|
7
7
|
* @property {string} [prevAccessToken] - Previous access token, used to pick the right block
|
|
8
8
|
* @property {string} [prevRefreshToken] - Previous refresh token, used to pick the right block
|
|
9
|
+
* @property {string} [clientId] - Client ID the tokens were issued to. Replaces a different saved one.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -53,6 +54,11 @@ export function applyTokenUpdate (configContents, update) {
|
|
|
53
54
|
block['accessToken'] = update.accessToken
|
|
54
55
|
block['refreshToken'] = update.refreshToken
|
|
55
56
|
block['tokenExpiresAt'] = update.tokenExpiresAt
|
|
57
|
+
// The settings form can save a stale client ID back; keep it matching the tokens.
|
|
58
|
+
// A missing one means the default, and is left out.
|
|
59
|
+
if (update.clientId && block['clientId'] !== undefined && block['clientId'] !== update.clientId) {
|
|
60
|
+
block['clientId'] = update.clientId
|
|
61
|
+
}
|
|
56
62
|
|
|
57
63
|
// Homebridge writes config.json with 4-space indentation
|
|
58
64
|
return JSON.stringify(parsed, null, 4)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** @import { API, PlatformAccessory } from 'homebridge' */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Make HomeKit show an accessory as "Not Responding". Every read and write fails
|
|
5
|
+
* with a communication error, instead of the tiles showing the last known state
|
|
6
|
+
* and silently ignoring taps.
|
|
7
|
+
*
|
|
8
|
+
* This replaces the handlers for good. Nothing needs them back: a new sign-in only
|
|
9
|
+
* takes effect when the child bridge restarts, which sets them up again.
|
|
10
|
+
* @param {PlatformAccessory} accessory
|
|
11
|
+
* @param {API['hap']} hap
|
|
12
|
+
*/
|
|
13
|
+
export function markAccessoryUnreachable (accessory, hap) {
|
|
14
|
+
const { Service, Characteristic, HapStatusError, HAPStatus, Perms } = hap
|
|
15
|
+
const skippedServices = new Set([Service.AccessoryInformation.UUID, Service.ProtocolInformation.UUID])
|
|
16
|
+
// Keep names readable so the Home app still shows which tile is which
|
|
17
|
+
const skippedCharacteristics = new Set([Characteristic.Name.UUID, Characteristic.ConfiguredName.UUID])
|
|
18
|
+
const fail = () => {
|
|
19
|
+
throw new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for (const service of accessory.services) {
|
|
23
|
+
if (skippedServices.has(service.UUID)) continue
|
|
24
|
+
for (const characteristic of service.characteristics) {
|
|
25
|
+
if (skippedCharacteristics.has(characteristic.UUID)) continue
|
|
26
|
+
const { perms } = characteristic.props
|
|
27
|
+
if (perms.includes(Perms.PAIRED_READ)) characteristic.onGet(fail)
|
|
28
|
+
if (perms.includes(Perms.PAIRED_WRITE)) characteristic.onSet(fail)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@mdjhnson/homebridge-yoto",
|
|
3
3
|
"displayName": "Homebridge Yoto",
|
|
4
4
|
"description": "Control your Yoto players through Apple HomeKit with real-time MQTT updates",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.7",
|
|
6
6
|
"author": "mdjhnson (https://github.com/mdjhnson)",
|
|
7
7
|
"contributors": [
|
|
8
8
|
"Bret Comnes <bcomnes@gmail.com> (https://bret.io)"
|