@mdjhnson/homebridge-yoto 0.1.5 → 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
@@ -2,7 +2,7 @@
2
2
 
3
3
  <img src="https://github.com/homebridge/branding/raw/latest/logos/homebridge-wordmark-logo-vertical.png" width="150">
4
4
 
5
- <img src="https://raw.githubusercontent.com/mdjhnson/homebridge-yoto/master/logo.png" width="150">
5
+ <img src="https://raw.githubusercontent.com/mdjhnson/homebridge-yoto/master/homebridge-ui/public/logo.png" width="100">
6
6
 
7
7
  </p>
8
8
 
@@ -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
 
@@ -8,11 +8,16 @@
8
8
  "schema": {
9
9
  "type": "object",
10
10
  "properties": {
11
+ "name": {
12
+ "title": "Name",
13
+ "type": "string",
14
+ "default": "Yoto",
15
+ "description": "Name used for this plugin in the Homebridge log."
16
+ },
11
17
  "clientId": {
12
18
  "title": "OAuth Client ID",
13
19
  "type": "string",
14
20
  "default": "tpc_ot5BY24FLyZoCX9MnykipB",
15
- "required": false,
16
21
  "description": "OAuth client ID for Yoto API authentication. The default works for most users. Only change if you've created your own OAuth app at yoto.dev.",
17
22
  "x-schema-form": {
18
23
  "hidden": false
@@ -21,7 +26,6 @@
21
26
  "accessToken": {
22
27
  "title": "Access Token",
23
28
  "type": "string",
24
- "required": false,
25
29
  "description": "Automatically managed by the authentication flow. Do not edit manually.",
26
30
  "x-schema-form": {
27
31
  "type": "password"
@@ -30,7 +34,6 @@
30
34
  "refreshToken": {
31
35
  "title": "Refresh Token",
32
36
  "type": "string",
33
- "required": false,
34
37
  "description": "Automatically managed by the authentication flow. Do not edit manually.",
35
38
  "x-schema-form": {
36
39
  "type": "password"
@@ -39,7 +42,6 @@
39
42
  "tokenExpiresAt": {
40
43
  "title": "Token Expiration",
41
44
  "type": "number",
42
- "required": false,
43
45
  "description": "Automatically managed by the authentication flow. Do not edit manually.",
44
46
  "x-schema-form": {
45
47
  "hidden": true
@@ -50,7 +52,6 @@
50
52
  "type": "integer",
51
53
  "default": 60000,
52
54
  "minimum": 10000,
53
- "required": false,
54
55
  "description": "How often to poll the Yoto API for device status and config as a fallback to MQTT updates. Defaults to 60000 (1 minute)."
55
56
  },
56
57
  "services": {
@@ -116,13 +117,11 @@
116
117
  "label": {
117
118
  "title": "Label",
118
119
  "type": "string",
119
- "required": true,
120
120
  "description": "Name shown in HomeKit for this card control."
121
121
  },
122
122
  "cardId": {
123
123
  "title": "Card ID",
124
124
  "type": "string",
125
- "required": true,
126
125
  "description": "The Yoto card ID to play."
127
126
  },
128
127
  "playOnAll": {
@@ -131,7 +130,11 @@
131
130
  "default": false,
132
131
  "description": "Create a separate accessory that plays this card on every Yoto."
133
132
  }
134
- }
133
+ },
134
+ "required": [
135
+ "label",
136
+ "cardId"
137
+ ]
135
138
  }
136
139
  },
137
140
  "shortcuts": {
@@ -250,6 +253,7 @@
250
253
  "expandable": true,
251
254
  "expanded": false,
252
255
  "items": [
256
+ "name",
253
257
  "httpPollIntervalMs",
254
258
  {
255
259
  "type": "help",
@@ -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
@@ -16,6 +16,7 @@ const homebridge = window.homebridge
16
16
  /**
17
17
  * @typedef {Object} YotoConfig
18
18
  * @property {string} [platform] - Platform alias (always "Yoto")
19
+ * @property {string} [name] - Name used in the Homebridge log
19
20
  * @property {string} [clientId] - OAuth client ID (only stored when not the default)
20
21
  * @property {string} [refreshToken] - Stored refresh token
21
22
  * @property {string} [accessToken] - Stored access token
@@ -31,6 +32,8 @@ let authorizeUrl = null
31
32
  let pluginConfig = []
32
33
  /** @type {string | null} */
33
34
  let defaultClientId = null
35
+ /** @type {string | null} */
36
+ let defaultName = null
34
37
  /** @type {string[]} */
35
38
  let legacyClientIds = []
36
39
 
@@ -52,6 +55,7 @@ async function initializeUI () {
52
55
  document.getElementById('cancelAuthButton')?.addEventListener('click', showAuthRequired)
53
56
  document.getElementById('retryButton')?.addEventListener('click', showAuthRequired)
54
57
  document.getElementById('logoutButton')?.addEventListener('click', logout)
58
+ document.getElementById('reauthButton')?.addEventListener('click', startAuthorization)
55
59
  getInput('callbackInput')?.addEventListener('keydown', (event) => {
56
60
  if (event.key === 'Enter') finishAuthorization()
57
61
  })
@@ -60,7 +64,7 @@ async function initializeUI () {
60
64
 
61
65
  // Load auth config and check authentication status
62
66
  await loadAuthConfig()
63
- checkAuthStatus()
67
+ await checkAuthStatus()
64
68
  }
65
69
 
66
70
  // Initialize on ready
@@ -78,6 +82,7 @@ function showSection (sectionToShow, options = {}) {
78
82
  'authRequired',
79
83
  'authCodeSection',
80
84
  'authSuccess',
85
+ 'authExpired',
81
86
  'errorSection'
82
87
  ]
83
88
 
@@ -153,14 +158,13 @@ function getErrorMessage (error, fallback) {
153
158
  async function loadAuthConfig () {
154
159
  try {
155
160
  pluginConfig = await homebridge.getPluginConfig()
156
- if (!pluginConfig.length) {
157
- pluginConfig.push({ platform: 'Yoto' })
158
- }
159
161
 
160
162
  /** @type {AuthConfigResponse} */
161
163
  const config = await homebridge.request('/auth/config')
162
164
  defaultClientId = config.defaultClientId
165
+ defaultName = config.defaultName
163
166
  legacyClientIds = config.legacyClientIds
167
+ getPlatformBlock()
164
168
 
165
169
  const redirectUriDisplay = document.getElementById('redirectUriDisplay')
166
170
  if (redirectUriDisplay) redirectUriDisplay.textContent = config.redirectUri
@@ -178,6 +182,18 @@ async function loadAuthConfig () {
178
182
  }
179
183
  }
180
184
 
185
+ /**
186
+ * The plugin's config block, created if missing. Also fills in the name,
187
+ * which blocks saved by older versions don't have.
188
+ * @returns {YotoConfig}
189
+ */
190
+ function getPlatformBlock () {
191
+ if (!pluginConfig[0]) pluginConfig[0] = { platform: 'Yoto' }
192
+ const config = pluginConfig[0]
193
+ if (!config.name && defaultName) config.name = defaultName
194
+ return config
195
+ }
196
+
181
197
  /**
182
198
  * The client ID saved in config, ignoring retired ones
183
199
  * @returns {string | undefined}
@@ -265,19 +281,15 @@ async function finishAuthorization () {
265
281
  response: pasted,
266
282
  })
267
283
 
268
- if (!pluginConfig[0]) pluginConfig[0] = { platform: 'Yoto' }
269
- const config = pluginConfig[0]
284
+ const config = getPlatformBlock()
270
285
  config.refreshToken = result.refreshToken
271
286
  config.accessToken = result.accessToken
272
287
  config.tokenExpiresAt = result.tokenExpiresAt
273
288
 
274
- // Only store a client ID when it differs from the default, so future
275
- // default changes apply automatically.
276
- if (result.clientId && result.clientId !== defaultClientId) {
277
- config.clientId = result.clientId
278
- } else {
279
- delete config.clientId
280
- }
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
281
293
 
282
294
  await homebridge.updatePluginConfig(pluginConfig)
283
295
  await homebridge.savePluginConfig()
@@ -306,11 +318,12 @@ async function logout () {
306
318
  try {
307
319
  homebridge.showSpinner()
308
320
 
309
- if (pluginConfig[0]) {
310
- delete pluginConfig[0].refreshToken
311
- delete pluginConfig[0].accessToken
312
- delete pluginConfig[0].tokenExpiresAt
313
- }
321
+ // Blank the tokens rather than deleting them: the settings form keeps
322
+ // values the client deletes and would save the old login back
323
+ const config = getPlatformBlock()
324
+ config.refreshToken = ''
325
+ config.accessToken = ''
326
+ delete config.tokenExpiresAt
314
327
 
315
328
  await homebridge.updatePluginConfig(pluginConfig)
316
329
  await homebridge.savePluginConfig()
@@ -327,13 +340,32 @@ async function logout () {
327
340
  }
328
341
 
329
342
  /**
330
- * 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>}
331
346
  */
332
- function checkAuthStatus () {
347
+ async function checkAuthStatus () {
333
348
  const config = pluginConfig[0]
334
- if (config?.refreshToken && config?.accessToken) {
335
- showSection('authSuccess')
336
- } else {
349
+ if (!config?.refreshToken || !config?.accessToken) {
337
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)
338
369
  }
370
+ showSection('authSuccess')
339
371
  }
@@ -2,6 +2,8 @@
2
2
  <script type="module" src="client.js"></script>
3
3
 
4
4
  <div class="card card-body">
5
+ <img src="logo.png" alt="Homebridge Yoto" width="80" height="80" class="d-block mx-auto mb-3" />
6
+
5
7
  <!-- Authentication Status -->
6
8
  <div id="authStatus">
7
9
  <h4>Authentication Status</h4>
@@ -112,6 +114,23 @@
112
114
  </div>
113
115
  </div>
114
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
+
115
134
  <!-- Error Display -->
116
135
  <div id="errorSection" style="display: none">
117
136
  <div class="alert alert-danger">
Binary file
@@ -11,11 +11,13 @@ import { HomebridgePluginUiServer, RequestError } from '@homebridge/plugin-ui-ut
11
11
  import { YotoClient } from 'yoto-nodejs-client'
12
12
  import {
13
13
  DEFAULT_CLIENT_ID,
14
+ DEFAULT_NAME,
14
15
  LEGACY_CLIENT_IDS,
15
16
  OAUTH_REDIRECT_URI,
16
17
  OAUTH_SCOPES,
17
18
  } from '../lib/settings.js'
18
19
  import { createOAuthState, createPkcePair, parseAuthorizationResponse } from '../lib/utils/oauth.js'
20
+ import { readLoginProblem } from '../lib/utils/login-status.js'
19
21
 
20
22
  const AUDIENCE = 'https://api.yotoplay.com'
21
23
  /** Sign-in attempts are discarded after this long */
@@ -46,6 +48,7 @@ class YotoUiServer extends HomebridgePluginUiServer {
46
48
  this.onRequest('/auth/config', getAuthConfig)
47
49
  this.onRequest('/auth/start', startAuthorization)
48
50
  this.onRequest('/auth/exchange', exchangeAuthorizationCode)
51
+ this.onRequest('/auth/status', (/** @type {AuthStatusRequest} */ payload) => getLoginStatus(this.homebridgeStoragePath, payload))
49
52
 
50
53
  // this MUST be called when you are ready to accept requests
51
54
  this.ready()
@@ -59,6 +62,7 @@ class YotoUiServer extends HomebridgePluginUiServer {
59
62
  * Response from /auth/config endpoint
60
63
  * @typedef {Object} AuthConfigResponse
61
64
  * @property {string} defaultClientId - The default OAuth client ID
65
+ * @property {string} defaultName - The default plugin name, filled into config blocks that have none
62
66
  * @property {string[]} legacyClientIds - Client IDs that no longer support sign-in
63
67
  * @property {string} redirectUri - Redirect URI to register on a custom Yoto app
64
68
  */
@@ -70,11 +74,40 @@ class YotoUiServer extends HomebridgePluginUiServer {
70
74
  async function getAuthConfig () {
71
75
  return {
72
76
  defaultClientId: DEFAULT_CLIENT_ID,
77
+ defaultName: DEFAULT_NAME,
73
78
  legacyClientIds: LEGACY_CLIENT_IDS,
74
79
  redirectUri: OAUTH_REDIRECT_URI,
75
80
  }
76
81
  }
77
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
+
78
111
  /**
79
112
  * Request payload for /auth/start endpoint
80
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
  }
package/lib/settings.js CHANGED
@@ -15,6 +15,11 @@ export const PLUGIN_NAME = '@mdjhnson/homebridge-yoto'
15
15
  */
16
16
  export const DEFAULT_CLIENT_ID = configSchema.schema.properties.clientId.default
17
17
 
18
+ /**
19
+ * Default plugin name (shown in the Homebridge log) from config schema
20
+ */
21
+ export const DEFAULT_NAME = configSchema.schema.properties.name.default
22
+
18
23
  /**
19
24
  * OAuth redirect URI registered on the Yoto developer app. Nothing listens here:
20
25
  * the user copies the resulting address back into the plugin settings.
@@ -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",
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)"
@@ -22,6 +22,7 @@
22
22
  "devDependencies": {
23
23
  "@types/node": "^25.9.8",
24
24
  "@voxpelli/tsconfig": "^16.2.1",
25
+ "ajv": "^8.20.0",
25
26
  "auto-changelog": "^2.0.0",
26
27
  "c8": "^10.0.0",
27
28
  "eslint": "^9.39.2",