@mdjhnson/homebridge-yoto 0.1.2 → 0.1.4
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 +16 -11
- package/config.schema.json +11 -7
- package/lib/accessory.js +5 -2
- package/lib/platform.js +211 -97
- package/lib/speaker-accessory.js +8 -3
- package/lib/television-accessory.js +5 -2
- package/lib/utils/error-format.js +23 -0
- package/lib/utils/listener-group.js +39 -4
- package/package.json +13 -4
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="
|
|
5
|
+
<img src="https://raw.githubusercontent.com/mdjhnson/homebridge-yoto/master/logo.png" width="150">
|
|
6
6
|
|
|
7
7
|
</p>
|
|
8
8
|
|
|
@@ -33,16 +33,16 @@ Search for `@mdjhnson/homebridge-yoto` in the Homebridge UI **Plugins** tab, or
|
|
|
33
33
|
npm install -g @mdjhnson/homebridge-yoto
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
Requires Node.js 22
|
|
36
|
+
Requires Node.js 22, 24 or 26 and Homebridge 1.8+ or 2.x.
|
|
37
37
|
|
|
38
38
|
## Sign in
|
|
39
39
|
|
|
40
40
|
1. Open the plugin's **Settings** in the Homebridge UI and click **Sign in with Yoto**. Yoto's sign-in page opens in a new tab.
|
|
41
41
|
2. Sign in and approve access.
|
|
42
|
-
3. Your browser then shows *"This site can't be reached"* at `127.0.0.1`. That's expected. Copy the full address from the address bar, paste it into the
|
|
42
|
+
3. Your browser then shows *"This site can't be reached"* at `127.0.0.1`. That's expected. Copy the full address from the address bar, paste it into the **Address from your browser** box on the sign-in screen, and click **Finish Sign-in**.
|
|
43
43
|
4. Restart Homebridge.
|
|
44
44
|
|
|
45
|
-
Using your own Yoto developer app? Make it a **Public Client**, add `http://127.0.0.1:8787/callback` as an allowed callback URL, enable the `family:devices:*`, `family:library:view`, `user:content:view` and `offline_access` scopes, and enter its client ID
|
|
45
|
+
Using your own Yoto developer app? Make it a **Public Client**, add `http://127.0.0.1:8787/callback` as an allowed callback URL, enable the `family:devices:*`, `family:library:view`, `user:content:view` and `offline_access` scopes, and enter its client ID in the **Advanced Settings** panel on the sign-in screen before signing in. (That panel is only shown while you're signed out, and is separate from the **Advanced** section of the plugin settings.)
|
|
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). If you signed in with an older version, sign in again so the new permissions apply.
|
|
48
48
|
|
|
@@ -50,14 +50,13 @@ The plugin refreshes its tokens on its own. If the login ever expires or is revo
|
|
|
50
50
|
|
|
51
51
|
## Settings
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
Most options live under **Accessory Services** in the plugin settings. The settings form, including **Advanced**, appears once you've signed in.
|
|
54
54
|
|
|
55
55
|
**Playback**
|
|
56
|
-
- **Playback Controls**: Adds a play/pause switch and a volume dimmer to each player's bridged accessory.
|
|
57
|
-
- **
|
|
58
|
-
- **TV Playback Accessory**: Publishes a separate TV-style accessory. Its inputs play your card controls and shortcuts, and you control it with the iOS remote.
|
|
56
|
+
- **Playback Controls**: Adds a play/pause switch and a volume dimmer to each player's bridged accessory. This is the simplest option and needs no extra pairing.
|
|
57
|
+
- **TV Playback Accessory**: Publishes a separate TV-style accessory. You control it with the iOS remote (play/pause, volume buttons), and its inputs play your card controls and shortcuts.
|
|
59
58
|
|
|
60
|
-
External accessories must be added by hand in the Home app (**Add Accessory → More options**) using the setup code in the Homebridge log. Each one listens on its own port (logged as `... is running on port N`), so open those ports if Homebridge runs behind a firewall, or set a fixed port range under Homebridge **Settings → Network**.
|
|
59
|
+
The TV accessory, and the legacy Smart Speaker below, are external accessories. External accessories must be added by hand in the Home app (**Add Accessory → More options**) using the setup code in the Homebridge log. Each one listens on its own port (logged as `... is running on port N`), so open those ports if Homebridge runs behind a firewall, or set a fixed port range under Homebridge **Settings → Network**.
|
|
61
60
|
|
|
62
61
|
**Card Controls** (`services.cardControls`)
|
|
63
62
|
- A switch on each player that plays the card ID you configure.
|
|
@@ -70,8 +69,9 @@ External accessories must be added by hand in the Home app (**Add Accessory →
|
|
|
70
69
|
**Service toggles**
|
|
71
70
|
- **Battery**, **Temperature Sensor** (v3), **Nightlight** (v3), **Card Slot**, **Day Mode**, **Sleep Timer**, **Bluetooth**, **Volume Limits**.
|
|
72
71
|
|
|
73
|
-
**Advanced**
|
|
72
|
+
**Advanced** (collapsed section at the bottom of the plugin settings)
|
|
74
73
|
- **HTTP Poll Interval**: How often to poll the Yoto API as a fallback to MQTT. Defaults to 60 seconds; the minimum is 10 seconds.
|
|
74
|
+
- **External Smart Speaker (Legacy)**: Kept for existing setups. It publishes a separate Smart Speaker accessory that only works in Home app scenes and automations. When you open it, the Home app shows *"Controls not available"* and can't show what's playing, because iOS only offers live controls for AirPlay speakers. It does not make the Yoto an AirPlay target. For more features, use the **TV Playback Accessory** instead. If you switch, remove the old Smart Speaker from the Home app by hand (Homebridge can't unpublish external accessories).
|
|
75
75
|
|
|
76
76
|
## HomeKit services
|
|
77
77
|
|
|
@@ -79,7 +79,7 @@ External accessories must be added by hand in the Home app (**Add Accessory →
|
|
|
79
79
|
- **Playback**: Switch. On resumes, Off pauses.
|
|
80
80
|
- **Volume**: Lightbulb. On unmutes, Off mutes, and Brightness maps 0–100% to the player's volume steps.
|
|
81
81
|
|
|
82
|
-
**Smart Speaker (external)**
|
|
82
|
+
**Smart Speaker (external, legacy)**
|
|
83
83
|
- Current/Target Media State, Volume, Mute, and online status. Stop pauses, so playback can be resumed.
|
|
84
84
|
|
|
85
85
|
**TV Playback (external)**
|
|
@@ -110,6 +110,11 @@ External accessories must be added by hand in the Home app (**Add Accessory →
|
|
|
110
110
|
|
|
111
111
|
- **Switching from `homebridge-yoto`:** uninstall the original plugin first. Both register the `Yoto` platform and would conflict. Your existing `Yoto` config block keeps working, but bridged accessories are re-created, so you'll need to re-add them to rooms and automations.
|
|
112
112
|
- **Removed external accessories:** if you turn off the Smart Speaker or TV accessory, or a player leaves your account, remove the old accessory from the Home app by hand. Homebridge can't unpublish external accessories.
|
|
113
|
+
- **Yoto unreachable at startup:** if the network or Yoto's API is down when Homebridge starts, the plugin keeps retrying, waiting longer each time (up to 10 minutes between tries). You don't need to restart Homebridge. If Yoto rejects the saved login, the log asks you to sign in again instead of retrying.
|
|
114
|
+
|
|
115
|
+
## Privacy
|
|
116
|
+
|
|
117
|
+
The plugin only connects to Yoto's API and MQTT service, using your own sign-in. It has no analytics or tracking. The only file it changes is Homebridge's `config.json`, to save refreshed sign-in tokens. It writes a temporary copy next to `config.json` first and renames it into place, so an interrupted save can't corrupt the file.
|
|
113
118
|
|
|
114
119
|
## Development
|
|
115
120
|
|
package/config.schema.json
CHANGED
|
@@ -61,13 +61,13 @@
|
|
|
61
61
|
"title": "Playback Controls",
|
|
62
62
|
"type": "boolean",
|
|
63
63
|
"default": false,
|
|
64
|
-
"description": "
|
|
64
|
+
"description": "Add a play/pause switch and a volume dimmer to each player. Simplest option, with no extra pairing."
|
|
65
65
|
},
|
|
66
66
|
"smartSpeaker": {
|
|
67
|
-
"title": "External Smart Speaker",
|
|
67
|
+
"title": "External Smart Speaker (Legacy)",
|
|
68
68
|
"type": "boolean",
|
|
69
69
|
"default": false,
|
|
70
|
-
"description": "
|
|
70
|
+
"description": "Legacy option kept for existing setups. Publishes a separate Smart Speaker accessory that only works in Home app scenes and automations. Requires pairing and its own network port."
|
|
71
71
|
},
|
|
72
72
|
"television": {
|
|
73
73
|
"title": "TV Playback Accessory",
|
|
@@ -171,7 +171,7 @@
|
|
|
171
171
|
"items": [
|
|
172
172
|
{
|
|
173
173
|
"type": "help",
|
|
174
|
-
"helpvalue": "
|
|
174
|
+
"helpvalue": "Sign in and out from the Yoto panel above. The default OAuth Client ID works for all users. The token fields below are managed by the plugin and should not be edited by hand."
|
|
175
175
|
},
|
|
176
176
|
"clientId",
|
|
177
177
|
"accessToken",
|
|
@@ -188,10 +188,9 @@
|
|
|
188
188
|
},
|
|
189
189
|
{
|
|
190
190
|
"type": "help",
|
|
191
|
-
"helpvalue": "<p
|
|
191
|
+
"helpvalue": "<p>The older <strong>External Smart Speaker</strong> option has moved to the <em>Advanced</em> section below.</p>"
|
|
192
192
|
},
|
|
193
193
|
"services.playbackControls",
|
|
194
|
-
"services.smartSpeaker",
|
|
195
194
|
"services.television",
|
|
196
195
|
"services.volumeLimits",
|
|
197
196
|
"services.battery",
|
|
@@ -225,7 +224,12 @@
|
|
|
225
224
|
"expandable": true,
|
|
226
225
|
"expanded": false,
|
|
227
226
|
"items": [
|
|
228
|
-
"httpPollIntervalMs"
|
|
227
|
+
"httpPollIntervalMs",
|
|
228
|
+
{
|
|
229
|
+
"type": "help",
|
|
230
|
+
"helpvalue": "<p><strong>Legacy: External Smart Speaker.</strong> Older playback option kept for existing setups. It only works in scenes and automations. The Home app shows \"Controls not available\" for it and can't show what's playing, because iOS only offers those for AirPlay speakers. For more features, turn on the <strong>TV Playback Accessory</strong> under <em>Accessory Services</em> instead. It supports the iPhone remote, volume buttons, and inputs for card controls and shortcuts. If you switch, remove the old Smart Speaker from the Home app by hand.</p>"
|
|
231
|
+
},
|
|
232
|
+
"services.smartSpeaker"
|
|
229
233
|
]
|
|
230
234
|
}
|
|
231
235
|
]
|
package/lib/accessory.js
CHANGED
|
@@ -50,7 +50,7 @@ import { getPlaybackAccessoryConfig } from './service-config.js'
|
|
|
50
50
|
import { getCardControlConfigs } from './card-controls.js'
|
|
51
51
|
import { getCardStartOptions, getShortcutEntries, getShortcutsSignature, toPlayableCard } from './shortcuts.js'
|
|
52
52
|
import { formatError } from './utils/error-format.js'
|
|
53
|
-
import { ListenerGroup } from './utils/listener-group.js'
|
|
53
|
+
import { ListenerGroup, logListenerError } from './utils/listener-group.js'
|
|
54
54
|
import { setDeviceVolume } from './utils/set-device-volume.js'
|
|
55
55
|
import { getBooleanSetting } from './utils/get-boolean-setting.js'
|
|
56
56
|
import {
|
|
@@ -118,7 +118,10 @@ export class YotoPlayerAccessory {
|
|
|
118
118
|
this.#platform = platform
|
|
119
119
|
this.#accessory = accessory
|
|
120
120
|
this.#deviceModel = deviceModel
|
|
121
|
-
this.#listeners = new ListenerGroup(
|
|
121
|
+
this.#listeners = new ListenerGroup(
|
|
122
|
+
deviceModel,
|
|
123
|
+
logListenerError(platform.log, `${LOG_PREFIX.ACCESSORY} [${accessory.context.device.name}]`)
|
|
124
|
+
)
|
|
122
125
|
this.#log = platform.log
|
|
123
126
|
|
|
124
127
|
// Extract device info from context
|
package/lib/platform.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
/** @import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge' */
|
|
6
6
|
/** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
|
|
7
7
|
/** @import { YotoDeviceModel } from 'yoto-nodejs-client' */
|
|
8
|
+
/** @import { YotoAccountEventMap } from 'yoto-nodejs-client/lib/yoto-account.js' */
|
|
8
9
|
/** @import { PlaybackAccessoryConfig } from './service-config.js' */
|
|
9
10
|
/** @import { CardControlConfig } from './card-controls.js' */
|
|
10
11
|
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
PLATFORM_NAME,
|
|
30
31
|
PLUGIN_NAME,
|
|
31
32
|
DEFAULT_CLIENT_ID,
|
|
33
|
+
LOG_PREFIX,
|
|
32
34
|
} from './settings.js'
|
|
33
35
|
import { YotoPlayerAccessory } from './accessory.js'
|
|
34
36
|
import { YotoSpeakerAccessory } from './speaker-accessory.js'
|
|
@@ -37,10 +39,28 @@ import { YotoCardControlAccessory } from './card-control-accessory.js'
|
|
|
37
39
|
import { sanitizeName } from './utils/sanitize-name.js'
|
|
38
40
|
import { getPlaybackAccessoryConfig } from './service-config.js'
|
|
39
41
|
import { getCardControlConfigs } from './card-controls.js'
|
|
40
|
-
import { formatError } from './utils/error-format.js'
|
|
42
|
+
import { errorMessage, formatError, getStatusCode } from './utils/error-format.js'
|
|
43
|
+
import { ListenerGroup, logListenerError } from './utils/listener-group.js'
|
|
41
44
|
import { applyTokenUpdate } from './utils/token-config.js'
|
|
42
45
|
import { tolerateMissingStatusScope } from './utils/status-scope-fallback.js'
|
|
43
46
|
|
|
47
|
+
/** First retry delay when the Yoto API can't be reached at startup; doubles each attempt */
|
|
48
|
+
const START_RETRY_BASE_MS = 30 * 1000
|
|
49
|
+
/** Longest wait between startup retries */
|
|
50
|
+
const START_RETRY_MAX_MS = 10 * 60 * 1000
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Whether a failed account start could succeed if tried again. Network errors
|
|
54
|
+
* and 5xx responses are transient; other 4xx responses won't change on retry.
|
|
55
|
+
* @param {unknown} error
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
function isRetryableStartError (error) {
|
|
59
|
+
const statusCode = getStatusCode(error)
|
|
60
|
+
if (statusCode === undefined) return true
|
|
61
|
+
return statusCode >= 500 || statusCode === 408 || statusCode === 429
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
/**
|
|
45
65
|
* Yoto Platform implementation
|
|
46
66
|
* This class is the main constructor for your plugin, this is where you should
|
|
@@ -63,8 +83,13 @@ export class YotoPlatform {
|
|
|
63
83
|
/** @type {Map<string, YotoTelevisionAccessory>} */ televisionAccessoryHandlers = new Map()
|
|
64
84
|
/** @type {Map<string, YotoCardControlAccessory>} */ cardAccessoryHandlers = new Map()
|
|
65
85
|
/** @type {YotoAccount | null} */ yotoAccount = null
|
|
86
|
+
/** @type {ListenerGroup<YotoAccountEventMap> | null} */ accountListeners = null
|
|
66
87
|
/** @type {string} */ sessionId = randomUUID()
|
|
67
88
|
/** @type {Map<string, Promise<string | null>>} */ cardTitleCache = new Map()
|
|
89
|
+
/** @type {boolean} */ authInvalid = false
|
|
90
|
+
/** @type {number} */ startRetryCount = 0
|
|
91
|
+
/** @type {ReturnType<typeof setTimeout> | null} */ startRetryTimer = null
|
|
92
|
+
/** @type {boolean} */ shuttingDown = false
|
|
68
93
|
|
|
69
94
|
/**
|
|
70
95
|
* @param {Logger} log - Homebridge logger
|
|
@@ -102,63 +127,83 @@ export class YotoPlatform {
|
|
|
102
127
|
const { sessionId } = this
|
|
103
128
|
const updateHomebridgeConfig = this.updateHomebridgeConfig.bind(this)
|
|
104
129
|
|
|
105
|
-
// Initialize YotoAccount with client and device options
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
'Yoto
|
|
140
|
-
error
|
|
141
|
-
|
|
130
|
+
// Initialize YotoAccount with client and device options. It decodes the saved
|
|
131
|
+
// access token, which throws if config.json holds a malformed one; Homebridge
|
|
132
|
+
// doesn't catch errors from platform constructors, so it would crash on every start.
|
|
133
|
+
/** @type {YotoAccount} */
|
|
134
|
+
let account
|
|
135
|
+
try {
|
|
136
|
+
account = new YotoAccount({
|
|
137
|
+
clientOptions: {
|
|
138
|
+
clientId,
|
|
139
|
+
refreshToken,
|
|
140
|
+
accessToken,
|
|
141
|
+
onTokenRefresh: async ({ updatedAccessToken, updatedRefreshToken, updatedExpiresAt, prevAccessToken, prevRefreshToken }) => {
|
|
142
|
+
// Client reports expiry in seconds; config.json stores milliseconds (matches the UI)
|
|
143
|
+
const tokenExpiresAt = updatedExpiresAt * 1000
|
|
144
|
+
log.debug('Access token refreshed, expires at:', new Date(tokenExpiresAt).toISOString())
|
|
145
|
+
|
|
146
|
+
// Keep the in-memory config in sync so later reads see current tokens
|
|
147
|
+
config['accessToken'] = updatedAccessToken
|
|
148
|
+
config['refreshToken'] = updatedRefreshToken
|
|
149
|
+
config['tokenExpiresAt'] = tokenExpiresAt
|
|
150
|
+
|
|
151
|
+
await updateHomebridgeConfig((configContents) => applyTokenUpdate(configContents, {
|
|
152
|
+
platform: PLATFORM_NAME,
|
|
153
|
+
accessToken: updatedAccessToken,
|
|
154
|
+
refreshToken: updatedRefreshToken,
|
|
155
|
+
tokenExpiresAt,
|
|
156
|
+
prevAccessToken,
|
|
157
|
+
prevRefreshToken,
|
|
158
|
+
}))
|
|
159
|
+
},
|
|
160
|
+
onRefreshStart: () => {
|
|
161
|
+
log.debug('Refreshing Yoto access token...')
|
|
162
|
+
},
|
|
163
|
+
onRefreshError: (error) => {
|
|
164
|
+
log.warn('Yoto token refresh failed, will retry:', error.message)
|
|
165
|
+
log.debug('Token refresh error details:', formatError(error))
|
|
166
|
+
},
|
|
167
|
+
onInvalid: (error) => {
|
|
168
|
+
this.authInvalid = true
|
|
169
|
+
log.error(
|
|
170
|
+
'Yoto login has expired or been revoked. Open the plugin settings in the Homebridge UI and sign in again.',
|
|
171
|
+
error.message
|
|
172
|
+
)
|
|
173
|
+
},
|
|
142
174
|
},
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
175
|
+
deviceOptions: {
|
|
176
|
+
httpPollIntervalMs: Math.max(10000, Number(config['httpPollIntervalMs']) || 60000),
|
|
177
|
+
yotoDeviceMqttOptions: {
|
|
178
|
+
sessionId
|
|
179
|
+
}
|
|
148
180
|
}
|
|
149
|
-
}
|
|
150
|
-
})
|
|
181
|
+
})
|
|
182
|
+
} catch (error) {
|
|
183
|
+
log.error(
|
|
184
|
+
'Could not load the saved Yoto login. Open the plugin settings in the Homebridge UI and sign in again.',
|
|
185
|
+
errorMessage(error)
|
|
186
|
+
)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
this.yotoAccount = account
|
|
190
|
+
// The account re-emits device events from MQTT and timer callbacks, so a
|
|
191
|
+
// throwing listener would be an uncaught exception that stops Homebridge
|
|
192
|
+
this.accountListeners = new ListenerGroup(account, logListenerError(log, LOG_PREFIX.PLATFORM))
|
|
151
193
|
|
|
152
194
|
// The status endpoint needs a scope the Yoto dashboard doesn't offer yet
|
|
153
195
|
tolerateMissingStatusScope(this.yotoAccount.client, (message) => log.debug(message))
|
|
154
196
|
|
|
155
197
|
// Listen to account-level events
|
|
156
|
-
this.
|
|
198
|
+
this.accountListeners.on('error', ({ error, context }) => {
|
|
157
199
|
const details = formatError(error)
|
|
158
200
|
if (context.deviceId) {
|
|
159
201
|
const label = this.formatDeviceLabel(context.deviceId)
|
|
160
202
|
log.error(`Device error [${label} ${context.operation} ${context.source}]:`, details)
|
|
161
203
|
log.debug('Device error context:', context)
|
|
204
|
+
} else if (context.operation === 'start') {
|
|
205
|
+
// connectAccount() logs the failure and schedules a retry
|
|
206
|
+
log.debug('Account start error:', details)
|
|
162
207
|
} else {
|
|
163
208
|
log.error('Account error:', details)
|
|
164
209
|
log.debug('Account error context:', context)
|
|
@@ -178,7 +223,9 @@ export class YotoPlatform {
|
|
|
178
223
|
// When homebridge shuts down, cleanup all handlers and MQTT connections
|
|
179
224
|
api.on('shutdown', () => {
|
|
180
225
|
log.debug('Homebridge shutting down, cleaning up accessories...')
|
|
181
|
-
this.shutdown()
|
|
226
|
+
this.shutdown().catch(error => {
|
|
227
|
+
log.error('Error while shutting down:', formatError(error))
|
|
228
|
+
})
|
|
182
229
|
})
|
|
183
230
|
}
|
|
184
231
|
|
|
@@ -215,7 +262,8 @@ export class YotoPlatform {
|
|
|
215
262
|
* Start YotoAccount - discovers devices and creates device models
|
|
216
263
|
*/
|
|
217
264
|
async startAccount () {
|
|
218
|
-
|
|
265
|
+
const listeners = this.accountListeners
|
|
266
|
+
if (!this.yotoAccount || !listeners) {
|
|
219
267
|
this.log.error('Cannot start account - YotoAccount not initialized')
|
|
220
268
|
return
|
|
221
269
|
}
|
|
@@ -229,37 +277,45 @@ export class YotoPlatform {
|
|
|
229
277
|
`smartSpeaker=${this.playbackAccessoryConfig.smartSpeakerEnabled}`,
|
|
230
278
|
`tvPlayback=${this.playbackAccessoryConfig.televisionEnabled}`
|
|
231
279
|
)
|
|
280
|
+
if (this.playbackAccessoryConfig.smartSpeakerEnabled && !this.playbackAccessoryConfig.televisionEnabled) {
|
|
281
|
+
this.log.info('The External Smart Speaker is a legacy option: the Home app shows "Controls not available" for it. For the iPhone remote, volume buttons, and inputs for card controls and shortcuts, turn on the TV Playback Accessory instead.')
|
|
282
|
+
}
|
|
232
283
|
|
|
233
284
|
// Listen for devices being added
|
|
234
|
-
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
285
|
+
listeners.on('deviceAdded', async ({ deviceId }) => {
|
|
286
|
+
// The account doesn't await this listener, so errors must not escape it
|
|
287
|
+
try {
|
|
288
|
+
// Homebridge may shut down while the account is still starting
|
|
289
|
+
if (this.shuttingDown || !this.yotoAccount) return
|
|
290
|
+
const deviceModel = this.yotoAccount.getDevice(deviceId)
|
|
291
|
+
if (!deviceModel) {
|
|
292
|
+
const label = this.formatDeviceLabel(deviceId)
|
|
293
|
+
this.log.warn(`Device added but no model found for ${label}`)
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const device = deviceModel.device
|
|
298
|
+
this.log.info(`Device discovered: ${device.name} (${deviceId})`)
|
|
299
|
+
this.log.debug('Registering device from account discovery:', device.name, deviceId)
|
|
300
|
+
await this.registerDevice(device, deviceModel)
|
|
301
|
+
} catch (error) {
|
|
302
|
+
this.log.error(`Failed to set up ${this.formatDeviceLabel(deviceId)}:`, formatError(error))
|
|
242
303
|
}
|
|
243
|
-
|
|
244
|
-
const device = deviceModel.device
|
|
245
|
-
this.log.info(`Device discovered: ${device.name} (${deviceId})`)
|
|
246
|
-
this.log.debug('Registering device from account discovery:', device.name, deviceId)
|
|
247
|
-
await this.registerDevice(device, deviceModel)
|
|
248
304
|
})
|
|
249
305
|
|
|
250
|
-
|
|
306
|
+
listeners.on('deviceRemoved', ({ deviceId }) => {
|
|
251
307
|
const label = this.formatDeviceLabel(deviceId)
|
|
252
308
|
this.log.debug(`Device removed: ${label}`)
|
|
253
309
|
this.removeStaleAccessories()
|
|
254
310
|
})
|
|
255
311
|
|
|
256
|
-
|
|
312
|
+
listeners.on('online', ({ deviceId, metadata }) => {
|
|
257
313
|
const label = this.formatDeviceLabel(deviceId)
|
|
258
314
|
const reason = metadata?.reason ? ` (${metadata.reason})` : ''
|
|
259
315
|
this.log.info(`Device online: ${label}${reason}`)
|
|
260
316
|
})
|
|
261
317
|
|
|
262
|
-
|
|
318
|
+
listeners.on('offline', ({ deviceId, metadata }) => {
|
|
263
319
|
const label = this.formatDeviceLabel(deviceId)
|
|
264
320
|
const reason = metadata?.reason ? ` (${metadata.reason})` : ''
|
|
265
321
|
this.log.info(`Device offline: ${label}${reason}`)
|
|
@@ -279,74 +335,74 @@ export class YotoPlatform {
|
|
|
279
335
|
return ` fields: ${preview}${suffix}`
|
|
280
336
|
}
|
|
281
337
|
|
|
282
|
-
|
|
338
|
+
listeners.on('statusUpdate', ({ deviceId, source, changedFields }) => {
|
|
283
339
|
const label = this.formatDeviceLabel(deviceId)
|
|
284
340
|
const fields = Array.from(changedFields).join(', ')
|
|
285
341
|
this.log.debug(`Status update [${label} ${source}]: ${fields}`)
|
|
286
342
|
})
|
|
287
343
|
|
|
288
|
-
|
|
344
|
+
listeners.on('configUpdate', ({ deviceId, changedFields }) => {
|
|
289
345
|
const label = this.formatDeviceLabel(deviceId)
|
|
290
346
|
const fields = Array.from(changedFields).join(', ')
|
|
291
347
|
this.log.debug(`Config update [${label}]: ${fields}`)
|
|
292
348
|
})
|
|
293
349
|
|
|
294
|
-
|
|
350
|
+
listeners.on('playbackUpdate', ({ deviceId, changedFields }) => {
|
|
295
351
|
const label = this.formatDeviceLabel(deviceId)
|
|
296
352
|
const fields = Array.from(changedFields).join(', ')
|
|
297
353
|
this.log.debug(`Playback update [${label}]: ${fields}`)
|
|
298
354
|
})
|
|
299
355
|
|
|
300
|
-
|
|
356
|
+
listeners.on('mqttConnect', ({ deviceId }) => {
|
|
301
357
|
const label = this.formatDeviceLabel(deviceId)
|
|
302
358
|
this.log.debug(`MQTT connected: ${label}`)
|
|
303
359
|
})
|
|
304
360
|
|
|
305
|
-
|
|
361
|
+
listeners.on('mqttDisconnect', ({ deviceId, metadata }) => {
|
|
306
362
|
const label = this.formatDeviceLabel(deviceId)
|
|
307
363
|
const reasonCode = metadata?.packet?.reasonCode
|
|
308
364
|
const reason = typeof reasonCode === 'number' ? ` (code ${reasonCode})` : ''
|
|
309
365
|
this.log.warn(`MQTT disconnected: ${label}${reason}`)
|
|
310
366
|
})
|
|
311
367
|
|
|
312
|
-
|
|
368
|
+
listeners.on('mqttClose', ({ deviceId, metadata }) => {
|
|
313
369
|
const label = this.formatDeviceLabel(deviceId)
|
|
314
370
|
const reason = metadata?.reason ? ` (${metadata.reason})` : ''
|
|
315
371
|
this.log.debug(`MQTT closed: ${label}${reason}`)
|
|
316
372
|
})
|
|
317
373
|
|
|
318
|
-
|
|
374
|
+
listeners.on('mqttReconnect', ({ deviceId }) => {
|
|
319
375
|
const label = this.formatDeviceLabel(deviceId)
|
|
320
376
|
this.log.debug(`MQTT reconnecting: ${label}`)
|
|
321
377
|
})
|
|
322
378
|
|
|
323
|
-
|
|
379
|
+
listeners.on('mqttOffline', ({ deviceId }) => {
|
|
324
380
|
const label = this.formatDeviceLabel(deviceId)
|
|
325
381
|
this.log.debug(`MQTT offline: ${label}`)
|
|
326
382
|
})
|
|
327
383
|
|
|
328
|
-
|
|
384
|
+
listeners.on('mqttEnd', ({ deviceId }) => {
|
|
329
385
|
const label = this.formatDeviceLabel(deviceId)
|
|
330
386
|
this.log.debug(`MQTT ended: ${label}`)
|
|
331
387
|
})
|
|
332
388
|
|
|
333
|
-
|
|
389
|
+
listeners.on('mqttStatus', ({ deviceId, topic }) => {
|
|
334
390
|
const label = this.formatDeviceLabel(deviceId)
|
|
335
391
|
this.log.debug(`MQTT status [${label}]: ${topic}`)
|
|
336
392
|
})
|
|
337
393
|
|
|
338
|
-
|
|
394
|
+
listeners.on('mqttEvents', ({ deviceId, topic }) => {
|
|
339
395
|
const label = this.formatDeviceLabel(deviceId)
|
|
340
396
|
this.log.debug(`MQTT events [${label}]: ${topic}`)
|
|
341
397
|
})
|
|
342
398
|
|
|
343
|
-
|
|
399
|
+
listeners.on('mqttStatusLegacy', ({ deviceId, topic, message }) => {
|
|
344
400
|
const label = this.formatDeviceLabel(deviceId)
|
|
345
401
|
const fields = formatLegacyStatusFields(message)
|
|
346
402
|
this.log.debug(`MQTT legacy status [${label}]: ${topic}${fields}`)
|
|
347
403
|
})
|
|
348
404
|
|
|
349
|
-
|
|
405
|
+
listeners.on('mqttResponse', ({ deviceId, topic, message }) => {
|
|
350
406
|
const label = this.formatDeviceLabel(deviceId)
|
|
351
407
|
let payload = ''
|
|
352
408
|
try {
|
|
@@ -357,32 +413,80 @@ export class YotoPlatform {
|
|
|
357
413
|
this.log.debug(`MQTT response [${label}]: ${topic}${payload}`)
|
|
358
414
|
})
|
|
359
415
|
|
|
360
|
-
|
|
416
|
+
listeners.on('mqttUnknown', ({ deviceId, topic }) => {
|
|
361
417
|
const label = this.formatDeviceLabel(deviceId)
|
|
362
418
|
this.log.debug(`MQTT unknown [${label}]: ${topic}`)
|
|
363
419
|
})
|
|
364
420
|
|
|
365
|
-
|
|
366
|
-
|
|
421
|
+
await this.connectAccount()
|
|
422
|
+
} catch (error) {
|
|
423
|
+
this.log.error('Failed to start account:', errorMessage(error))
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Start the account (discovers devices, creates device models, starts MQTT).
|
|
429
|
+
* If the Yoto API can't be reached, e.g. because the network isn't up yet
|
|
430
|
+
* when Homebridge boots, retry with backoff instead of giving up. Errors that
|
|
431
|
+
* a retry won't fix (an invalid login, other 4xx responses) are logged once.
|
|
432
|
+
* @returns {Promise<void>}
|
|
433
|
+
*/
|
|
434
|
+
async connectAccount () {
|
|
435
|
+
const account = this.yotoAccount
|
|
436
|
+
if (!account || this.shuttingDown) return
|
|
437
|
+
|
|
438
|
+
try {
|
|
367
439
|
await account.start()
|
|
440
|
+
} catch (error) {
|
|
441
|
+
// Homebridge shut down while the account was starting
|
|
442
|
+
if (this.shuttingDown) return
|
|
443
|
+
if (this.authInvalid) {
|
|
444
|
+
this.log.error('Failed to start account:', errorMessage(error))
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
const statusCode = getStatusCode(error)
|
|
448
|
+
if (statusCode === 401 || statusCode === 403) {
|
|
449
|
+
this.log.error(
|
|
450
|
+
`Yoto rejected the saved login (HTTP ${statusCode}). Open the plugin settings in the Homebridge UI and sign in again.`,
|
|
451
|
+
formatError(error)
|
|
452
|
+
)
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
if (!isRetryableStartError(error)) {
|
|
456
|
+
this.log.error('Failed to start account:', formatError(error))
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
const delayMs = Math.min(START_RETRY_MAX_MS, START_RETRY_BASE_MS * 2 ** this.startRetryCount)
|
|
460
|
+
this.startRetryCount++
|
|
461
|
+
this.log.warn(`Could not connect to Yoto (${errorMessage(error)}). Retrying in ${Math.round(delayMs / 1000)} seconds.`)
|
|
462
|
+
this.startRetryTimer = setTimeout(() => {
|
|
463
|
+
this.startRetryTimer = null
|
|
464
|
+
this.connectAccount().catch(retryError => {
|
|
465
|
+
this.log.error('Failed to start account:', formatError(retryError))
|
|
466
|
+
})
|
|
467
|
+
}, delayMs)
|
|
468
|
+
return
|
|
469
|
+
}
|
|
368
470
|
|
|
369
|
-
|
|
370
|
-
|
|
471
|
+
// Homebridge shut down while the account was starting. shutdown()'s stop() ran
|
|
472
|
+
// before start() finished, so stop the device models and MQTT start() just opened.
|
|
473
|
+
if (this.shuttingDown) {
|
|
474
|
+
await account.stop()
|
|
475
|
+
return
|
|
476
|
+
}
|
|
371
477
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
)
|
|
478
|
+
this.startRetryCount = 0
|
|
479
|
+
this.log.info(`✓ Yoto account started with ${account.devices.size} device(s)`)
|
|
480
|
+
this.log.debug(
|
|
481
|
+
'Account devices:',
|
|
482
|
+
Array.from(account.devices.keys()).join(', ') || 'none'
|
|
483
|
+
)
|
|
377
484
|
|
|
378
|
-
|
|
379
|
-
|
|
485
|
+
// Remove stale accessories after all devices are registered
|
|
486
|
+
this.removeStaleAccessories()
|
|
380
487
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
} catch (error) {
|
|
384
|
-
this.log.error('Failed to start account:', error instanceof Error ? error.message : String(error))
|
|
385
|
-
}
|
|
488
|
+
this.log.debug('Registering card control accessories (playOnAll).')
|
|
489
|
+
await this.registerCardControlAccessories()
|
|
386
490
|
}
|
|
387
491
|
|
|
388
492
|
/**
|
|
@@ -925,6 +1029,11 @@ export class YotoPlatform {
|
|
|
925
1029
|
*/
|
|
926
1030
|
async shutdown () {
|
|
927
1031
|
this.log.debug('Shutting down Yoto platform...')
|
|
1032
|
+
this.shuttingDown = true
|
|
1033
|
+
if (this.startRetryTimer) {
|
|
1034
|
+
clearTimeout(this.startRetryTimer)
|
|
1035
|
+
this.startRetryTimer = null
|
|
1036
|
+
}
|
|
928
1037
|
this.log.debug(
|
|
929
1038
|
'Handlers to stop:',
|
|
930
1039
|
`devices=${this.accessoryHandlers.size}`,
|
|
@@ -1001,7 +1110,13 @@ export class YotoPlatform {
|
|
|
1001
1110
|
// Keep the original file mode, since config.json holds tokens.
|
|
1002
1111
|
const { mode } = await stat(configPath)
|
|
1003
1112
|
const tmpPath = `${configPath}.${process.pid}.tmp`
|
|
1004
|
-
|
|
1113
|
+
try {
|
|
1114
|
+
await writeFile(tmpPath, updatedContents, { encoding: 'utf8', mode: mode & 0o777 })
|
|
1115
|
+
} catch (writeError) {
|
|
1116
|
+
// Don't leave a partial copy of the tokens next to config.json
|
|
1117
|
+
await unlink(tmpPath).catch(() => {})
|
|
1118
|
+
throw writeError
|
|
1119
|
+
}
|
|
1005
1120
|
try {
|
|
1006
1121
|
await rename(tmpPath, configPath)
|
|
1007
1122
|
} catch (renameError) {
|
|
@@ -1012,8 +1127,7 @@ export class YotoPlatform {
|
|
|
1012
1127
|
}
|
|
1013
1128
|
this.log.debug('Updated config.json with new tokens')
|
|
1014
1129
|
} catch (error) {
|
|
1015
|
-
|
|
1016
|
-
this.log.error(`Failed to update config.json at ${configPath}. Refreshed tokens may not persist:`, details)
|
|
1130
|
+
this.log.error(`Failed to update config.json at ${configPath}. Refreshed tokens may not persist:`, errorMessage(error))
|
|
1017
1131
|
}
|
|
1018
1132
|
}
|
|
1019
1133
|
}
|
package/lib/speaker-accessory.js
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
import { sanitizeName } from './utils/sanitize-name.js'
|
|
18
18
|
import { syncServiceNames } from './sync-service-names.js'
|
|
19
19
|
import { formatError } from './utils/error-format.js'
|
|
20
|
-
import { ListenerGroup } from './utils/listener-group.js'
|
|
20
|
+
import { ListenerGroup, logListenerError } from './utils/listener-group.js'
|
|
21
21
|
import { setDeviceVolume } from './utils/set-device-volume.js'
|
|
22
22
|
import {
|
|
23
23
|
clampPercent,
|
|
@@ -51,7 +51,10 @@ export class YotoSpeakerAccessory {
|
|
|
51
51
|
this.#platform = platform
|
|
52
52
|
this.#accessory = accessory
|
|
53
53
|
this.#deviceModel = deviceModel
|
|
54
|
-
this.#listeners = new ListenerGroup(
|
|
54
|
+
this.#listeners = new ListenerGroup(
|
|
55
|
+
deviceModel,
|
|
56
|
+
logListenerError(platform.log, `${LOG_PREFIX.ACCESSORY} [${accessory.context.device.name}]`)
|
|
57
|
+
)
|
|
55
58
|
this.#log = platform.log
|
|
56
59
|
|
|
57
60
|
this.#device = accessory.context.device
|
|
@@ -148,7 +151,9 @@ export class YotoSpeakerAccessory {
|
|
|
148
151
|
.onGet(this.getTargetMediaState.bind(this))
|
|
149
152
|
.onSet(this.setTargetMediaState.bind(this))
|
|
150
153
|
|
|
151
|
-
//
|
|
154
|
+
// Report AirPlay as enabled. Kept from earlier versions so already-paired speakers keep
|
|
155
|
+
// the same characteristics; removing it hasn't been tested on real players. It does not
|
|
156
|
+
// make the Yoto an AirPlay target, and the Home app still shows "Controls not available".
|
|
152
157
|
// AirPlayEnable is a writable uint8 (0/1); ignore writes and always report enabled.
|
|
153
158
|
service
|
|
154
159
|
.getCharacteristic(Characteristic.AirPlayEnable)
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
import { sanitizeName } from './utils/sanitize-name.js'
|
|
27
27
|
import { syncServiceNames } from './sync-service-names.js'
|
|
28
28
|
import { formatError } from './utils/error-format.js'
|
|
29
|
-
import { ListenerGroup } from './utils/listener-group.js'
|
|
29
|
+
import { ListenerGroup, logListenerError } from './utils/listener-group.js'
|
|
30
30
|
import { setDeviceVolume } from './utils/set-device-volume.js'
|
|
31
31
|
import { getTrimmedString } from './utils/get-trimmed-string.js'
|
|
32
32
|
import { getCardControlConfigs } from './card-controls.js'
|
|
@@ -79,7 +79,10 @@ export class YotoTelevisionAccessory {
|
|
|
79
79
|
this.#platform = platform
|
|
80
80
|
this.#accessory = accessory
|
|
81
81
|
this.#deviceModel = deviceModel
|
|
82
|
-
this.#listeners = new ListenerGroup(
|
|
82
|
+
this.#listeners = new ListenerGroup(
|
|
83
|
+
deviceModel,
|
|
84
|
+
logListenerError(platform.log, `${LOG_PREFIX.ACCESSORY} [${accessory.context.device.name}]`)
|
|
85
|
+
)
|
|
83
86
|
this.#log = platform.log
|
|
84
87
|
|
|
85
88
|
this.#device = accessory.context.device
|
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A one-line description of an error for user-facing logs. Includes the HTTP
|
|
3
|
+
* status for Yoto API errors, whose message alone is always
|
|
4
|
+
* "Unexpected response status code".
|
|
5
|
+
* @param {unknown} error
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export function errorMessage (error) {
|
|
9
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
10
|
+
const statusCode = getStatusCode(error)
|
|
11
|
+
return statusCode === undefined ? message : `${message} (HTTP ${statusCode})`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The HTTP status of a Yoto API error, if the error has one.
|
|
16
|
+
* @param {unknown} error
|
|
17
|
+
* @returns {number | undefined}
|
|
18
|
+
*/
|
|
19
|
+
export function getStatusCode (error) {
|
|
20
|
+
if (!error || typeof error !== 'object' || !('statusCode' in error)) return undefined
|
|
21
|
+
return typeof error.statusCode === 'number' ? error.statusCode : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
1
24
|
/**
|
|
2
25
|
* @param {unknown} error
|
|
3
26
|
* @returns {string}
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
/** @import { EventEmitter } from 'node:events' */
|
|
2
|
+
/** @import { Logger } from 'homebridge' */
|
|
3
|
+
|
|
4
|
+
import { formatError } from './error-format.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A `ListenerGroup` error callback that logs the failure.
|
|
8
|
+
* @param {Logger} log
|
|
9
|
+
* @param {string} label - Names the listener's owner, e.g. `[Accessory] [Kitchen Yoto]`
|
|
10
|
+
* @returns {(event: string, error: unknown) => void}
|
|
11
|
+
*/
|
|
12
|
+
export function logListenerError (log, label) {
|
|
13
|
+
return (event, error) => {
|
|
14
|
+
log.error(`${label} Failed to handle ${event} event:`, formatError(error))
|
|
15
|
+
}
|
|
16
|
+
}
|
|
2
17
|
|
|
3
18
|
/**
|
|
4
19
|
* Tracks listeners registered on a shared emitter so they can be removed
|
|
@@ -11,24 +26,44 @@
|
|
|
11
26
|
*/
|
|
12
27
|
export class ListenerGroup {
|
|
13
28
|
/** @type {EventEmitter} */ #emitter
|
|
29
|
+
/** @type {((event: string, error: unknown) => void) | undefined} */ #onListenerError
|
|
14
30
|
/** @type {Array<[string, Parameters<EventEmitter['on']>[1]]>} */ #entries = []
|
|
15
31
|
|
|
16
32
|
/**
|
|
17
33
|
* @param {EventEmitter<TEvents>} emitter
|
|
34
|
+
* @param {(event: string, error: unknown) => void} [onListenerError] - Called when a
|
|
35
|
+
* listener throws or its returned promise rejects. The device model emits from inside
|
|
36
|
+
* MQTT and timer callbacks, where an escaping error would be an uncaught exception
|
|
37
|
+
* (or unhandled rejection) that stops Homebridge.
|
|
18
38
|
*/
|
|
19
|
-
constructor (emitter) {
|
|
39
|
+
constructor (emitter, onListenerError) {
|
|
20
40
|
this.#emitter = /** @type {EventEmitter} */ (/** @type {unknown} */ (emitter))
|
|
41
|
+
this.#onListenerError = onListenerError
|
|
21
42
|
}
|
|
22
43
|
|
|
23
44
|
/**
|
|
24
45
|
* @template {keyof TEvents & string} K
|
|
25
46
|
* @param {K} event
|
|
26
|
-
* @param {(...args: TEvents[K]) =>
|
|
47
|
+
* @param {(...args: TEvents[K]) => unknown} listener
|
|
27
48
|
* @returns {this}
|
|
28
49
|
*/
|
|
29
50
|
on (event, listener) {
|
|
30
|
-
this.#
|
|
31
|
-
|
|
51
|
+
const onListenerError = this.#onListenerError
|
|
52
|
+
const registered = onListenerError
|
|
53
|
+
? (/** @type {TEvents[K]} */ ...args) => {
|
|
54
|
+
try {
|
|
55
|
+
const result = listener(...args)
|
|
56
|
+
if (result instanceof Promise) {
|
|
57
|
+
result.catch(error => { onListenerError(event, error) })
|
|
58
|
+
}
|
|
59
|
+
} catch (error) {
|
|
60
|
+
onListenerError(event, error)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
: listener
|
|
64
|
+
const untyped = /** @type {Parameters<EventEmitter['on']>[1]} */ (registered)
|
|
65
|
+
this.#emitter.on(event, untyped)
|
|
66
|
+
this.#entries.push([event, untyped])
|
|
32
67
|
return this
|
|
33
68
|
}
|
|
34
69
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mdjhnson/homebridge-yoto",
|
|
3
|
+
"displayName": "Homebridge Yoto",
|
|
3
4
|
"description": "Control your Yoto players through Apple HomeKit with real-time MQTT updates",
|
|
4
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.4",
|
|
5
6
|
"author": "mdjhnson (https://github.com/mdjhnson)",
|
|
6
7
|
"contributors": [
|
|
7
8
|
"Bret Comnes <bcomnes@gmail.com> (https://bret.io)"
|
|
@@ -9,6 +10,10 @@
|
|
|
9
10
|
"bugs": {
|
|
10
11
|
"url": "https://github.com/mdjhnson/homebridge-yoto/issues"
|
|
11
12
|
},
|
|
13
|
+
"funding": {
|
|
14
|
+
"type": "github",
|
|
15
|
+
"url": "https://github.com/sponsors/mdjhnson"
|
|
16
|
+
},
|
|
12
17
|
"dependencies": {
|
|
13
18
|
"@homebridge/plugin-ui-utils": "^2.2.6",
|
|
14
19
|
"color-convert": "3.1.3",
|
|
@@ -27,20 +32,24 @@
|
|
|
27
32
|
"typescript": "~5.9.3"
|
|
28
33
|
},
|
|
29
34
|
"engines": {
|
|
30
|
-
"node": "
|
|
35
|
+
"node": "^22.12.0 || ^24.0.0 || ^26.0.0",
|
|
31
36
|
"npm": ">=10",
|
|
32
37
|
"homebridge": "^1.8.0 || ^2.0.0"
|
|
33
38
|
},
|
|
34
39
|
"homepage": "https://github.com/mdjhnson/homebridge-yoto",
|
|
35
40
|
"keywords": [
|
|
36
41
|
"homebridge-plugin",
|
|
37
|
-
"
|
|
42
|
+
"supports-hap",
|
|
43
|
+
"homebridge",
|
|
44
|
+
"homekit",
|
|
45
|
+
"yoto",
|
|
46
|
+
"yoto-player",
|
|
47
|
+
"yoto-mini"
|
|
38
48
|
],
|
|
39
49
|
"license": "MIT",
|
|
40
50
|
"type": "module",
|
|
41
51
|
"module": "index.js",
|
|
42
52
|
"main": "index.js",
|
|
43
|
-
"types": "index.d.ts",
|
|
44
53
|
"files": [
|
|
45
54
|
"index.js",
|
|
46
55
|
"config.schema.json",
|