@mdjhnson/homebridge-yoto 0.1.3 → 0.1.5
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 +22 -7
- package/config.schema.json +28 -2
- package/lib/accessory.js +199 -38
- package/lib/card-controls.js +2 -4
- package/lib/platform.js +290 -103
- package/lib/service-config.js +33 -9
- package/lib/speaker-accessory.js +5 -2
- package/lib/television-accessory.js +179 -20
- package/lib/utils/error-format.js +23 -0
- package/lib/utils/input-order.js +92 -0
- package/lib/utils/library.js +70 -0
- package/lib/utils/listener-group.js +39 -4
- package/lib/utils/sanitize-name.js +23 -3
- package/lib/utils/sleep-timer.js +45 -0
- package/lib/utils/token-client-id.js +24 -0
- package/lib/utils/token-config.js +57 -0
- 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
|
|
|
@@ -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
|
|
|
@@ -33,7 +33,7 @@ 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
|
|
|
@@ -44,7 +44,7 @@ Requires Node.js 22+ 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
|
|
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,14 +106,25 @@ 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**:
|
|
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.
|
|
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.
|
|
124
|
+
|
|
125
|
+
## Privacy
|
|
126
|
+
|
|
127
|
+
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
128
|
|
|
114
129
|
## Development
|
|
115
130
|
|
package/config.schema.json
CHANGED
|
@@ -73,7 +73,13 @@
|
|
|
73
73
|
"title": "TV Playback Accessory",
|
|
74
74
|
"type": "boolean",
|
|
75
75
|
"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."
|
|
76
|
+
"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."
|
|
77
|
+
},
|
|
78
|
+
"televisionLibrary": {
|
|
79
|
+
"title": "TV Inputs for Library Cards",
|
|
80
|
+
"type": "boolean",
|
|
81
|
+
"default": true,
|
|
82
|
+
"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
83
|
},
|
|
78
84
|
"battery": {
|
|
79
85
|
"title": "Battery",
|
|
@@ -144,7 +150,15 @@
|
|
|
144
150
|
"title": "Sleep Timer",
|
|
145
151
|
"type": "boolean",
|
|
146
152
|
"default": false,
|
|
147
|
-
"description": "Expose sleep timer
|
|
153
|
+
"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."
|
|
154
|
+
},
|
|
155
|
+
"sleepTimerMinutesPerPercent": {
|
|
156
|
+
"title": "Sleep Timer Minutes per 1%",
|
|
157
|
+
"type": "number",
|
|
158
|
+
"default": 1,
|
|
159
|
+
"minimum": 0.5,
|
|
160
|
+
"maximum": 10,
|
|
161
|
+
"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
162
|
},
|
|
149
163
|
"bluetooth": {
|
|
150
164
|
"title": "Bluetooth",
|
|
@@ -192,6 +206,12 @@
|
|
|
192
206
|
},
|
|
193
207
|
"services.playbackControls",
|
|
194
208
|
"services.television",
|
|
209
|
+
{
|
|
210
|
+
"key": "services.televisionLibrary",
|
|
211
|
+
"condition": {
|
|
212
|
+
"functionBody": "return model.services && model.services.television === true"
|
|
213
|
+
}
|
|
214
|
+
},
|
|
195
215
|
"services.volumeLimits",
|
|
196
216
|
"services.battery",
|
|
197
217
|
"services.temperature",
|
|
@@ -200,6 +220,12 @@
|
|
|
200
220
|
"services.nightlight",
|
|
201
221
|
"services.bluetooth",
|
|
202
222
|
"services.sleepTimer",
|
|
223
|
+
{
|
|
224
|
+
"key": "services.sleepTimerMinutesPerPercent",
|
|
225
|
+
"condition": {
|
|
226
|
+
"functionBody": "return model.services && model.services.sleepTimer === true"
|
|
227
|
+
}
|
|
228
|
+
},
|
|
203
229
|
"services.shortcuts",
|
|
204
230
|
{
|
|
205
231
|
"type": "help",
|
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,11 +47,11 @@ 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'
|
|
53
|
-
import { ListenerGroup } from './utils/listener-group.js'
|
|
54
|
+
import { ListenerGroup, logListenerError } from './utils/listener-group.js'
|
|
54
55
|
import { setDeviceVolume } from './utils/set-device-volume.js'
|
|
55
56
|
import { getBooleanSetting } from './utils/get-boolean-setting.js'
|
|
56
57
|
import {
|
|
@@ -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
|
|
@@ -118,7 +149,10 @@ export class YotoPlayerAccessory {
|
|
|
118
149
|
this.#platform = platform
|
|
119
150
|
this.#accessory = accessory
|
|
120
151
|
this.#deviceModel = deviceModel
|
|
121
|
-
this.#listeners = new ListenerGroup(
|
|
152
|
+
this.#listeners = new ListenerGroup(
|
|
153
|
+
deviceModel,
|
|
154
|
+
logListenerError(platform.log, `${LOG_PREFIX.ACCESSORY} [${accessory.context.device.name}]`)
|
|
155
|
+
)
|
|
122
156
|
this.#log = platform.log
|
|
123
157
|
|
|
124
158
|
// Extract device info from context
|
|
@@ -132,11 +166,7 @@ export class YotoPlayerAccessory {
|
|
|
132
166
|
* @returns {YotoServiceToggles}
|
|
133
167
|
*/
|
|
134
168
|
getServiceToggles () {
|
|
135
|
-
const
|
|
136
|
-
const services = config && typeof config === 'object' ? config['services'] : undefined
|
|
137
|
-
const serviceConfig = typeof services === 'object' && services !== null
|
|
138
|
-
? /** @type {Record<string, unknown>} */ (services)
|
|
139
|
-
: {}
|
|
169
|
+
const serviceConfig = getServiceConfig(this.#platform.config)
|
|
140
170
|
const playbackConfig = getPlaybackAccessoryConfig(this.#platform.config)
|
|
141
171
|
|
|
142
172
|
return {
|
|
@@ -575,23 +605,32 @@ export class YotoPlayerAccessory {
|
|
|
575
605
|
}
|
|
576
606
|
|
|
577
607
|
/**
|
|
578
|
-
* Setup sleep timer
|
|
579
|
-
*
|
|
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.
|
|
580
612
|
*/
|
|
581
613
|
setupSleepTimerService () {
|
|
582
614
|
const { Service, Characteristic } = this.#platform
|
|
583
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)
|
|
584
618
|
|
|
585
|
-
const service = this.#accessory.getServiceById(Service.
|
|
586
|
-
this.#accessory.addService(Service.
|
|
619
|
+
const service = this.#accessory.getServiceById(Service.Lightbulb, 'SleepTimer') ||
|
|
620
|
+
this.#accessory.addService(Service.Lightbulb, serviceName, 'SleepTimer')
|
|
587
621
|
syncServiceNames({ Characteristic, service, name: serviceName })
|
|
588
622
|
|
|
589
623
|
service.getCharacteristic(Characteristic.On)
|
|
590
624
|
.onGet(this.getSleepTimerState.bind(this))
|
|
591
625
|
.onSet(this.setSleepTimerState.bind(this))
|
|
592
626
|
|
|
627
|
+
service.getCharacteristic(Characteristic.Brightness)
|
|
628
|
+
.onGet(this.getSleepTimerPercent.bind(this))
|
|
629
|
+
.onSet(this.setSleepTimerPercent.bind(this))
|
|
630
|
+
|
|
593
631
|
this.sleepTimerService = service
|
|
594
632
|
this.#currentServices.add(service)
|
|
633
|
+
this.syncSleepTimerFromPlayback()
|
|
595
634
|
}
|
|
596
635
|
|
|
597
636
|
/**
|
|
@@ -1003,7 +1042,8 @@ export class YotoPlayerAccessory {
|
|
|
1003
1042
|
break
|
|
1004
1043
|
|
|
1005
1044
|
case 'sleepTimerActive':
|
|
1006
|
-
|
|
1045
|
+
case 'sleepTimerSeconds':
|
|
1046
|
+
this.syncSleepTimerFromPlayback()
|
|
1007
1047
|
break
|
|
1008
1048
|
|
|
1009
1049
|
// Playback fields - informational only
|
|
@@ -1019,7 +1059,6 @@ export class YotoPlayerAccessory {
|
|
|
1019
1059
|
case 'trackKey':
|
|
1020
1060
|
case 'chapterTitle':
|
|
1021
1061
|
case 'chapterKey':
|
|
1022
|
-
case 'sleepTimerSeconds':
|
|
1023
1062
|
case 'streaming':
|
|
1024
1063
|
case 'updatedAt': {
|
|
1025
1064
|
// Not exposed as characteristics
|
|
@@ -1781,44 +1820,163 @@ export class YotoPlayerAccessory {
|
|
|
1781
1820
|
: Characteristic.ContactSensorState.CONTACT_DETECTED
|
|
1782
1821
|
}
|
|
1783
1822
|
|
|
1784
|
-
// ==================== Sleep Timer
|
|
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
|
+
}
|
|
1785
1854
|
|
|
1786
1855
|
/**
|
|
1787
1856
|
* Get sleep timer state
|
|
1788
1857
|
* @returns {Promise<CharacteristicValue>}
|
|
1789
1858
|
*/
|
|
1790
1859
|
async getSleepTimerState () {
|
|
1791
|
-
const
|
|
1792
|
-
this.#log.debug(
|
|
1793
|
-
|
|
1794
|
-
`[${this.#device.name}] Get sleep timer -> ${playback.sleepTimerActive ?? false}`
|
|
1795
|
-
)
|
|
1796
|
-
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
|
|
1797
1863
|
}
|
|
1798
1864
|
|
|
1799
1865
|
/**
|
|
1800
|
-
* Set sleep timer state
|
|
1866
|
+
* Set sleep timer state. On starts the last time used (30 minutes at first).
|
|
1801
1867
|
* @param {CharacteristicValue} value
|
|
1802
1868
|
*/
|
|
1803
1869
|
async setSleepTimerState (value) {
|
|
1804
|
-
|
|
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
|
+
)
|
|
1805
1913
|
|
|
1806
1914
|
try {
|
|
1807
|
-
|
|
1808
|
-
// Turn on sleep timer - default to 30 minutes
|
|
1809
|
-
this.#log.debug(LOG_PREFIX.ACCESSORY, 'Activating sleep timer (30 minutes)')
|
|
1810
|
-
await this.#deviceModel.setSleepTimer(30 * 60)
|
|
1811
|
-
} else {
|
|
1812
|
-
// Turn off sleep timer
|
|
1813
|
-
this.#log.debug(LOG_PREFIX.ACCESSORY, 'Deactivating sleep timer')
|
|
1814
|
-
await this.#deviceModel.setSleepTimer(0)
|
|
1815
|
-
}
|
|
1915
|
+
await this.#deviceModel.setSleepTimer(seconds)
|
|
1816
1916
|
} catch (error) {
|
|
1817
1917
|
this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set sleep timer:`, formatError(error))
|
|
1818
1918
|
throw new this.#platform.api.hap.HapStatusError(
|
|
1819
1919
|
this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
1820
1920
|
)
|
|
1821
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
|
+
}
|
|
1822
1980
|
}
|
|
1823
1981
|
|
|
1824
1982
|
// ==================== Bluetooth Switch Getter/Setter ====================
|
|
@@ -2245,19 +2403,18 @@ export class YotoPlayerAccessory {
|
|
|
2245
2403
|
}
|
|
2246
2404
|
|
|
2247
2405
|
/**
|
|
2248
|
-
* Update sleep timer
|
|
2406
|
+
* Update sleep timer On and Brightness characteristics
|
|
2249
2407
|
*/
|
|
2250
2408
|
updateSleepTimerCharacteristic () {
|
|
2409
|
+
const on = this.isSleepTimerOn()
|
|
2410
|
+
if (!on) this.stopSleepTimerTick()
|
|
2251
2411
|
if (!this.sleepTimerService) {
|
|
2252
2412
|
return
|
|
2253
2413
|
}
|
|
2254
2414
|
|
|
2255
2415
|
const { Characteristic } = this.#platform
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
this.sleepTimerService
|
|
2259
|
-
.getCharacteristic(Characteristic.On)
|
|
2260
|
-
.updateValue(playback.sleepTimerActive ?? false)
|
|
2416
|
+
this.sleepTimerService.updateCharacteristic(Characteristic.On, on)
|
|
2417
|
+
this.sleepTimerService.updateCharacteristic(Characteristic.Brightness, this.getSleepTimerPercentValue())
|
|
2261
2418
|
}
|
|
2262
2419
|
|
|
2263
2420
|
/**
|
|
@@ -2344,6 +2501,10 @@ export class YotoPlayerAccessory {
|
|
|
2344
2501
|
|
|
2345
2502
|
// Only remove our own listeners; the device model is shared
|
|
2346
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()
|
|
2347
2508
|
|
|
2348
2509
|
// Note: Don't call deviceModel.stop() here - that's handled by YotoAccount
|
|
2349
2510
|
}
|
package/lib/card-controls.js
CHANGED
|
@@ -19,16 +19,14 @@
|
|
|
19
19
|
|
|
20
20
|
import { getTrimmedString } from './utils/get-trimmed-string.js'
|
|
21
21
|
import { getBooleanSetting } from './utils/get-boolean-setting.js'
|
|
22
|
+
import { getServiceConfig } from './service-config.js'
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* @param {PlatformConfig} config
|
|
25
26
|
* @returns {CardControlConfig[]}
|
|
26
27
|
*/
|
|
27
28
|
export function getCardControlConfigs (config) {
|
|
28
|
-
const
|
|
29
|
-
const serviceConfig = typeof services === 'object' && services !== null
|
|
30
|
-
? /** @type {Record<string, unknown>} */ (services)
|
|
31
|
-
: {}
|
|
29
|
+
const serviceConfig = getServiceConfig(config)
|
|
32
30
|
|
|
33
31
|
const rawControls = Array.isArray(serviceConfig['cardControls'])
|
|
34
32
|
? serviceConfig['cardControls']
|