@mdjhnson/homebridge-yoto 0.1.4 → 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 +15 -5
- package/config.schema.json +28 -2
- package/lib/accessory.js +194 -36
- package/lib/card-controls.js +2 -4
- package/lib/platform.js +82 -6
- package/lib/service-config.js +33 -9
- package/lib/television-accessory.js +174 -18
- package/lib/utils/input-order.js +92 -0
- package/lib/utils/library.js +70 -0
- 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 +1 -1
package/README.md
CHANGED
|
@@ -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
|
|
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**:
|
|
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
|
|
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,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
|
|
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
|
|
582
|
-
*
|
|
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.
|
|
589
|
-
this.#accessory.addService(Service.
|
|
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
|
-
|
|
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
|
|
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
|
|
1795
|
-
this.#log.debug(
|
|
1796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
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']
|
package/lib/platform.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
/** @import { YotoAccountEventMap } from 'yoto-nodejs-client/lib/yoto-account.js' */
|
|
9
9
|
/** @import { PlaybackAccessoryConfig } from './service-config.js' */
|
|
10
10
|
/** @import { CardControlConfig } from './card-controls.js' */
|
|
11
|
+
/** @import { LibraryCard } from './utils/library.js' */
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Context stored in PlatformAccessory for Yoto devices
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
* @property {'card-control'} type - Accessory type marker
|
|
24
25
|
*/
|
|
25
26
|
|
|
27
|
+
import { readFileSync } from 'node:fs'
|
|
26
28
|
import { readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
27
29
|
import { YotoAccount } from 'yoto-nodejs-client'
|
|
28
30
|
import { randomUUID } from 'node:crypto'
|
|
@@ -41,13 +43,17 @@ import { getPlaybackAccessoryConfig } from './service-config.js'
|
|
|
41
43
|
import { getCardControlConfigs } from './card-controls.js'
|
|
42
44
|
import { errorMessage, formatError, getStatusCode } from './utils/error-format.js'
|
|
43
45
|
import { ListenerGroup, logListenerError } from './utils/listener-group.js'
|
|
44
|
-
import { applyTokenUpdate } from './utils/token-config.js'
|
|
46
|
+
import { applyTokenUpdate, findNewerSavedTokens } from './utils/token-config.js'
|
|
45
47
|
import { tolerateMissingStatusScope } from './utils/status-scope-fallback.js'
|
|
48
|
+
import { getTokenClientId } from './utils/token-client-id.js'
|
|
49
|
+
import { fetchFamilyLibrary } from './utils/library.js'
|
|
46
50
|
|
|
47
51
|
/** First retry delay when the Yoto API can't be reached at startup; doubles each attempt */
|
|
48
52
|
const START_RETRY_BASE_MS = 30 * 1000
|
|
49
53
|
/** Longest wait between startup retries */
|
|
50
54
|
const START_RETRY_MAX_MS = 10 * 60 * 1000
|
|
55
|
+
/** How long a fetched card library is reused (shared by all TV accessories) */
|
|
56
|
+
const LIBRARY_CACHE_MS = 10 * 60 * 1000
|
|
51
57
|
|
|
52
58
|
/**
|
|
53
59
|
* Whether a failed account start could succeed if tried again. Network errors
|
|
@@ -86,6 +92,7 @@ export class YotoPlatform {
|
|
|
86
92
|
/** @type {ListenerGroup<YotoAccountEventMap> | null} */ accountListeners = null
|
|
87
93
|
/** @type {string} */ sessionId = randomUUID()
|
|
88
94
|
/** @type {Map<string, Promise<string | null>>} */ cardTitleCache = new Map()
|
|
95
|
+
/** @type {{ fetchedAt: number, cards: Promise<LibraryCard[]> } | null} */ libraryCache = null
|
|
89
96
|
/** @type {boolean} */ authInvalid = false
|
|
90
97
|
/** @type {number} */ startRetryCount = 0
|
|
91
98
|
/** @type {ReturnType<typeof setTimeout> | null} */ startRetryTimer = null
|
|
@@ -106,10 +113,22 @@ export class YotoPlatform {
|
|
|
106
113
|
|
|
107
114
|
log.debug('Finished initializing platform:', config.name)
|
|
108
115
|
|
|
116
|
+
// A child bridge restarted after its process exits gets the config Homebridge
|
|
117
|
+
// read at startup, which can hold tokens already rotated by a refresh
|
|
118
|
+
this.useNewerSavedTokens(config)
|
|
119
|
+
|
|
109
120
|
// Extract auth tokens once
|
|
110
|
-
const clientId = config['clientId'] || DEFAULT_CLIENT_ID
|
|
111
121
|
const refreshToken = config['refreshToken']
|
|
112
122
|
const accessToken = config['accessToken']
|
|
123
|
+
// Refreshing needs the client ID the tokens were issued to. The saved
|
|
124
|
+
// clientId can be stale: the settings form can save an old value back
|
|
125
|
+
// after signing in with another app.
|
|
126
|
+
const configuredClientId = config['clientId'] || DEFAULT_CLIENT_ID
|
|
127
|
+
const tokenClientId = getTokenClientId(accessToken)
|
|
128
|
+
const clientId = tokenClientId || configuredClientId
|
|
129
|
+
if (tokenClientId && tokenClientId !== configuredClientId) {
|
|
130
|
+
log.info(`Using the client ID your Yoto login was issued to (${tokenClientId}) instead of the one in the settings (${configuredClientId}).`)
|
|
131
|
+
}
|
|
113
132
|
|
|
114
133
|
// Debug: Log what we found (redacted)
|
|
115
134
|
log.debug('Config check - Has refreshToken:', !!refreshToken)
|
|
@@ -527,6 +546,38 @@ export class YotoPlatform {
|
|
|
527
546
|
return lookup
|
|
528
547
|
}
|
|
529
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Cards in the family library (Make Your Own cards included), for TV inputs.
|
|
551
|
+
* Cached briefly. Rejects if the library can't be read, so callers keep the
|
|
552
|
+
* inputs they have; a failure isn't cached.
|
|
553
|
+
* @returns {Promise<LibraryCard[]>}
|
|
554
|
+
*/
|
|
555
|
+
getLibraryCards () {
|
|
556
|
+
const now = Date.now()
|
|
557
|
+
if (this.libraryCache && now - this.libraryCache.fetchedAt < LIBRARY_CACHE_MS) {
|
|
558
|
+
return this.libraryCache.cards
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const client = this.yotoAccount?.client
|
|
562
|
+
if (!client) return Promise.reject(new Error('Not signed in to Yoto'))
|
|
563
|
+
|
|
564
|
+
const cards = fetchFamilyLibrary(client).then(cards => {
|
|
565
|
+
for (const card of cards) {
|
|
566
|
+
if (!this.cardTitleCache.has(card.cardId)) {
|
|
567
|
+
this.cardTitleCache.set(card.cardId, Promise.resolve(card.title))
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
this.log.debug(`Loaded ${cards.length} library card(s) for TV inputs`)
|
|
571
|
+
return cards
|
|
572
|
+
})
|
|
573
|
+
cards.catch(error => {
|
|
574
|
+
if (this.libraryCache?.cards === cards) this.libraryCache = null
|
|
575
|
+
this.log.warn(`Could not load the Yoto card library for TV inputs (${errorMessage(error)}).`)
|
|
576
|
+
})
|
|
577
|
+
this.libraryCache = { fetchedAt: now, cards }
|
|
578
|
+
return cards
|
|
579
|
+
}
|
|
580
|
+
|
|
530
581
|
/**
|
|
531
582
|
* @param {string} deviceId
|
|
532
583
|
* @returns {string}
|
|
@@ -624,10 +675,6 @@ export class YotoPlatform {
|
|
|
624
675
|
existingAccessory.category = accessoryCategory
|
|
625
676
|
}
|
|
626
677
|
|
|
627
|
-
// Update accessory information
|
|
628
|
-
this.api.updatePlatformAccessories([existingAccessory])
|
|
629
|
-
this.log.debug('Updated accessory cache entry:', existingAccessory.displayName, existingAccessory.UUID)
|
|
630
|
-
|
|
631
678
|
// Create handler for this accessory with device model
|
|
632
679
|
const handler = new YotoPlayerAccessory({
|
|
633
680
|
platform: this,
|
|
@@ -643,6 +690,11 @@ export class YotoPlatform {
|
|
|
643
690
|
await handler.setup()
|
|
644
691
|
this.log.debug('Accessory setup complete:', existingAccessory.displayName)
|
|
645
692
|
|
|
693
|
+
// Save the cache after setup, so services it added or removed (e.g. the
|
|
694
|
+
// old sleep timer Switch) are kept on the next start
|
|
695
|
+
this.api.updatePlatformAccessories([existingAccessory])
|
|
696
|
+
this.log.debug('Updated accessory cache entry:', existingAccessory.displayName, existingAccessory.UUID)
|
|
697
|
+
|
|
646
698
|
if (this.playbackAccessoryConfig.smartSpeakerEnabled) {
|
|
647
699
|
await this.registerSpeakerAccessory(device, deviceModel)
|
|
648
700
|
}
|
|
@@ -1092,6 +1144,30 @@ export class YotoPlatform {
|
|
|
1092
1144
|
this.log.debug('✓ Yoto platform shutdown complete')
|
|
1093
1145
|
}
|
|
1094
1146
|
|
|
1147
|
+
/**
|
|
1148
|
+
* Replace the tokens in `config` with newer ones saved in config.json, if any.
|
|
1149
|
+
* @param {PlatformConfig} config
|
|
1150
|
+
*/
|
|
1151
|
+
useNewerSavedTokens (config) {
|
|
1152
|
+
try {
|
|
1153
|
+
const bridge = config['_bridge']
|
|
1154
|
+
const bridgeUsername = bridge && typeof bridge === 'object' && typeof bridge.username === 'string'
|
|
1155
|
+
? bridge.username
|
|
1156
|
+
: undefined
|
|
1157
|
+
const saved = findNewerSavedTokens(readFileSync(this.api.user.configPath(), 'utf8'), {
|
|
1158
|
+
platform: PLATFORM_NAME,
|
|
1159
|
+
...(bridgeUsername && { bridgeUsername }),
|
|
1160
|
+
refreshToken: config['refreshToken'],
|
|
1161
|
+
tokenExpiresAt: config['tokenExpiresAt'],
|
|
1162
|
+
})
|
|
1163
|
+
if (!saved) return
|
|
1164
|
+
this.log.debug('Using the newer tokens saved in config.json')
|
|
1165
|
+
Object.assign(config, saved)
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
this.log.debug('Could not read saved tokens from config.json:', errorMessage(error))
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1095
1171
|
/**
|
|
1096
1172
|
* Update Homebridge config.json file
|
|
1097
1173
|
* @param {(configContents: string) => string | null} updateFn - Returns updated contents, or null if it could not apply the update
|
package/lib/service-config.js
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
import { getBooleanSetting } from './utils/get-boolean-setting.js'
|
|
4
4
|
import { serviceSchema } from '../config.schema.cjs'
|
|
5
|
+
import { getSleepTimerMinutesPerPercent } from './utils/sleep-timer.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `services` section of the platform config, or {} if missing.
|
|
9
|
+
* @param {PlatformConfig} config
|
|
10
|
+
* @returns {Record<string, unknown>}
|
|
11
|
+
*/
|
|
12
|
+
export function getServiceConfig (config) {
|
|
13
|
+
const services = config && typeof config === 'object' ? config['services'] : undefined
|
|
14
|
+
return typeof services === 'object' && services !== null
|
|
15
|
+
? /** @type {Record<string, unknown>} */ (services)
|
|
16
|
+
: {}
|
|
17
|
+
}
|
|
5
18
|
|
|
6
19
|
/**
|
|
7
20
|
* @typedef {Object} PlaybackAccessoryConfig
|
|
@@ -16,10 +29,7 @@ import { serviceSchema } from '../config.schema.cjs'
|
|
|
16
29
|
* @returns {PlaybackAccessoryConfig}
|
|
17
30
|
*/
|
|
18
31
|
export function getPlaybackAccessoryConfig (config) {
|
|
19
|
-
const
|
|
20
|
-
const serviceConfig = typeof services === 'object' && services !== null
|
|
21
|
-
? /** @type {Record<string, unknown>} */ (services)
|
|
22
|
-
: {}
|
|
32
|
+
const serviceConfig = getServiceConfig(config)
|
|
23
33
|
|
|
24
34
|
const playbackEnabled = getBooleanSetting(serviceConfig['playbackControls'], false)
|
|
25
35
|
const smartSpeakerEnabled = getBooleanSetting(serviceConfig['smartSpeaker'], false)
|
|
@@ -39,9 +49,23 @@ export function getPlaybackAccessoryConfig (config) {
|
|
|
39
49
|
* @returns {boolean}
|
|
40
50
|
*/
|
|
41
51
|
export function getShortcutsEnabled (config) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
return getBooleanSetting(getServiceConfig(config)['shortcuts'], serviceSchema.shortcuts.default)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether the TV lists library cards as inputs.
|
|
57
|
+
* @param {PlatformConfig} config
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
export function getTelevisionLibraryEnabled (config) {
|
|
61
|
+
return getBooleanSetting(getServiceConfig(config)['televisionLibrary'], serviceSchema.televisionLibrary.default)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Minutes each 1% on the sleep timer slider stands for.
|
|
66
|
+
* @param {PlatformConfig} config
|
|
67
|
+
* @returns {number}
|
|
68
|
+
*/
|
|
69
|
+
export function getSleepTimerIncrement (config) {
|
|
70
|
+
return getSleepTimerMinutesPerPercent(getServiceConfig(config)['sleepTimerMinutesPerPercent'])
|
|
47
71
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
/** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
|
|
10
10
|
/** @import { YotoAccessoryContext } from './platform.js' */
|
|
11
11
|
/** @import { PlayableCard } from './card-controls.js' */
|
|
12
|
+
/** @import { LibraryCard } from './utils/library.js' */
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* A TV input and the content it plays (null for the "now playing" input).
|
|
@@ -17,6 +18,16 @@
|
|
|
17
18
|
* @property {PlayableCard | null} card
|
|
18
19
|
*/
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* An input other than "now playing", before it gets an identifier.
|
|
23
|
+
* @typedef {Object} InputSpec
|
|
24
|
+
* @property {string} subtype
|
|
25
|
+
* @property {string} name
|
|
26
|
+
* @property {PlayableCard} card
|
|
27
|
+
* @property {boolean} forceName - Apply the name even if the input already exists
|
|
28
|
+
* @property {boolean} resolveName - Rename to the card title once known
|
|
29
|
+
*/
|
|
30
|
+
|
|
20
31
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
21
32
|
import {
|
|
22
33
|
DEFAULT_MANUFACTURER,
|
|
@@ -31,7 +42,8 @@ import { setDeviceVolume } from './utils/set-device-volume.js'
|
|
|
31
42
|
import { getTrimmedString } from './utils/get-trimmed-string.js'
|
|
32
43
|
import { getCardControlConfigs } from './card-controls.js'
|
|
33
44
|
import { getCardStartOptions, getShortcutEntries, getShortcutsSignature, toPlayableCard } from './shortcuts.js'
|
|
34
|
-
import { getShortcutsEnabled } from './service-config.js'
|
|
45
|
+
import { getShortcutsEnabled, getTelevisionLibraryEnabled } from './service-config.js'
|
|
46
|
+
import { allocateInputIdentifiers, encodeDisplayOrder, sortByName } from './utils/input-order.js'
|
|
35
47
|
import {
|
|
36
48
|
clampPercent,
|
|
37
49
|
clampSteps,
|
|
@@ -42,7 +54,15 @@ import {
|
|
|
42
54
|
|
|
43
55
|
/** Identifier of the input that represents whatever is currently playing */
|
|
44
56
|
const NOW_PLAYING_INPUT_ID = 1
|
|
45
|
-
const
|
|
57
|
+
const LIBRARY_INPUT_PREFIX = 'LibraryInput:'
|
|
58
|
+
const INPUT_SUBTYPE_PREFIXES = ['CardInput:', 'ShortcutInput:', LIBRARY_INPUT_PREFIX]
|
|
59
|
+
/**
|
|
60
|
+
* Most inputs to publish, "now playing" included. HAP allows 100 services per
|
|
61
|
+
* accessory, and the TV also needs its information, TV and speaker services.
|
|
62
|
+
*/
|
|
63
|
+
const MAX_TV_INPUTS = 90
|
|
64
|
+
/** How often to look for cards added to the library */
|
|
65
|
+
const LIBRARY_REFRESH_MS = 6 * 60 * 60 * 1000
|
|
46
66
|
/**
|
|
47
67
|
* Picking an input while the TV is off makes Home send Active=1 and then the
|
|
48
68
|
* input. Wait this long before resuming so a picked input can cancel the resume.
|
|
@@ -68,6 +88,11 @@ export class YotoTelevisionAccessory {
|
|
|
68
88
|
/** @type {Map<number, TelevisionInput>} */ #inputs = new Map()
|
|
69
89
|
/** @type {string} */ #shortcutsSignature = ''
|
|
70
90
|
/** @type {number} */ #resumeRequest = 0
|
|
91
|
+
/** @type {LibraryCard[] | null} */ #libraryCards = null
|
|
92
|
+
/** @type {string} */ #librarySignature = ''
|
|
93
|
+
/** @type {number} */ #omittedLibraryCards = 0
|
|
94
|
+
/** @type {ReturnType<typeof setInterval> | null} */ #libraryRefreshTimer = null
|
|
95
|
+
/** @type {boolean} */ #stopped = false
|
|
71
96
|
|
|
72
97
|
/**
|
|
73
98
|
* @param {Object} params
|
|
@@ -114,6 +139,12 @@ export class YotoTelevisionAccessory {
|
|
|
114
139
|
|
|
115
140
|
this.setupEventListeners()
|
|
116
141
|
|
|
142
|
+
if (getTelevisionLibraryEnabled(this.#platform.config)) {
|
|
143
|
+
this.loadLibraryInputs()
|
|
144
|
+
this.#libraryRefreshTimer = setInterval(() => this.loadLibraryInputs(), LIBRARY_REFRESH_MS)
|
|
145
|
+
this.#libraryRefreshTimer.unref?.()
|
|
146
|
+
}
|
|
147
|
+
|
|
117
148
|
this.#log.debug(LOG_PREFIX.ACCESSORY, `✓ ${this.#device.name} playback ready`)
|
|
118
149
|
}
|
|
119
150
|
|
|
@@ -227,21 +258,27 @@ export class YotoTelevisionAccessory {
|
|
|
227
258
|
}
|
|
228
259
|
|
|
229
260
|
/**
|
|
230
|
-
* Setup InputSource services: "now playing", configured card controls,
|
|
231
|
-
* (when enabled) device shortcuts
|
|
261
|
+
* Setup InputSource services: "now playing", configured card controls,
|
|
262
|
+
* (when enabled) device shortcuts, then library cards up to the HomeKit
|
|
263
|
+
* limit. Removes inputs that no longer exist.
|
|
232
264
|
*/
|
|
233
265
|
setupInputSources () {
|
|
234
|
-
|
|
235
|
-
const inputs = new Map()
|
|
236
|
-
let identifier = NOW_PLAYING_INPUT_ID
|
|
266
|
+
const { Service, Characteristic } = this.#platform
|
|
237
267
|
|
|
238
|
-
|
|
239
|
-
|
|
268
|
+
/** @type {InputSpec[]} */
|
|
269
|
+
const specs = []
|
|
270
|
+
/** Cards already reachable from start, so the library doesn't repeat them */
|
|
271
|
+
const wholeCardIds = new Set()
|
|
240
272
|
|
|
241
273
|
for (const control of getCardControlConfigs(this.#platform.config)) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
274
|
+
specs.push({
|
|
275
|
+
subtype: `CardInput:${control.id}`,
|
|
276
|
+
name: control.label,
|
|
277
|
+
card: { label: control.label, cardId: control.cardId },
|
|
278
|
+
forceName: false,
|
|
279
|
+
resolveName: false,
|
|
280
|
+
})
|
|
281
|
+
wholeCardIds.add(control.cardId)
|
|
245
282
|
}
|
|
246
283
|
|
|
247
284
|
const shortcuts = getShortcutsEnabled(this.#platform.config)
|
|
@@ -250,12 +287,66 @@ export class YotoTelevisionAccessory {
|
|
|
250
287
|
this.#shortcutsSignature = getShortcutsSignature(shortcuts)
|
|
251
288
|
|
|
252
289
|
for (const entry of shortcuts) {
|
|
253
|
-
identifier += 1
|
|
254
290
|
const fallbackName = entry.builtInName ?? `Shortcut ${entry.number}`
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
291
|
+
specs.push({
|
|
292
|
+
subtype: `ShortcutInput:${entry.id}`,
|
|
293
|
+
name: fallbackName,
|
|
294
|
+
card: toPlayableCard(entry, fallbackName),
|
|
295
|
+
forceName: Boolean(entry.builtInName),
|
|
296
|
+
resolveName: !entry.builtInName,
|
|
297
|
+
})
|
|
298
|
+
if (!entry.chapterKey && !entry.trackKey) wholeCardIds.add(entry.cardId)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Library cards fill the remaining inputs, alphabetically
|
|
302
|
+
const libraryCards = sortByName(
|
|
303
|
+
this.getLibraryInputCards()
|
|
304
|
+
.filter(card => !wholeCardIds.has(card.cardId))
|
|
305
|
+
.map(card => ({ ...card, name: card.title }))
|
|
306
|
+
)
|
|
307
|
+
const room = Math.max(0, MAX_TV_INPUTS - 1 - specs.length)
|
|
308
|
+
for (const libraryCard of libraryCards.slice(0, room)) {
|
|
309
|
+
specs.push({
|
|
310
|
+
subtype: `${LIBRARY_INPUT_PREFIX}${libraryCard.cardId}`,
|
|
311
|
+
name: libraryCard.title,
|
|
312
|
+
card: { label: libraryCard.title, cardId: libraryCard.cardId },
|
|
313
|
+
forceName: true,
|
|
314
|
+
resolveName: false,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
const omitted = libraryCards.length - Math.min(room, libraryCards.length)
|
|
318
|
+
if (omitted > 0 && omitted !== this.#omittedLibraryCards) {
|
|
319
|
+
this.#log.warn(
|
|
320
|
+
LOG_PREFIX.ACCESSORY,
|
|
321
|
+
`[${this.#device.name}] HomeKit allows ${MAX_TV_INPUTS} TV inputs, so ${omitted} library card(s) were left out (the last ones alphabetically). Add a card control for any you need.`
|
|
322
|
+
)
|
|
323
|
+
}
|
|
324
|
+
this.#omittedLibraryCards = omitted
|
|
325
|
+
|
|
326
|
+
// Inputs that already exist keep their identifiers, so Home scenes do too
|
|
327
|
+
/** @type {Map<string, number>} */
|
|
328
|
+
const currentIdentifiers = new Map()
|
|
329
|
+
for (const service of this.#accessory.services) {
|
|
330
|
+
if (service.UUID !== Service.InputSource.UUID || !service.subtype) continue
|
|
331
|
+
const identifier = service.getCharacteristic(Characteristic.Identifier).value
|
|
332
|
+
if (typeof identifier === 'number') currentIdentifiers.set(service.subtype, identifier)
|
|
333
|
+
}
|
|
334
|
+
const identifiers = allocateInputIdentifiers(
|
|
335
|
+
specs.map(spec => spec.subtype),
|
|
336
|
+
currentIdentifiers,
|
|
337
|
+
[NOW_PLAYING_INPUT_ID]
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
/** @type {Map<number, TelevisionInput>} */
|
|
341
|
+
const inputs = new Map()
|
|
342
|
+
const nowPlaying = this.configureInputSource('PlaybackInput', NOW_PLAYING_INPUT_ID, `${this.#device.name} Now Playing`)
|
|
343
|
+
inputs.set(NOW_PLAYING_INPUT_ID, { service: nowPlaying, card: null })
|
|
344
|
+
|
|
345
|
+
for (const spec of specs) {
|
|
346
|
+
const identifier = /** @type {number} */ (identifiers.get(spec.subtype))
|
|
347
|
+
const service = this.configureInputSource(spec.subtype, identifier, spec.name, spec.forceName)
|
|
348
|
+
inputs.set(identifier, { service, card: spec.card })
|
|
349
|
+
if (spec.resolveName) this.resolveInputName(service, spec.card)
|
|
259
350
|
}
|
|
260
351
|
|
|
261
352
|
// Drop inputs that are no longer configured
|
|
@@ -271,6 +362,66 @@ export class YotoTelevisionAccessory {
|
|
|
271
362
|
|
|
272
363
|
this.#inputs = inputs
|
|
273
364
|
this.inputSourceService = nowPlaying
|
|
365
|
+
this.updateDisplayOrder()
|
|
366
|
+
this.updateActiveIdentifierCharacteristic()
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Library cards to list as inputs. Until the library has loaded, these are
|
|
371
|
+
* the library inputs already published (a re-attached handler), so they
|
|
372
|
+
* aren't removed and added again.
|
|
373
|
+
* @returns {LibraryCard[]}
|
|
374
|
+
*/
|
|
375
|
+
getLibraryInputCards () {
|
|
376
|
+
if (!getTelevisionLibraryEnabled(this.#platform.config)) return []
|
|
377
|
+
if (this.#libraryCards) return this.#libraryCards
|
|
378
|
+
|
|
379
|
+
const { Characteristic } = this.#platform
|
|
380
|
+
/** @type {LibraryCard[]} */
|
|
381
|
+
const published = []
|
|
382
|
+
for (const service of this.#accessory.services) {
|
|
383
|
+
const subtype = service.subtype ?? ''
|
|
384
|
+
if (!subtype.startsWith(LIBRARY_INPUT_PREFIX)) continue
|
|
385
|
+
const title = service.getCharacteristic(Characteristic.ConfiguredName).value
|
|
386
|
+
if (typeof title === 'string' && title) {
|
|
387
|
+
published.push({ cardId: subtype.slice(LIBRARY_INPUT_PREFIX.length), title })
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return published
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Fetch the card library and rebuild the inputs if it changed. If it can't
|
|
395
|
+
* be read, the current inputs stay.
|
|
396
|
+
*/
|
|
397
|
+
loadLibraryInputs () {
|
|
398
|
+
this.#platform.getLibraryCards().then(cards => {
|
|
399
|
+
if (this.#stopped) return
|
|
400
|
+
const signature = cards.map(card => `${card.cardId}\u0000${card.title}`).join('\u0001')
|
|
401
|
+
if (this.#libraryCards && signature === this.#librarySignature) return
|
|
402
|
+
this.#librarySignature = signature
|
|
403
|
+
this.#libraryCards = cards
|
|
404
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Library changed, updating TV inputs`)
|
|
405
|
+
this.setupInputSources()
|
|
406
|
+
}).catch(error => {
|
|
407
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Keeping the current library TV inputs:`, formatError(error))
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* List inputs alphabetically in the Home app, with "now playing" first.
|
|
413
|
+
*/
|
|
414
|
+
updateDisplayOrder () {
|
|
415
|
+
if (!this.televisionService) return
|
|
416
|
+
const { Characteristic } = this.#platform
|
|
417
|
+
const named = []
|
|
418
|
+
for (const [identifier, input] of this.#inputs) {
|
|
419
|
+
if (identifier === NOW_PLAYING_INPUT_ID) continue
|
|
420
|
+
const name = input.service.getCharacteristic(Characteristic.ConfiguredName).value
|
|
421
|
+
named.push({ identifier, name: typeof name === 'string' ? name : '' })
|
|
422
|
+
}
|
|
423
|
+
const order = [NOW_PLAYING_INPUT_ID, ...sortByName(named).map(input => input.identifier)]
|
|
424
|
+
this.televisionService.updateCharacteristic(Characteristic.DisplayOrder, encodeDisplayOrder(order))
|
|
274
425
|
}
|
|
275
426
|
|
|
276
427
|
/**
|
|
@@ -340,6 +491,7 @@ export class YotoTelevisionAccessory {
|
|
|
340
491
|
service
|
|
341
492
|
.updateCharacteristic(Characteristic.Name, name)
|
|
342
493
|
.updateCharacteristic(Characteristic.ConfiguredName, name)
|
|
494
|
+
this.updateDisplayOrder()
|
|
343
495
|
}).catch(error => {
|
|
344
496
|
this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to name TV input:`, formatError(error))
|
|
345
497
|
})
|
|
@@ -508,7 +660,6 @@ export class YotoTelevisionAccessory {
|
|
|
508
660
|
if (signature === this.#shortcutsSignature) return
|
|
509
661
|
this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Shortcuts changed, updating TV inputs`)
|
|
510
662
|
this.setupInputSources()
|
|
511
|
-
this.updateActiveIdentifierCharacteristic()
|
|
512
663
|
})
|
|
513
664
|
|
|
514
665
|
this.#listeners.on('online', ({ reason: _reason }) => {
|
|
@@ -986,5 +1137,10 @@ export class YotoTelevisionAccessory {
|
|
|
986
1137
|
this.#listeners.removeAll()
|
|
987
1138
|
// Drop any pending resume
|
|
988
1139
|
this.#resumeRequest++
|
|
1140
|
+
this.#stopped = true
|
|
1141
|
+
if (this.#libraryRefreshTimer) {
|
|
1142
|
+
clearInterval(this.#libraryRefreshTimer)
|
|
1143
|
+
this.#libraryRefreshTimer = null
|
|
1144
|
+
}
|
|
989
1145
|
}
|
|
990
1146
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview TV input identifiers and display order.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Identifiers from here up are derived from the input subtype */
|
|
6
|
+
const FIRST_HASHED_IDENTIFIER = 2
|
|
7
|
+
const IDENTIFIER_RANGE = 999_998
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pick a stable identifier for an input, so Home scenes that select an input
|
|
11
|
+
* keep pointing at the same card when other inputs are added or removed.
|
|
12
|
+
* Collisions move to the next free identifier.
|
|
13
|
+
* @param {string} subtype
|
|
14
|
+
* @param {Set<number>} used - Identifiers already taken (updated in place)
|
|
15
|
+
* @returns {number}
|
|
16
|
+
*/
|
|
17
|
+
export function allocateInputIdentifier (subtype, used) {
|
|
18
|
+
// FNV-1a
|
|
19
|
+
let hash = 0x811c9dc5
|
|
20
|
+
for (let i = 0; i < subtype.length; i++) {
|
|
21
|
+
hash ^= subtype.charCodeAt(i)
|
|
22
|
+
hash = Math.imul(hash, 0x01000193) >>> 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let offset = hash % IDENTIFIER_RANGE
|
|
26
|
+
while (used.has(FIRST_HASHED_IDENTIFIER + offset)) {
|
|
27
|
+
offset = (offset + 1) % IDENTIFIER_RANGE
|
|
28
|
+
}
|
|
29
|
+
const identifier = FIRST_HASHED_IDENTIFIER + offset
|
|
30
|
+
used.add(identifier)
|
|
31
|
+
return identifier
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pick identifiers for a set of inputs. An input that already has one keeps
|
|
36
|
+
* it, so adding or removing other inputs never moves it (a hash collision
|
|
37
|
+
* would otherwise depend on which inputs exist). New inputs get one from
|
|
38
|
+
* `allocateInputIdentifier`.
|
|
39
|
+
* @param {string[]} subtypes - Inputs to identify, in allocation order
|
|
40
|
+
* @param {Map<string, number>} current - Identifiers the inputs already have
|
|
41
|
+
* @param {Iterable<number>} reserved - Identifiers no input may take
|
|
42
|
+
* @returns {Map<string, number>} Identifier by subtype
|
|
43
|
+
*/
|
|
44
|
+
export function allocateInputIdentifiers (subtypes, current, reserved) {
|
|
45
|
+
const used = new Set(reserved)
|
|
46
|
+
/** @type {Map<string, number>} */
|
|
47
|
+
const result = new Map()
|
|
48
|
+
for (const subtype of subtypes) {
|
|
49
|
+
const identifier = current.get(subtype)
|
|
50
|
+
if (identifier !== undefined && identifier >= FIRST_HASHED_IDENTIFIER && !used.has(identifier)) {
|
|
51
|
+
result.set(subtype, identifier)
|
|
52
|
+
used.add(identifier)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const subtype of subtypes) {
|
|
56
|
+
if (!result.has(subtype)) result.set(subtype, allocateInputIdentifier(subtype, used))
|
|
57
|
+
}
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Encode the Television DisplayOrder characteristic: one TLV entry (type 1,
|
|
63
|
+
* uint32 little-endian identifier) per input, separated by empty TLVs.
|
|
64
|
+
* @param {number[]} identifiers - In display order
|
|
65
|
+
* @returns {string} Base64 TLV8
|
|
66
|
+
*/
|
|
67
|
+
export function encodeDisplayOrder (identifiers) {
|
|
68
|
+
/** @type {Buffer[]} */
|
|
69
|
+
const parts = []
|
|
70
|
+
identifiers.forEach((identifier, index) => {
|
|
71
|
+
if (index > 0) parts.push(Buffer.from([0x00, 0x00]))
|
|
72
|
+
const entry = Buffer.alloc(6)
|
|
73
|
+
entry.writeUInt8(0x01, 0)
|
|
74
|
+
entry.writeUInt8(4, 1)
|
|
75
|
+
entry.writeUInt32LE(identifier, 2)
|
|
76
|
+
parts.push(entry)
|
|
77
|
+
})
|
|
78
|
+
return Buffer.concat(parts).toString('base64')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true })
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Sort alphabetically by name, ignoring case and accents, with numbers in
|
|
85
|
+
* numeric order ("Book 2" before "Book 10").
|
|
86
|
+
* @template {{ name: string }} T
|
|
87
|
+
* @param {T[]} items
|
|
88
|
+
* @returns {T[]}
|
|
89
|
+
*/
|
|
90
|
+
export function sortByName (items) {
|
|
91
|
+
return [...items].sort((a, b) => collator.compare(a.name, b.name))
|
|
92
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Family card library lookup for TV inputs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @import { YotoClient } from 'yoto-nodejs-client' */
|
|
6
|
+
|
|
7
|
+
import { YOTO_API_URL } from 'yoto-nodejs-client/lib/api-endpoints/constants.js'
|
|
8
|
+
import { defaultAuthHeaders, YotoAPIError } from 'yoto-nodejs-client/lib/api-endpoints/helpers.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {Object} LibraryCard
|
|
12
|
+
* @property {string} cardId
|
|
13
|
+
* @property {string} title
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Give up on a library request that takes longer than this */
|
|
17
|
+
const LIBRARY_TIMEOUT_MS = 30_000
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read `{ cards: [{ cardId, inFamilyLibrary, card: { title } }] }` from
|
|
21
|
+
* GET /card/family/library. Entries without an ID or title, marked as not in
|
|
22
|
+
* the library, or repeating a card already listed are skipped.
|
|
23
|
+
* @param {unknown} body
|
|
24
|
+
* @returns {LibraryCard[]}
|
|
25
|
+
*/
|
|
26
|
+
export function parseFamilyLibrary (body) {
|
|
27
|
+
const cards = body && typeof body === 'object' && 'cards' in body && Array.isArray(body.cards)
|
|
28
|
+
? body.cards
|
|
29
|
+
: []
|
|
30
|
+
|
|
31
|
+
/** @type {Map<string, LibraryCard>} */
|
|
32
|
+
const byId = new Map()
|
|
33
|
+
for (const entry of cards) {
|
|
34
|
+
if (!entry || typeof entry !== 'object') continue
|
|
35
|
+
const record = /** @type {{ cardId?: unknown, inFamilyLibrary?: unknown, card?: { title?: unknown } }} */ (entry)
|
|
36
|
+
if (record.inFamilyLibrary === false) continue
|
|
37
|
+
const cardId = typeof record.cardId === 'string' ? record.cardId.trim() : ''
|
|
38
|
+
const title = typeof record.card?.title === 'string' ? record.card.title.trim() : ''
|
|
39
|
+
if (cardId && title && !byId.has(cardId)) byId.set(cardId, { cardId, title })
|
|
40
|
+
}
|
|
41
|
+
return [...byId.values()]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fetch the family library (needs the `family:library:view` scope). It
|
|
46
|
+
* includes Make Your Own cards. yoto-nodejs-client has no method for this
|
|
47
|
+
* endpoint, so this uses its URL, headers and error type.
|
|
48
|
+
* @param {YotoClient} client
|
|
49
|
+
* @returns {Promise<LibraryCard[]>}
|
|
50
|
+
*/
|
|
51
|
+
export async function fetchFamilyLibrary (client) {
|
|
52
|
+
const accessToken = await client.token.getAccessToken()
|
|
53
|
+
const response = await fetch(new URL('/card/family/library', YOTO_API_URL), {
|
|
54
|
+
headers: defaultAuthHeaders({ accessToken }),
|
|
55
|
+
signal: AbortSignal.timeout(LIBRARY_TIMEOUT_MS),
|
|
56
|
+
})
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const textBody = await response.text()
|
|
59
|
+
let jsonBody = null
|
|
60
|
+
try {
|
|
61
|
+
jsonBody = JSON.parse(textBody)
|
|
62
|
+
} catch {
|
|
63
|
+
jsonBody = null
|
|
64
|
+
}
|
|
65
|
+
// YotoAPIError only reads the status code from the response
|
|
66
|
+
const responseData = /** @type {ConstructorParameters<typeof YotoAPIError>[0]} */ (/** @type {unknown} */ ({ statusCode: response.status }))
|
|
67
|
+
throw new YotoAPIError(responseData, textBody, jsonBody)
|
|
68
|
+
}
|
|
69
|
+
return parseFamilyLibrary(await response.json())
|
|
70
|
+
}
|
|
@@ -27,12 +27,31 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Starts and ends with a letter or number. Exception: may end with a period.
|
|
29
29
|
* May have the following special characters: -"',.#&.
|
|
30
|
-
* Must not include emojis.
|
|
30
|
+
* Must not include emojis. At most 64 characters (HAP's string limit).
|
|
31
31
|
*
|
|
32
32
|
* @param {string} name - The name to sanitize
|
|
33
33
|
* @returns {string} The HomeKit-sanitized version of the name
|
|
34
34
|
*/
|
|
35
35
|
export function sanitizeName (name) {
|
|
36
|
+
const cleaned = cleanName(name
|
|
37
|
+
// Curly quotes (common in card titles) become their allowed straight forms.
|
|
38
|
+
.replace(/[\u2018\u2019]/g, "'")
|
|
39
|
+
.replace(/[\u201C\u201D]/g, '"'))
|
|
40
|
+
if (cleaned.length <= MAX_NAME_LENGTH) return cleaned
|
|
41
|
+
|
|
42
|
+
// Too long: cut at the last word break that fits, then tidy the end again
|
|
43
|
+
const cut = cleaned.slice(0, MAX_NAME_LENGTH + 1)
|
|
44
|
+
const lastSpace = cut.lastIndexOf(' ')
|
|
45
|
+
return cleanName(lastSpace > 0 ? cut.slice(0, lastSpace) : cleaned.slice(0, MAX_NAME_LENGTH))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MAX_NAME_LENGTH = 64
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} name
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function cleanName (name) {
|
|
36
55
|
return name
|
|
37
56
|
// Replace any disallowed char (including emojis) with a space.
|
|
38
57
|
.replace(/[^\p{L}\p{N}\-"'.,#&\s]/gu, ' ')
|
|
@@ -42,8 +61,9 @@ export function sanitizeName (name) {
|
|
|
42
61
|
.trim()
|
|
43
62
|
// Strip any leading non-letter/number.
|
|
44
63
|
.replace(/^[^\p{L}\p{N}]+/u, '')
|
|
64
|
+
// Strip trailing chars that aren't a letter/number/period, with any
|
|
65
|
+
// spaces before them (e.g. the " &" in "Tales &").
|
|
66
|
+
.replace(/[^\p{L}\p{N}.]+$/u, '')
|
|
45
67
|
// Collapse two or more trailing periods into one.
|
|
46
68
|
.replace(/\.{2,}$/g, '.')
|
|
47
|
-
// Remove any other trailing char that's not letter/number/period.
|
|
48
|
-
.replace(/[^\p{L}\p{N}.]$/u, '')
|
|
49
69
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Sleep timer slider helpers: each slider percent is a fixed
|
|
3
|
+
* number of minutes (1% = 1 minute by default).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_SLEEP_TIMER_MINUTES_PER_PERCENT = 1
|
|
7
|
+
export const MAX_SLEEP_TIMER_MINUTES_PER_PERCENT = 10
|
|
8
|
+
/** Duration used when the timer is switched on without picking a time */
|
|
9
|
+
export const DEFAULT_SLEEP_TIMER_MINUTES = 30
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read a minutes-per-percent setting, falling back to the default.
|
|
13
|
+
* @param {unknown} value
|
|
14
|
+
* @returns {number}
|
|
15
|
+
*/
|
|
16
|
+
export function getSleepTimerMinutesPerPercent (value) {
|
|
17
|
+
const minutes = typeof value === 'string' ? Number(value) : value
|
|
18
|
+
if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes <= 0) {
|
|
19
|
+
return DEFAULT_SLEEP_TIMER_MINUTES_PER_PERCENT
|
|
20
|
+
}
|
|
21
|
+
return Math.min(minutes, MAX_SLEEP_TIMER_MINUTES_PER_PERCENT)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Slider percent for the time left, rounded up so the slider only reaches 0
|
|
26
|
+
* when the timer ends. Timers longer than the slider shows stay at 100.
|
|
27
|
+
* @param {number} remainingSeconds
|
|
28
|
+
* @param {number} minutesPerPercent
|
|
29
|
+
* @returns {number}
|
|
30
|
+
*/
|
|
31
|
+
export function sleepSecondsToPercent (remainingSeconds, minutesPerPercent) {
|
|
32
|
+
if (!Number.isFinite(remainingSeconds) || remainingSeconds <= 0) return 0
|
|
33
|
+
return Math.min(100, Math.ceil(remainingSeconds / (minutesPerPercent * 60)))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Timer length in seconds for a slider percent.
|
|
38
|
+
* @param {number} percent
|
|
39
|
+
* @param {number} minutesPerPercent
|
|
40
|
+
* @returns {number}
|
|
41
|
+
*/
|
|
42
|
+
export function sleepPercentToSeconds (percent, minutesPerPercent) {
|
|
43
|
+
const clamped = Number.isFinite(percent) ? Math.max(0, Math.min(Math.round(percent), 100)) : 0
|
|
44
|
+
return Math.round(clamped * minutesPerPercent * 60)
|
|
45
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Read which OAuth client an access token was issued to.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `azp` (authorized party) claim of a Yoto access token: the client ID it
|
|
7
|
+
* was issued to. Refreshing must use that same client ID, or Yoto rejects the
|
|
8
|
+
* refresh token with invalid_grant. The signature isn't checked; this only
|
|
9
|
+
* picks which client ID to send.
|
|
10
|
+
* @param {unknown} accessToken
|
|
11
|
+
* @returns {string | undefined}
|
|
12
|
+
*/
|
|
13
|
+
export function getTokenClientId (accessToken) {
|
|
14
|
+
if (typeof accessToken !== 'string') return undefined
|
|
15
|
+
const payload = accessToken.split('.')[1]
|
|
16
|
+
if (!payload) return undefined
|
|
17
|
+
try {
|
|
18
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
|
|
19
|
+
const azp = claims && typeof claims === 'object' ? claims.azp : undefined
|
|
20
|
+
return typeof azp === 'string' && azp.trim() ? azp.trim() : undefined
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -57,3 +57,60 @@ export function applyTokenUpdate (configContents, update) {
|
|
|
57
57
|
// Homebridge writes config.json with 4-space indentation
|
|
58
58
|
return JSON.stringify(parsed, null, 4)
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {Object} SavedTokens
|
|
63
|
+
* @property {string} accessToken
|
|
64
|
+
* @property {string} refreshToken
|
|
65
|
+
* @property {number} tokenExpiresAt
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Tokens in config.json that are newer than the ones Homebridge handed the plugin.
|
|
70
|
+
*
|
|
71
|
+
* Homebridge gives a child bridge the config it read when Homebridge started, and
|
|
72
|
+
* reuses it when the child bridge restarts after its process exits. After a token
|
|
73
|
+
* refresh that copy holds a refresh token Yoto has already rotated away, and using
|
|
74
|
+
* it again fails with invalid_grant. The file on disk has the current ones.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} configContents - Raw config.json contents
|
|
77
|
+
* @param {Object} options
|
|
78
|
+
* @param {string} options.platform - Platform name to match (e.g. 'Yoto')
|
|
79
|
+
* @param {string} [options.bridgeUsername] - Child bridge username, to pick the block when there are several
|
|
80
|
+
* @param {unknown} options.refreshToken - Refresh token Homebridge passed in
|
|
81
|
+
* @param {unknown} options.tokenExpiresAt - Its expiry (ms)
|
|
82
|
+
* @returns {SavedTokens | null}
|
|
83
|
+
*/
|
|
84
|
+
export function findNewerSavedTokens (configContents, { platform, bridgeUsername, refreshToken, tokenExpiresAt }) {
|
|
85
|
+
/** @type {unknown} */
|
|
86
|
+
let parsed
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(configContents)
|
|
89
|
+
} catch {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
93
|
+
const platforms = /** @type {Record<string, unknown>} */ (parsed)['platforms']
|
|
94
|
+
if (!Array.isArray(platforms)) return null
|
|
95
|
+
|
|
96
|
+
/** @type {Record<string, unknown>[]} */
|
|
97
|
+
const candidates = platforms.filter(entry => entry && typeof entry === 'object' && entry.platform === platform)
|
|
98
|
+
const block = bridgeUsername
|
|
99
|
+
? candidates.find(entry => {
|
|
100
|
+
const bridge = entry['_bridge']
|
|
101
|
+
return bridge && typeof bridge === 'object' && /** @type {Record<string, unknown>} */ (bridge)['username'] === bridgeUsername
|
|
102
|
+
})
|
|
103
|
+
: (candidates.length === 1 ? candidates[0] : undefined)
|
|
104
|
+
if (!block) return null
|
|
105
|
+
|
|
106
|
+
const savedAccess = block['accessToken']
|
|
107
|
+
const savedRefresh = block['refreshToken']
|
|
108
|
+
const savedExpiresAt = block['tokenExpiresAt']
|
|
109
|
+
if (typeof savedAccess !== 'string' || typeof savedRefresh !== 'string' || typeof savedExpiresAt !== 'number') return null
|
|
110
|
+
if (savedRefresh === refreshToken) return null
|
|
111
|
+
|
|
112
|
+
const currentExpiresAt = typeof tokenExpiresAt === 'number' ? tokenExpiresAt : 0
|
|
113
|
+
if (savedExpiresAt <= currentExpiresAt) return null
|
|
114
|
+
|
|
115
|
+
return { accessToken: savedAccess, refreshToken: savedRefresh, tokenExpiresAt: savedExpiresAt }
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@mdjhnson/homebridge-yoto",
|
|
3
3
|
"displayName": "Homebridge Yoto",
|
|
4
4
|
"description": "Control your Yoto players through Apple HomeKit with real-time MQTT updates",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.5",
|
|
6
6
|
"author": "mdjhnson (https://github.com/mdjhnson)",
|
|
7
7
|
"contributors": [
|
|
8
8
|
"Bret Comnes <bcomnes@gmail.com> (https://bret.io)"
|