@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 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 Homebridge log will say so. Sign in again from the plugin settings.
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
- // Only store a client ID when it differs from the default, so future
288
- // default changes apply automatically.
289
- if (result.clientId && result.clientId !== defaultClientId) {
290
- config.clientId = result.clientId
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
- delete config.refreshToken
324
- delete config.accessToken
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 && config?.accessToken) {
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">
@@ -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: async ({ updatedAccessToken, updatedRefreshToken, updatedExpiresAt, prevAccessToken, prevRefreshToken }) => {
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
- log.debug('Refreshing Yoto access token...')
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
- if (this.shuttingDown || !this.yotoAccount) return
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.6",
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)"