homebridge-yoto 0.0.39 → 0.0.41
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 +50 -0
- package/config.schema.json +67 -14
- package/lib/accessory.js +250 -82
- package/lib/card-control-accessory.js +200 -0
- package/lib/card-controls.js +80 -0
- package/lib/platform.js +330 -32
- package/lib/service-config.js +63 -0
- package/lib/speaker-accessory.js +491 -0
- package/package.json +2 -2
|
@@ -0,0 +1,200 @@
|
|
|
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 './constants.js'
|
|
14
|
+
import { sanitizeName } from './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
|
+
for (const service of this.#accessory.services) {
|
|
56
|
+
if (service.UUID !== this.#platform.Service.AccessoryInformation.UUID &&
|
|
57
|
+
!this.#currentServices.has(service)) {
|
|
58
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `Removing stale card control service: ${service.displayName || service.UUID}`)
|
|
59
|
+
this.#accessory.removeService(service)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `✓ Card control accessory ready: ${label}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Setup AccessoryInformation service
|
|
68
|
+
*/
|
|
69
|
+
setupAccessoryInformation () {
|
|
70
|
+
const { Service, Characteristic } = this.#platform
|
|
71
|
+
const service = this.#accessory.getService(Service.AccessoryInformation) ||
|
|
72
|
+
this.#accessory.addService(Service.AccessoryInformation)
|
|
73
|
+
|
|
74
|
+
const displayName = sanitizeName(this.#accessory.displayName)
|
|
75
|
+
|
|
76
|
+
service
|
|
77
|
+
.setCharacteristic(Characteristic.Manufacturer, DEFAULT_MANUFACTURER)
|
|
78
|
+
.setCharacteristic(Characteristic.Model, DEFAULT_MODEL)
|
|
79
|
+
.setCharacteristic(Characteristic.SerialNumber, this.#cardControl.id)
|
|
80
|
+
.setCharacteristic(Characteristic.Name, displayName)
|
|
81
|
+
.setCharacteristic(Characteristic.ConfiguredName, displayName)
|
|
82
|
+
|
|
83
|
+
this.#currentServices.add(service)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Setup card control Switch service
|
|
88
|
+
*/
|
|
89
|
+
setupSwitchService () {
|
|
90
|
+
const { Service, Characteristic } = this.#platform
|
|
91
|
+
const serviceName = sanitizeName(this.#accessory.displayName)
|
|
92
|
+
|
|
93
|
+
const service = this.#accessory.getServiceById(Service.Switch, 'CardControl') ||
|
|
94
|
+
this.#accessory.addService(Service.Switch, serviceName, 'CardControl')
|
|
95
|
+
|
|
96
|
+
syncServiceNames({ Characteristic, service, name: serviceName })
|
|
97
|
+
|
|
98
|
+
service
|
|
99
|
+
.getCharacteristic(Characteristic.On)
|
|
100
|
+
.onGet(() => false)
|
|
101
|
+
.onSet(this.setCardControl.bind(this))
|
|
102
|
+
|
|
103
|
+
service.updateCharacteristic(Characteristic.On, false)
|
|
104
|
+
|
|
105
|
+
this.switchService = service
|
|
106
|
+
this.#currentServices.add(service)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Trigger playback for all devices.
|
|
111
|
+
* @param {CharacteristicValue} value
|
|
112
|
+
* @returns {Promise<void>}
|
|
113
|
+
*/
|
|
114
|
+
async setCardControl (value) {
|
|
115
|
+
const { Characteristic } = this.#platform
|
|
116
|
+
const isOn = Boolean(value)
|
|
117
|
+
|
|
118
|
+
if (!isOn) {
|
|
119
|
+
this.switchService?.getCharacteristic(Characteristic.On).updateValue(false)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const account = this.#platform.yotoAccount
|
|
124
|
+
if (!account) {
|
|
125
|
+
this.#log.warn(LOG_PREFIX.ACCESSORY, 'Card control requested before account is ready.')
|
|
126
|
+
this.switchService?.getCharacteristic(Characteristic.On).updateValue(false)
|
|
127
|
+
throw new this.#platform.api.hap.HapStatusError(
|
|
128
|
+
this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const devices = Array.from(account.devices.values())
|
|
133
|
+
if (devices.length === 0) {
|
|
134
|
+
this.#log.warn(LOG_PREFIX.ACCESSORY, 'Card control requested with no devices available.')
|
|
135
|
+
this.switchService?.getCharacteristic(Characteristic.On).updateValue(false)
|
|
136
|
+
throw new this.#platform.api.hap.HapStatusError(
|
|
137
|
+
this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const onlineDevices = devices.filter((deviceModel) => deviceModel.status.isOnline)
|
|
142
|
+
const offlineDevices = devices.filter((deviceModel) => !deviceModel.status.isOnline)
|
|
143
|
+
if (offlineDevices.length > 0) {
|
|
144
|
+
const offlineNames = offlineDevices.map(deviceModel => deviceModel.device.name).join(', ')
|
|
145
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `Skipping offline devices for card control: ${offlineNames}`)
|
|
146
|
+
}
|
|
147
|
+
if (onlineDevices.length === 0) {
|
|
148
|
+
this.#log.warn(LOG_PREFIX.ACCESSORY, 'Card control requested but no devices are online.')
|
|
149
|
+
this.switchService?.getCharacteristic(Characteristic.On).updateValue(false)
|
|
150
|
+
throw new this.#platform.api.hap.HapStatusError(
|
|
151
|
+
this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
this.#log.debug(
|
|
156
|
+
LOG_PREFIX.ACCESSORY,
|
|
157
|
+
`Play card on all devices: ${this.#cardControl.label} (${this.#cardControl.cardId})`
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const results = await Promise.allSettled(
|
|
161
|
+
onlineDevices.map(async (deviceModel) => {
|
|
162
|
+
await deviceModel.startCard({ cardId: this.#cardControl.cardId })
|
|
163
|
+
return deviceModel.device.name
|
|
164
|
+
})
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const failedDevices = results.reduce((acc, result, index) => {
|
|
168
|
+
if (result.status === 'rejected') {
|
|
169
|
+
const failedDevice = onlineDevices[index]
|
|
170
|
+
if (failedDevice) {
|
|
171
|
+
acc.push(failedDevice.device.name)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return acc
|
|
175
|
+
}, /** @type {string[]} */ ([]))
|
|
176
|
+
|
|
177
|
+
if (failedDevices.length > 0) {
|
|
178
|
+
this.#log.warn(
|
|
179
|
+
LOG_PREFIX.ACCESSORY,
|
|
180
|
+
`Card control failed on ${failedDevices.length} device(s): ${failedDevices.join(', ')}`
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this.switchService?.getCharacteristic(Characteristic.On).updateValue(false)
|
|
185
|
+
|
|
186
|
+
if (failedDevices.length === results.length) {
|
|
187
|
+
throw new this.#platform.api.hap.HapStatusError(
|
|
188
|
+
this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Stop accessory - cleanup handlers (no listeners to remove)
|
|
195
|
+
* @returns {Promise<void>}
|
|
196
|
+
*/
|
|
197
|
+
async stop () {
|
|
198
|
+
this.#currentServices.clear()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
* @param {unknown} value
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
function getTrimmedString (value) {
|
|
16
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {unknown} value
|
|
21
|
+
* @param {boolean} fallback
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
function getBooleanSetting (value, fallback) {
|
|
25
|
+
return typeof value === 'boolean' ? value : fallback
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {PlatformConfig} config
|
|
30
|
+
* @returns {CardControlConfig[]}
|
|
31
|
+
*/
|
|
32
|
+
export function getCardControlConfigs (config) {
|
|
33
|
+
const services = config && typeof config === 'object' ? config['services'] : undefined
|
|
34
|
+
const serviceConfig = typeof services === 'object' && services !== null
|
|
35
|
+
? /** @type {Record<string, unknown>} */ (services)
|
|
36
|
+
: {}
|
|
37
|
+
|
|
38
|
+
const rawControls = Array.isArray(serviceConfig['cardControls'])
|
|
39
|
+
? serviceConfig['cardControls']
|
|
40
|
+
: []
|
|
41
|
+
|
|
42
|
+
/** @type {CardControlConfig[]} */
|
|
43
|
+
const controls = []
|
|
44
|
+
const usedIds = new Set()
|
|
45
|
+
|
|
46
|
+
for (const entry of rawControls) {
|
|
47
|
+
if (!entry || typeof entry !== 'object') {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const record = /** @type {Record<string, unknown>} */ (entry)
|
|
52
|
+
const cardId = getTrimmedString(record['cardId'])
|
|
53
|
+
const label = getTrimmedString(record['label'])
|
|
54
|
+
|
|
55
|
+
if (!cardId || !label) {
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const playOnAll = getBooleanSetting(record['playOnAll'], false)
|
|
60
|
+
|
|
61
|
+
let id = cardId
|
|
62
|
+
if (usedIds.has(id)) {
|
|
63
|
+
let suffix = 1
|
|
64
|
+
while (usedIds.has(`${cardId}-${suffix}`)) {
|
|
65
|
+
suffix += 1
|
|
66
|
+
}
|
|
67
|
+
id = `${cardId}-${suffix}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
usedIds.add(id)
|
|
71
|
+
controls.push({
|
|
72
|
+
id,
|
|
73
|
+
cardId,
|
|
74
|
+
label,
|
|
75
|
+
playOnAll,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return controls
|
|
80
|
+
}
|