homebridge-yoto 0.0.38 → 0.0.40

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,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
+ }