@mdjhnson/homebridge-yoto 0.1.0
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/LICENSE +22 -0
- package/README.md +131 -0
- package/config.schema.cjs +11 -0
- package/config.schema.json +231 -0
- package/homebridge-ui/public/client.js +339 -0
- package/homebridge-ui/public/index.html +125 -0
- package/homebridge-ui/server.js +247 -0
- package/index.js +16 -0
- package/lib/accessory.js +2350 -0
- package/lib/card-control-accessory.js +212 -0
- package/lib/card-controls.js +75 -0
- package/lib/platform.js +1019 -0
- package/lib/service-config.js +47 -0
- package/lib/settings.js +56 -0
- package/lib/shortcuts.js +131 -0
- package/lib/speaker-accessory.js +551 -0
- package/lib/sync-service-names.js +39 -0
- package/lib/television-accessory.js +987 -0
- package/lib/utils/error-format.js +29 -0
- package/lib/utils/get-boolean-setting.js +8 -0
- package/lib/utils/get-trimmed-string.js +7 -0
- package/lib/utils/listener-group.js +45 -0
- package/lib/utils/oauth.js +70 -0
- package/lib/utils/sanitize-name.js +49 -0
- package/lib/utils/set-device-volume.js +22 -0
- package/lib/utils/status-scope-fallback.js +48 -0
- package/lib/utils/token-config.js +59 -0
- package/lib/utils/volume.js +78 -0
- package/package.json +76 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Yoto card control accessory implementation (play card on all devices).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @import { PlatformAccessory, CharacteristicValue, Service, Logger } from 'homebridge' */
|
|
6
|
+
/** @import { YotoPlatform } from './platform.js' */
|
|
7
|
+
/** @import { CardControlConfig } from './card-controls.js' */
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_MANUFACTURER,
|
|
11
|
+
DEFAULT_MODEL,
|
|
12
|
+
LOG_PREFIX,
|
|
13
|
+
} from './settings.js'
|
|
14
|
+
import { sanitizeName } from './utils/sanitize-name.js'
|
|
15
|
+
import { syncServiceNames } from './sync-service-names.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Yoto Card Control Accessory Handler (bridged)
|
|
19
|
+
* Triggers card playback on all devices when toggled.
|
|
20
|
+
*/
|
|
21
|
+
export class YotoCardControlAccessory {
|
|
22
|
+
/** @type {YotoPlatform} */ #platform
|
|
23
|
+
/** @type {PlatformAccessory} */ #accessory
|
|
24
|
+
/** @type {Logger} */ #log
|
|
25
|
+
/** @type {CardControlConfig} */ #cardControl
|
|
26
|
+
/** @type {Service | undefined} */ switchService
|
|
27
|
+
/** @type {Set<Service>} */ #currentServices = new Set()
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Object} params
|
|
31
|
+
* @param {YotoPlatform} params.platform - Platform instance
|
|
32
|
+
* @param {PlatformAccessory} params.accessory - Platform accessory
|
|
33
|
+
* @param {CardControlConfig} params.cardControl - Card control configuration
|
|
34
|
+
*/
|
|
35
|
+
constructor ({ platform, accessory, cardControl }) {
|
|
36
|
+
this.#platform = platform
|
|
37
|
+
this.#accessory = accessory
|
|
38
|
+
this.#cardControl = cardControl
|
|
39
|
+
this.#log = platform.log
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Setup accessory - create services and setup handlers
|
|
44
|
+
* @returns {Promise<void>}
|
|
45
|
+
*/
|
|
46
|
+
async setup () {
|
|
47
|
+
const label = this.#cardControl.label
|
|
48
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting up card control accessory: ${label}`)
|
|
49
|
+
|
|
50
|
+
this.#currentServices.clear()
|
|
51
|
+
|
|
52
|
+
this.setupAccessoryInformation()
|
|
53
|
+
this.setupSwitchService()
|
|
54
|
+
|
|
55
|
+
// Iterate a copy: removeService splices the live array
|
|
56
|
+
for (const service of [...this.#accessory.services]) {
|
|
57
|
+
if (service.UUID !== this.#platform.Service.AccessoryInformation.UUID &&
|
|
58
|
+
!this.#currentServices.has(service)) {
|
|
59
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `Removing stale card control service: ${service.displayName || service.UUID}`)
|
|
60
|
+
this.#accessory.removeService(service)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `✓ Card control accessory ready: ${label}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Setup AccessoryInformation service
|
|
69
|
+
*/
|
|
70
|
+
setupAccessoryInformation () {
|
|
71
|
+
const { Service, Characteristic } = this.#platform
|
|
72
|
+
const service = this.#accessory.getService(Service.AccessoryInformation) ||
|
|
73
|
+
this.#accessory.addService(Service.AccessoryInformation)
|
|
74
|
+
|
|
75
|
+
const displayName = sanitizeName(this.#accessory.displayName)
|
|
76
|
+
const nameCharacteristic = service.getCharacteristic(Characteristic.Name)
|
|
77
|
+
const configuredCharacteristic = service.getCharacteristic(Characteristic.ConfiguredName)
|
|
78
|
+
const previousName = nameCharacteristic.value
|
|
79
|
+
const configuredName = configuredCharacteristic.value
|
|
80
|
+
|
|
81
|
+
service
|
|
82
|
+
.setCharacteristic(Characteristic.Manufacturer, DEFAULT_MANUFACTURER)
|
|
83
|
+
.setCharacteristic(Characteristic.Model, DEFAULT_MODEL)
|
|
84
|
+
.setCharacteristic(Characteristic.SerialNumber, this.#cardControl.id)
|
|
85
|
+
.setCharacteristic(Characteristic.Name, displayName)
|
|
86
|
+
|
|
87
|
+
if (typeof configuredName !== 'string' || configuredName === previousName) {
|
|
88
|
+
service.setCharacteristic(Characteristic.ConfiguredName, displayName)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.#currentServices.add(service)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Setup card control Switch service
|
|
96
|
+
*/
|
|
97
|
+
setupSwitchService () {
|
|
98
|
+
const { Service, Characteristic } = this.#platform
|
|
99
|
+
const serviceName = sanitizeName(this.#accessory.displayName)
|
|
100
|
+
|
|
101
|
+
const service = this.#accessory.getServiceById(Service.Switch, 'CardControl') ||
|
|
102
|
+
this.#accessory.addService(Service.Switch, serviceName, 'CardControl')
|
|
103
|
+
|
|
104
|
+
syncServiceNames({ Characteristic, service, name: serviceName })
|
|
105
|
+
|
|
106
|
+
service
|
|
107
|
+
.getCharacteristic(Characteristic.On)
|
|
108
|
+
.onGet(() => false)
|
|
109
|
+
.onSet(this.setCardControl.bind(this))
|
|
110
|
+
|
|
111
|
+
service.updateCharacteristic(Characteristic.On, false)
|
|
112
|
+
|
|
113
|
+
this.switchService = service
|
|
114
|
+
this.#currentServices.add(service)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Trigger playback for all devices.
|
|
119
|
+
* @param {CharacteristicValue} value
|
|
120
|
+
* @returns {Promise<void>}
|
|
121
|
+
*/
|
|
122
|
+
async setCardControl (value) {
|
|
123
|
+
const { Characteristic } = this.#platform
|
|
124
|
+
const isOn = Boolean(value)
|
|
125
|
+
this.#log.debug(
|
|
126
|
+
LOG_PREFIX.ACCESSORY,
|
|
127
|
+
`Card control toggle requested: ${this.#cardControl.label} (${this.#cardControl.cardId}) -> ${isOn}`
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if (!isOn) {
|
|
131
|
+
this.switchService?.getCharacteristic(Characteristic.On).updateValue(false)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const account = this.#platform.yotoAccount
|
|
136
|
+
if (!account) {
|
|
137
|
+
this.#log.warn(LOG_PREFIX.ACCESSORY, 'Card control requested before account is ready.')
|
|
138
|
+
this.switchService?.getCharacteristic(Characteristic.On).updateValue(false)
|
|
139
|
+
throw new this.#platform.api.hap.HapStatusError(
|
|
140
|
+
this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const devices = Array.from(account.devices.values())
|
|
145
|
+
if (devices.length === 0) {
|
|
146
|
+
this.#log.warn(LOG_PREFIX.ACCESSORY, 'Card control requested with no devices available.')
|
|
147
|
+
this.switchService?.getCharacteristic(Characteristic.On).updateValue(false)
|
|
148
|
+
throw new this.#platform.api.hap.HapStatusError(
|
|
149
|
+
this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const onlineDevices = devices.filter((deviceModel) => deviceModel.status.isOnline)
|
|
154
|
+
const offlineDevices = devices.filter((deviceModel) => !deviceModel.status.isOnline)
|
|
155
|
+
if (offlineDevices.length > 0) {
|
|
156
|
+
const offlineNames = offlineDevices.map(deviceModel => deviceModel.device.name).join(', ')
|
|
157
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `Skipping offline devices for card control: ${offlineNames}`)
|
|
158
|
+
}
|
|
159
|
+
if (onlineDevices.length === 0) {
|
|
160
|
+
this.#log.warn(LOG_PREFIX.ACCESSORY, 'Card control requested but no devices are online.')
|
|
161
|
+
this.switchService?.getCharacteristic(Characteristic.On).updateValue(false)
|
|
162
|
+
throw new this.#platform.api.hap.HapStatusError(
|
|
163
|
+
this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
this.#log.debug(
|
|
168
|
+
LOG_PREFIX.ACCESSORY,
|
|
169
|
+
`Play card on all devices: ${this.#cardControl.label} (${this.#cardControl.cardId})`
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
const results = await Promise.allSettled(
|
|
173
|
+
onlineDevices.map(async (deviceModel) => {
|
|
174
|
+
await deviceModel.startCard({ cardId: this.#cardControl.cardId })
|
|
175
|
+
return deviceModel.device.name
|
|
176
|
+
})
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const failedDevices = results.reduce((acc, result, index) => {
|
|
180
|
+
if (result.status === 'rejected') {
|
|
181
|
+
const failedDevice = onlineDevices[index]
|
|
182
|
+
if (failedDevice) {
|
|
183
|
+
acc.push(failedDevice.device.name)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return acc
|
|
187
|
+
}, /** @type {string[]} */ ([]))
|
|
188
|
+
|
|
189
|
+
if (failedDevices.length > 0) {
|
|
190
|
+
this.#log.warn(
|
|
191
|
+
LOG_PREFIX.ACCESSORY,
|
|
192
|
+
`Card control failed on ${failedDevices.length} device(s): ${failedDevices.join(', ')}`
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.switchService?.getCharacteristic(Characteristic.On).updateValue(false)
|
|
197
|
+
|
|
198
|
+
if (failedDevices.length === results.length) {
|
|
199
|
+
throw new this.#platform.api.hap.HapStatusError(
|
|
200
|
+
this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Stop accessory - cleanup handlers (no listeners to remove)
|
|
207
|
+
* @returns {Promise<void>}
|
|
208
|
+
*/
|
|
209
|
+
async stop () {
|
|
210
|
+
this.#currentServices.clear()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** @import { PlatformConfig } from 'homebridge' */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} CardControlConfig
|
|
5
|
+
* @property {string} id
|
|
6
|
+
* @property {string} cardId
|
|
7
|
+
* @property {string} label
|
|
8
|
+
* @property {boolean} playOnAll
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Content a switch or input can start playing.
|
|
13
|
+
* @typedef {Object} PlayableCard
|
|
14
|
+
* @property {string} label - Name used in logs
|
|
15
|
+
* @property {string} cardId
|
|
16
|
+
* @property {string} [chapterKey]
|
|
17
|
+
* @property {string} [trackKey]
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { getTrimmedString } from './utils/get-trimmed-string.js'
|
|
21
|
+
import { getBooleanSetting } from './utils/get-boolean-setting.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {PlatformConfig} config
|
|
25
|
+
* @returns {CardControlConfig[]}
|
|
26
|
+
*/
|
|
27
|
+
export function getCardControlConfigs (config) {
|
|
28
|
+
const services = config && typeof config === 'object' ? config['services'] : undefined
|
|
29
|
+
const serviceConfig = typeof services === 'object' && services !== null
|
|
30
|
+
? /** @type {Record<string, unknown>} */ (services)
|
|
31
|
+
: {}
|
|
32
|
+
|
|
33
|
+
const rawControls = Array.isArray(serviceConfig['cardControls'])
|
|
34
|
+
? serviceConfig['cardControls']
|
|
35
|
+
: []
|
|
36
|
+
|
|
37
|
+
/** @type {CardControlConfig[]} */
|
|
38
|
+
const controls = []
|
|
39
|
+
const usedIds = new Set()
|
|
40
|
+
|
|
41
|
+
for (const entry of rawControls) {
|
|
42
|
+
if (!entry || typeof entry !== 'object') {
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const record = /** @type {Record<string, unknown>} */ (entry)
|
|
47
|
+
const cardId = getTrimmedString(record['cardId'])
|
|
48
|
+
const label = getTrimmedString(record['label'])
|
|
49
|
+
|
|
50
|
+
if (!cardId || !label) {
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const playOnAll = getBooleanSetting(record['playOnAll'], false)
|
|
55
|
+
|
|
56
|
+
let id = cardId
|
|
57
|
+
if (usedIds.has(id)) {
|
|
58
|
+
let suffix = 1
|
|
59
|
+
while (usedIds.has(`${cardId}-${suffix}`)) {
|
|
60
|
+
suffix += 1
|
|
61
|
+
}
|
|
62
|
+
id = `${cardId}-${suffix}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
usedIds.add(id)
|
|
66
|
+
controls.push({
|
|
67
|
+
id,
|
|
68
|
+
cardId,
|
|
69
|
+
label,
|
|
70
|
+
playOnAll,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return controls
|
|
75
|
+
}
|