@mdjhnson/homebridge-yoto 0.1.4 → 0.1.6

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
 
@@ -21,7 +21,7 @@
21
21
 
22
22
  </span>
23
23
 
24
- Homebridge plugin that exposes Yoto players to HomeKit: playback and volume, card and shortcut buttons, battery, temperature, nightlights, and more. Updates arrive in real time over MQTT, with HTTP polling as a fallback.
24
+ Homebridge plugin that exposes Yoto players to HomeKit: playback and volume, card and shortcut buttons, a TV-style remote with your card library as inputs, a sleep timer slider, battery, temperature, nightlights, and more. Updates arrive in real time over MQTT, with HTTP polling as a fallback.
25
25
 
26
26
  This is a maintained fork of [bcomnes/homebridge-yoto](https://github.com/bcomnes/homebridge-yoto).
27
27
 
@@ -44,7 +44,7 @@ Requires Node.js 22, 24 or 26 and Homebridge 1.8+ or 2.x.
44
44
 
45
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
- 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.
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
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.
50
50
 
@@ -54,7 +54,8 @@ Most options live under **Accessory Services** in the plugin settings. The setti
54
54
 
55
55
  **Playback**
56
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.
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 library cards, card controls and shortcuts.
58
+ - **TV Inputs for Library Cards** (on by default, shown once the TV accessory is on): lists every card in your Yoto library, including Make Your Own cards, as an input. Turn it off to list only card controls and shortcuts.
58
59
 
59
60
  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**.
60
61
 
@@ -68,6 +69,7 @@ The TV accessory, and the legacy Smart Speaker below, are external accessories.
68
69
 
69
70
  **Service toggles**
70
71
  - **Battery**, **Temperature Sensor** (v3), **Nightlight** (v3), **Card Slot**, **Day Mode**, **Sleep Timer**, **Bluetooth**, **Volume Limits**.
72
+ - **Sleep Timer Minutes per 1%** (shown once Sleep Timer is on): how many minutes each 1% on the sleep timer slider stands for. Defaults to 1, so the slider goes up to 100 minutes; 2 allows up to 200 minutes. Accepts 0.5 to 10.
71
73
 
72
74
  **Advanced** (collapsed section at the bottom of the plugin settings)
73
75
  - **HTTP Poll Interval**: How often to poll the Yoto API as a fallback to MQTT. Defaults to 60 seconds; the minimum is 10 seconds.
@@ -84,7 +86,9 @@ The TV accessory, and the legacy Smart Speaker below, are external accessories.
84
86
 
85
87
  **TV Playback (external)**
86
88
  - Active is on while the player is playing. Turning it off pauses.
87
- - Inputs: **Now Playing**, then one per card control and one per shortcut. Choosing an input plays its card.
89
+ - Inputs: **Now Playing**, then your card controls, shortcuts and library cards (including Make Your Own cards), listed alphabetically. Choosing an input plays its card.
90
+ - HomeKit allows about 90 inputs. Card controls and shortcuts always get one; library cards fill the rest in alphabetical order, and the log says how many were left out. Turn off **TV Inputs for Library Cards** to list only card controls and shortcuts.
91
+ - New library cards appear within about six hours, or after restarting Homebridge.
88
92
  - Remote: Play/Pause and Select toggle playback. Volume buttons step the volume.
89
93
 
90
94
  **Card Controls and Shortcuts**
@@ -102,13 +106,19 @@ The TV accessory, and the legacy Smart Speaker below, are external accessories.
102
106
  **Other controls**
103
107
  - **Card Slot**: Contact sensor for card insertion.
104
108
  - **Day Mode**: Contact sensor. Contact Not Detected means day mode.
105
- - **Sleep Timer**: Switch that turns the sleep timer on or off.
109
+ - **Sleep Timer**: Lightbulb whose brightness is the time left on the sleep timer.
110
+ - Each 1% is one minute by default, so 45% is a 45-minute timer. Change **Sleep Timer Minutes per 1%** for longer timers.
111
+ - Turning it on without picking a time reuses the last time you set (30 minutes at first, and again after Homebridge restarts). Turning it off cancels the timer.
112
+ - It counts down while the timer runs and follows timers started in the Yoto app.
113
+ - Because it's a light, a "turn off all the lights" scene or Siri request also cancels the timer.
106
114
  - **Bluetooth**: Switch that toggles Bluetooth.
107
115
  - **Day/Night Max Volume**: Lightbulbs whose brightness sets the max volume limits.
108
116
 
109
117
  ## Notes
110
118
 
111
119
  - **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.
120
+ - **Upgrading to the sleep timer slider:** the old on/off Sleep Timer switch is replaced by the slider, so add the slider back to any scenes or automations that used the switch.
121
+ - **Upgrading to library TV inputs:** existing TV inputs get new identifiers once, so scenes that choose a TV input need that input picked again. After that, identifiers stay the same when cards are added or removed.
112
122
  - **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
123
  - **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
124
 
@@ -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": {
@@ -73,7 +74,13 @@
73
74
  "title": "TV Playback Accessory",
74
75
  "type": "boolean",
75
76
  "default": false,
76
- "description": "Publish an external TV-style accessory: play/pause and volume from the iPhone remote, and inputs that play your card controls and shortcuts. Requires pairing and its own network port."
77
+ "description": "Publish an external TV-style accessory: play/pause and volume from the iPhone remote, and inputs that play your cards, card controls and shortcuts. Requires pairing and its own network port."
78
+ },
79
+ "televisionLibrary": {
80
+ "title": "TV Inputs for Library Cards",
81
+ "type": "boolean",
82
+ "default": true,
83
+ "description": "Add every card in your Yoto library as a TV input, listed alphabetically. HomeKit allows about 90 inputs; if your library is bigger, cards past the limit are left out."
77
84
  },
78
85
  "battery": {
79
86
  "title": "Battery",
@@ -110,13 +117,11 @@
110
117
  "label": {
111
118
  "title": "Label",
112
119
  "type": "string",
113
- "required": true,
114
120
  "description": "Name shown in HomeKit for this card control."
115
121
  },
116
122
  "cardId": {
117
123
  "title": "Card ID",
118
124
  "type": "string",
119
- "required": true,
120
125
  "description": "The Yoto card ID to play."
121
126
  },
122
127
  "playOnAll": {
@@ -125,7 +130,11 @@
125
130
  "default": false,
126
131
  "description": "Create a separate accessory that plays this card on every Yoto."
127
132
  }
128
- }
133
+ },
134
+ "required": [
135
+ "label",
136
+ "cardId"
137
+ ]
129
138
  }
130
139
  },
131
140
  "shortcuts": {
@@ -144,7 +153,15 @@
144
153
  "title": "Sleep Timer",
145
154
  "type": "boolean",
146
155
  "default": false,
147
- "description": "Expose sleep timer switch."
156
+ "description": "Expose the sleep timer as a slider. Each 1% is one minute unless changed below; turning it on without a time uses 30 minutes."
157
+ },
158
+ "sleepTimerMinutesPerPercent": {
159
+ "title": "Sleep Timer Minutes per 1%",
160
+ "type": "number",
161
+ "default": 1,
162
+ "minimum": 0.5,
163
+ "maximum": 10,
164
+ "description": "How many minutes each 1% on the sleep timer slider adds. 1 gives up to 100 minutes; 2 gives up to 200 minutes."
148
165
  },
149
166
  "bluetooth": {
150
167
  "title": "Bluetooth",
@@ -192,6 +209,12 @@
192
209
  },
193
210
  "services.playbackControls",
194
211
  "services.television",
212
+ {
213
+ "key": "services.televisionLibrary",
214
+ "condition": {
215
+ "functionBody": "return model.services && model.services.television === true"
216
+ }
217
+ },
195
218
  "services.volumeLimits",
196
219
  "services.battery",
197
220
  "services.temperature",
@@ -200,6 +223,12 @@
200
223
  "services.nightlight",
201
224
  "services.bluetooth",
202
225
  "services.sleepTimer",
226
+ {
227
+ "key": "services.sleepTimerMinutesPerPercent",
228
+ "condition": {
229
+ "functionBody": "return model.services && model.services.sleepTimer === true"
230
+ }
231
+ },
203
232
  "services.shortcuts",
204
233
  {
205
234
  "type": "help",
@@ -224,6 +253,7 @@
224
253
  "expandable": true,
225
254
  "expanded": false,
226
255
  "items": [
256
+ "name",
227
257
  "httpPollIntervalMs",
228
258
  {
229
259
  "type": "help",
@@ -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
 
@@ -153,14 +156,13 @@ function getErrorMessage (error, fallback) {
153
156
  async function loadAuthConfig () {
154
157
  try {
155
158
  pluginConfig = await homebridge.getPluginConfig()
156
- if (!pluginConfig.length) {
157
- pluginConfig.push({ platform: 'Yoto' })
158
- }
159
159
 
160
160
  /** @type {AuthConfigResponse} */
161
161
  const config = await homebridge.request('/auth/config')
162
162
  defaultClientId = config.defaultClientId
163
+ defaultName = config.defaultName
163
164
  legacyClientIds = config.legacyClientIds
165
+ getPlatformBlock()
164
166
 
165
167
  const redirectUriDisplay = document.getElementById('redirectUriDisplay')
166
168
  if (redirectUriDisplay) redirectUriDisplay.textContent = config.redirectUri
@@ -178,6 +180,18 @@ async function loadAuthConfig () {
178
180
  }
179
181
  }
180
182
 
183
+ /**
184
+ * The plugin's config block, created if missing. Also fills in the name,
185
+ * which blocks saved by older versions don't have.
186
+ * @returns {YotoConfig}
187
+ */
188
+ function getPlatformBlock () {
189
+ if (!pluginConfig[0]) pluginConfig[0] = { platform: 'Yoto' }
190
+ const config = pluginConfig[0]
191
+ if (!config.name && defaultName) config.name = defaultName
192
+ return config
193
+ }
194
+
181
195
  /**
182
196
  * The client ID saved in config, ignoring retired ones
183
197
  * @returns {string | undefined}
@@ -265,8 +279,7 @@ async function finishAuthorization () {
265
279
  response: pasted,
266
280
  })
267
281
 
268
- if (!pluginConfig[0]) pluginConfig[0] = { platform: 'Yoto' }
269
- const config = pluginConfig[0]
282
+ const config = getPlatformBlock()
270
283
  config.refreshToken = result.refreshToken
271
284
  config.accessToken = result.accessToken
272
285
  config.tokenExpiresAt = result.tokenExpiresAt
@@ -306,11 +319,10 @@ async function logout () {
306
319
  try {
307
320
  homebridge.showSpinner()
308
321
 
309
- if (pluginConfig[0]) {
310
- delete pluginConfig[0].refreshToken
311
- delete pluginConfig[0].accessToken
312
- delete pluginConfig[0].tokenExpiresAt
313
- }
322
+ const config = getPlatformBlock()
323
+ delete config.refreshToken
324
+ delete config.accessToken
325
+ delete config.tokenExpiresAt
314
326
 
315
327
  await homebridge.updatePluginConfig(pluginConfig)
316
328
  await homebridge.savePluginConfig()
@@ -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>
Binary file
@@ -11,6 +11,7 @@ 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,
@@ -59,6 +60,7 @@ class YotoUiServer extends HomebridgePluginUiServer {
59
60
  * Response from /auth/config endpoint
60
61
  * @typedef {Object} AuthConfigResponse
61
62
  * @property {string} defaultClientId - The default OAuth client ID
63
+ * @property {string} defaultName - The default plugin name, filled into config blocks that have none
62
64
  * @property {string[]} legacyClientIds - Client IDs that no longer support sign-in
63
65
  * @property {string} redirectUri - Redirect URI to register on a custom Yoto app
64
66
  */
@@ -70,6 +72,7 @@ class YotoUiServer extends HomebridgePluginUiServer {
70
72
  async function getAuthConfig () {
71
73
  return {
72
74
  defaultClientId: DEFAULT_CLIENT_ID,
75
+ defaultName: DEFAULT_NAME,
73
76
  legacyClientIds: LEGACY_CLIENT_IDS,
74
77
  redirectUri: OAUTH_REDIRECT_URI,
75
78
  }
package/lib/accessory.js CHANGED
@@ -36,6 +36,7 @@
36
36
  * @property {boolean} shortcuts
37
37
  */
38
38
 
39
+ import { setTimeout as delay } from 'node:timers/promises'
39
40
  import convert from 'color-convert'
40
41
  import {
41
42
  DEFAULT_MANUFACTURER,
@@ -46,7 +47,7 @@ import {
46
47
  import { sanitizeName } from './utils/sanitize-name.js'
47
48
  import { syncServiceNames } from './sync-service-names.js'
48
49
  import { serviceSchema } from '../config.schema.cjs'
49
- import { getPlaybackAccessoryConfig } from './service-config.js'
50
+ import { getPlaybackAccessoryConfig, getServiceConfig, getSleepTimerIncrement } from './service-config.js'
50
51
  import { getCardControlConfigs } from './card-controls.js'
51
52
  import { getCardStartOptions, getShortcutEntries, getShortcutsSignature, toPlayableCard } from './shortcuts.js'
52
53
  import { formatError } from './utils/error-format.js'
@@ -59,6 +60,27 @@ import {
59
60
  percentToSteps,
60
61
  stepsToPercent,
61
62
  } from './utils/volume.js'
63
+ import {
64
+ DEFAULT_SLEEP_TIMER_MINUTES,
65
+ DEFAULT_SLEEP_TIMER_MINUTES_PER_PERCENT,
66
+ sleepPercentToSeconds,
67
+ sleepSecondsToPercent,
68
+ } from './utils/sleep-timer.js'
69
+
70
+ /**
71
+ * Dragging the slider on an off sleep timer makes Home send On=1 and then the
72
+ * brightness. Wait this long before starting the default timer so the
73
+ * brightness can set the time instead.
74
+ */
75
+ const SLEEP_TIMER_ON_DELAY_MS = 250
76
+ /** How often the sleep timer slider counts down between player reports */
77
+ const SLEEP_TIMER_TICK_MS = 30 * 1000
78
+ /**
79
+ * Players take a few seconds to apply a sleep timer command and keep sending
80
+ * the old state meanwhile. For this long after a command, ignore reports that
81
+ * don't match it.
82
+ */
83
+ const SLEEP_TIMER_CONFIRM_MS = 10 * 1000
62
84
 
63
85
  /**
64
86
  * @param {ServiceSchemaKey} key
@@ -107,6 +129,15 @@ export class YotoPlayerAccessory {
107
129
  /** @type {ListenerGroup<YotoDeviceModelEventMap>} */ #listeners
108
130
  /** @type {Map<string, Service>} */ #shortcutServices = new Map()
109
131
  /** @type {string} */ #shortcutsSignature = ''
132
+ // Sleep timer: the player reports seconds left only now and then, so count
133
+ // down locally from the last report
134
+ /** @type {number} */ #sleepTimerIncrement = DEFAULT_SLEEP_TIMER_MINUTES_PER_PERCENT
135
+ /** @type {number | null} */ #sleepTimerEndsAt = null
136
+ /** @type {ReturnType<typeof setInterval> | null} */ #sleepTimerTick = null
137
+ /** @type {number} */ #lastSleepTimerPercent = DEFAULT_SLEEP_TIMER_MINUTES
138
+ /** @type {number} */ #sleepTimerRequest = 0
139
+ /** @type {{ seconds: number, sentAt: number } | null} */ #sleepTimerPending = null
140
+ /** @type {boolean} */ #stopped = false
110
141
 
111
142
  /**
112
143
  * @param {Object} params
@@ -135,11 +166,7 @@ export class YotoPlayerAccessory {
135
166
  * @returns {YotoServiceToggles}
136
167
  */
137
168
  getServiceToggles () {
138
- const config = this.#platform.config
139
- const services = config && typeof config === 'object' ? config['services'] : undefined
140
- const serviceConfig = typeof services === 'object' && services !== null
141
- ? /** @type {Record<string, unknown>} */ (services)
142
- : {}
169
+ const serviceConfig = getServiceConfig(this.#platform.config)
143
170
  const playbackConfig = getPlaybackAccessoryConfig(this.#platform.config)
144
171
 
145
172
  return {
@@ -578,23 +605,32 @@ export class YotoPlayerAccessory {
578
605
  }
579
606
 
580
607
  /**
581
- * Setup sleep timer Switch service
582
- * Toggle sleep timer on/off
608
+ * Setup sleep timer Lightbulb service
609
+ * On/off starts or cancels the timer; brightness is the time left
610
+ * (each 1% is `sleepTimerMinutesPerPercent` minutes).
611
+ * Replaces the Switch used by earlier versions, which setup() removes as stale.
583
612
  */
584
613
  setupSleepTimerService () {
585
614
  const { Service, Characteristic } = this.#platform
586
615
  const serviceName = this.generateServiceName('Sleep Timer')
616
+ this.#sleepTimerIncrement = getSleepTimerIncrement(this.#platform.config)
617
+ this.#lastSleepTimerPercent = sleepSecondsToPercent(DEFAULT_SLEEP_TIMER_MINUTES * 60, this.#sleepTimerIncrement)
587
618
 
588
- const service = this.#accessory.getServiceById(Service.Switch, 'SleepTimer') ||
589
- this.#accessory.addService(Service.Switch, serviceName, 'SleepTimer')
619
+ const service = this.#accessory.getServiceById(Service.Lightbulb, 'SleepTimer') ||
620
+ this.#accessory.addService(Service.Lightbulb, serviceName, 'SleepTimer')
590
621
  syncServiceNames({ Characteristic, service, name: serviceName })
591
622
 
592
623
  service.getCharacteristic(Characteristic.On)
593
624
  .onGet(this.getSleepTimerState.bind(this))
594
625
  .onSet(this.setSleepTimerState.bind(this))
595
626
 
627
+ service.getCharacteristic(Characteristic.Brightness)
628
+ .onGet(this.getSleepTimerPercent.bind(this))
629
+ .onSet(this.setSleepTimerPercent.bind(this))
630
+
596
631
  this.sleepTimerService = service
597
632
  this.#currentServices.add(service)
633
+ this.syncSleepTimerFromPlayback()
598
634
  }
599
635
 
600
636
  /**
@@ -1006,7 +1042,8 @@ export class YotoPlayerAccessory {
1006
1042
  break
1007
1043
 
1008
1044
  case 'sleepTimerActive':
1009
- this.updateSleepTimerCharacteristic()
1045
+ case 'sleepTimerSeconds':
1046
+ this.syncSleepTimerFromPlayback()
1010
1047
  break
1011
1048
 
1012
1049
  // Playback fields - informational only
@@ -1022,7 +1059,6 @@ export class YotoPlayerAccessory {
1022
1059
  case 'trackKey':
1023
1060
  case 'chapterTitle':
1024
1061
  case 'chapterKey':
1025
- case 'sleepTimerSeconds':
1026
1062
  case 'streaming':
1027
1063
  case 'updatedAt': {
1028
1064
  // Not exposed as characteristics
@@ -1784,44 +1820,163 @@ export class YotoPlayerAccessory {
1784
1820
  : Characteristic.ContactSensorState.CONTACT_DETECTED
1785
1821
  }
1786
1822
 
1787
- // ==================== Sleep Timer Switch Getter/Setter ====================
1823
+ // ==================== Sleep Timer (Lightbulb) Getter/Setter ====================
1824
+
1825
+ /**
1826
+ * Seconds left on the sleep timer, or null when the player hasn't said.
1827
+ * @returns {number | null}
1828
+ */
1829
+ getSleepTimerRemainingSeconds () {
1830
+ if (this.#sleepTimerEndsAt === null) return null
1831
+ return Math.max(0, (this.#sleepTimerEndsAt - Date.now()) / 1000)
1832
+ }
1833
+
1834
+ /**
1835
+ * @returns {boolean}
1836
+ */
1837
+ isSleepTimerOn () {
1838
+ const remaining = this.getSleepTimerRemainingSeconds()
1839
+ if (remaining !== null) return remaining > 0
1840
+ return this.#deviceModel.playback.sleepTimerActive === true
1841
+ }
1842
+
1843
+ /**
1844
+ * Slider position: the time left while running, otherwise the last time set.
1845
+ * @returns {number}
1846
+ */
1847
+ getSleepTimerPercentValue () {
1848
+ const remaining = this.getSleepTimerRemainingSeconds()
1849
+ if (remaining !== null && remaining > 0) {
1850
+ return sleepSecondsToPercent(remaining, this.#sleepTimerIncrement)
1851
+ }
1852
+ return this.#lastSleepTimerPercent
1853
+ }
1788
1854
 
1789
1855
  /**
1790
1856
  * Get sleep timer state
1791
1857
  * @returns {Promise<CharacteristicValue>}
1792
1858
  */
1793
1859
  async getSleepTimerState () {
1794
- const playback = this.#deviceModel.playback
1795
- this.#log.debug(
1796
- LOG_PREFIX.ACCESSORY,
1797
- `[${this.#device.name}] Get sleep timer -> ${playback.sleepTimerActive ?? false}`
1798
- )
1799
- return playback.sleepTimerActive ?? false
1860
+ const on = this.isSleepTimerOn()
1861
+ this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get sleep timer -> ${on}`)
1862
+ return on
1800
1863
  }
1801
1864
 
1802
1865
  /**
1803
- * Set sleep timer state
1866
+ * Set sleep timer state. On starts the last time used (30 minutes at first).
1804
1867
  * @param {CharacteristicValue} value
1805
1868
  */
1806
1869
  async setSleepTimerState (value) {
1807
- const enabled = Boolean(value)
1870
+ if (!value) {
1871
+ this.#sleepTimerRequest++
1872
+ await this.applySleepTimer(0)
1873
+ return
1874
+ }
1875
+ if (this.isSleepTimerOn()) return
1876
+
1877
+ const request = ++this.#sleepTimerRequest
1878
+ await delay(SLEEP_TIMER_ON_DELAY_MS)
1879
+ // The slider was moved meanwhile; it sets the time itself
1880
+ if (request !== this.#sleepTimerRequest) return
1881
+ await this.applySleepTimer(this.#lastSleepTimerPercent)
1882
+ }
1883
+
1884
+ /**
1885
+ * Get sleep timer slider position
1886
+ * @returns {Promise<CharacteristicValue>}
1887
+ */
1888
+ async getSleepTimerPercent () {
1889
+ return this.getSleepTimerPercentValue()
1890
+ }
1891
+
1892
+ /**
1893
+ * Set sleep timer length from the slider
1894
+ * @param {CharacteristicValue} value
1895
+ */
1896
+ async setSleepTimerPercent (value) {
1897
+ this.#sleepTimerRequest++
1898
+ const percent = clampPercent(Number(value))
1899
+ if (percent > 0) this.#lastSleepTimerPercent = percent
1900
+ await this.applySleepTimer(percent)
1901
+ }
1902
+
1903
+ /**
1904
+ * Send a sleep timer length to the player (0 cancels it).
1905
+ * @param {number} percent
1906
+ */
1907
+ async applySleepTimer (percent) {
1908
+ const seconds = sleepPercentToSeconds(percent, this.#sleepTimerIncrement)
1909
+ this.#log.debug(
1910
+ LOG_PREFIX.ACCESSORY,
1911
+ `[${this.#device.name}] Set sleep timer: ${seconds > 0 ? `${Math.round(seconds / 60)} minutes` : 'off'}`
1912
+ )
1808
1913
 
1809
1914
  try {
1810
- if (enabled) {
1811
- // Turn on sleep timer - default to 30 minutes
1812
- this.#log.debug(LOG_PREFIX.ACCESSORY, 'Activating sleep timer (30 minutes)')
1813
- await this.#deviceModel.setSleepTimer(30 * 60)
1814
- } else {
1815
- // Turn off sleep timer
1816
- this.#log.debug(LOG_PREFIX.ACCESSORY, 'Deactivating sleep timer')
1817
- await this.#deviceModel.setSleepTimer(0)
1818
- }
1915
+ await this.#deviceModel.setSleepTimer(seconds)
1819
1916
  } catch (error) {
1820
1917
  this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set sleep timer:`, formatError(error))
1821
1918
  throw new this.#platform.api.hap.HapStatusError(
1822
1919
  this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
1823
1920
  )
1824
1921
  }
1922
+
1923
+ // Show the new time now rather than waiting for the player to report it.
1924
+ // A cancel ends "now", so the tile doesn't flick back on while the player's
1925
+ // last report still says the timer is running.
1926
+ this.#sleepTimerPending = { seconds, sentAt: Date.now() }
1927
+ this.setSleepTimerEnd(Date.now() + seconds * 1000)
1928
+ }
1929
+
1930
+ /**
1931
+ * Take the sleep timer state the player last reported.
1932
+ */
1933
+ syncSleepTimerFromPlayback () {
1934
+ if (!this.sleepTimerService) return
1935
+ const { sleepTimerActive, sleepTimerSeconds } = this.#deviceModel.playback
1936
+
1937
+ const pending = this.#sleepTimerPending
1938
+ if (pending) {
1939
+ const age = Date.now() - pending.sentAt
1940
+ const expectedSeconds = pending.seconds - age / 1000
1941
+ const confirmed = pending.seconds === 0
1942
+ ? sleepTimerActive !== true
1943
+ : sleepTimerActive === true && typeof sleepTimerSeconds === 'number' &&
1944
+ Math.abs(sleepTimerSeconds - expectedSeconds) <= 5
1945
+ // A report from before the player applied our command
1946
+ if (!confirmed && age < SLEEP_TIMER_CONFIRM_MS) return
1947
+ this.#sleepTimerPending = null
1948
+ }
1949
+
1950
+ if (sleepTimerActive === true && typeof sleepTimerSeconds === 'number' && sleepTimerSeconds > 0) {
1951
+ this.setSleepTimerEnd(Date.now() + sleepTimerSeconds * 1000)
1952
+ } else if (sleepTimerActive !== true) {
1953
+ this.setSleepTimerEnd(null)
1954
+ } else {
1955
+ // Running, but the player hasn't said how long is left. Drop an end time
1956
+ // that has passed (e.g. from a cancel) so the tile follows the player.
1957
+ if (this.getSleepTimerRemainingSeconds() === 0) this.#sleepTimerEndsAt = null
1958
+ this.updateSleepTimerCharacteristic()
1959
+ }
1960
+ }
1961
+
1962
+ /**
1963
+ * Record when the sleep timer ends and count the slider down until then.
1964
+ * @param {number | null} endsAt - Epoch ms, or null when off
1965
+ */
1966
+ setSleepTimerEnd (endsAt) {
1967
+ this.#sleepTimerEndsAt = endsAt
1968
+ if (endsAt !== null && !this.#sleepTimerTick && !this.#stopped) {
1969
+ this.#sleepTimerTick = setInterval(() => this.updateSleepTimerCharacteristic(), SLEEP_TIMER_TICK_MS)
1970
+ this.#sleepTimerTick.unref?.()
1971
+ }
1972
+ this.updateSleepTimerCharacteristic()
1973
+ }
1974
+
1975
+ stopSleepTimerTick () {
1976
+ if (this.#sleepTimerTick) {
1977
+ clearInterval(this.#sleepTimerTick)
1978
+ this.#sleepTimerTick = null
1979
+ }
1825
1980
  }
1826
1981
 
1827
1982
  // ==================== Bluetooth Switch Getter/Setter ====================
@@ -2248,19 +2403,18 @@ export class YotoPlayerAccessory {
2248
2403
  }
2249
2404
 
2250
2405
  /**
2251
- * Update sleep timer Switch characteristic
2406
+ * Update sleep timer On and Brightness characteristics
2252
2407
  */
2253
2408
  updateSleepTimerCharacteristic () {
2409
+ const on = this.isSleepTimerOn()
2410
+ if (!on) this.stopSleepTimerTick()
2254
2411
  if (!this.sleepTimerService) {
2255
2412
  return
2256
2413
  }
2257
2414
 
2258
2415
  const { Characteristic } = this.#platform
2259
- const playback = this.#deviceModel.playback
2260
-
2261
- this.sleepTimerService
2262
- .getCharacteristic(Characteristic.On)
2263
- .updateValue(playback.sleepTimerActive ?? false)
2416
+ this.sleepTimerService.updateCharacteristic(Characteristic.On, on)
2417
+ this.sleepTimerService.updateCharacteristic(Characteristic.Brightness, this.getSleepTimerPercentValue())
2264
2418
  }
2265
2419
 
2266
2420
  /**
@@ -2347,6 +2501,10 @@ export class YotoPlayerAccessory {
2347
2501
 
2348
2502
  // Only remove our own listeners; the device model is shared
2349
2503
  this.#listeners.removeAll()
2504
+ // Drop a pending "On" so this handler doesn't start a timer after it's replaced
2505
+ this.#sleepTimerRequest++
2506
+ this.#stopped = true
2507
+ this.stopSleepTimerTick()
2350
2508
 
2351
2509
  // Note: Don't call deviceModel.stop() here - that's handled by YotoAccount
2352
2510
  }