@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 +2 -2
- package/config.schema.json +12 -8
- package/homebridge-ui/public/client.js +56 -24
- package/homebridge-ui/public/index.html +19 -0
- package/homebridge-ui/public/logo.png +0 -0
- package/homebridge-ui/server.js +33 -0
- package/lib/platform.js +129 -25
- package/lib/settings.js +5 -0
- 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 +2 -1
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="
|
|
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
|
|
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
|
|
package/config.schema.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
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
|
package/homebridge-ui/server.js
CHANGED
|
@@ -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:
|
|
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
|
}
|
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
|
+
"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",
|