@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.
@@ -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
+ }