@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,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Yoto SmartSpeaker accessory implementation for external accessories.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @import { PlatformAccessory, CharacteristicValue, Service, Logger } from 'homebridge' */
|
|
6
|
+
/** @import { YotoPlatform } from './platform.js' */
|
|
7
|
+
/** @import { YotoDeviceModel } from 'yoto-nodejs-client' */
|
|
8
|
+
/** @import { YotoDeviceModelEventMap } from 'yoto-nodejs-client/lib/yoto-device.js' */
|
|
9
|
+
/** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
|
|
10
|
+
/** @import { YotoAccessoryContext } from './platform.js' */
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_MANUFACTURER,
|
|
14
|
+
DEFAULT_MODEL,
|
|
15
|
+
LOG_PREFIX,
|
|
16
|
+
} from './settings.js'
|
|
17
|
+
import { sanitizeName } from './utils/sanitize-name.js'
|
|
18
|
+
import { syncServiceNames } from './sync-service-names.js'
|
|
19
|
+
import { formatError } from './utils/error-format.js'
|
|
20
|
+
import { ListenerGroup } from './utils/listener-group.js'
|
|
21
|
+
import { setDeviceVolume } from './utils/set-device-volume.js'
|
|
22
|
+
import {
|
|
23
|
+
clampPercent,
|
|
24
|
+
clampSteps,
|
|
25
|
+
percentToSteps,
|
|
26
|
+
stepsToPercent,
|
|
27
|
+
} from './utils/volume.js'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Yoto SmartSpeaker Accessory Handler (external)
|
|
31
|
+
* Manages SmartSpeaker service and characteristics for a single Yoto player.
|
|
32
|
+
*/
|
|
33
|
+
export class YotoSpeakerAccessory {
|
|
34
|
+
/** @type {YotoPlatform} */ #platform
|
|
35
|
+
/** @type {PlatformAccessory<YotoAccessoryContext>} */ #accessory
|
|
36
|
+
/** @type {YotoDeviceModel} */ #deviceModel
|
|
37
|
+
/** @type {Logger} */ #log
|
|
38
|
+
/** @type {YotoDevice} */ #device
|
|
39
|
+
/** @type {Service | undefined} */ speakerService
|
|
40
|
+
/** @type {number} */ #lastNonZeroVolume = 50
|
|
41
|
+
/** @type {Set<Service>} */ #currentServices = new Set()
|
|
42
|
+
/** @type {ListenerGroup<YotoDeviceModelEventMap>} */ #listeners
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {Object} params
|
|
46
|
+
* @param {YotoPlatform} params.platform - Platform instance
|
|
47
|
+
* @param {PlatformAccessory<YotoAccessoryContext>} params.accessory - Platform accessory
|
|
48
|
+
* @param {YotoDeviceModel} params.deviceModel - Yoto device model with live state
|
|
49
|
+
*/
|
|
50
|
+
constructor ({ platform, accessory, deviceModel }) {
|
|
51
|
+
this.#platform = platform
|
|
52
|
+
this.#accessory = accessory
|
|
53
|
+
this.#deviceModel = deviceModel
|
|
54
|
+
this.#listeners = new ListenerGroup(deviceModel)
|
|
55
|
+
this.#log = platform.log
|
|
56
|
+
|
|
57
|
+
this.#device = accessory.context.device
|
|
58
|
+
this.#currentServices = new Set()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Setup accessory - create services and setup event listeners
|
|
63
|
+
* @returns {Promise<void>}
|
|
64
|
+
*/
|
|
65
|
+
async setup () {
|
|
66
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `Setting up speaker for ${this.#device.name}`)
|
|
67
|
+
|
|
68
|
+
this.#currentServices.clear()
|
|
69
|
+
|
|
70
|
+
this.setupAccessoryInformation()
|
|
71
|
+
this.setupSmartSpeakerService()
|
|
72
|
+
|
|
73
|
+
// Iterate a copy: removeService splices the live array
|
|
74
|
+
for (const service of [...this.#accessory.services]) {
|
|
75
|
+
if (service.UUID !== this.#platform.Service.AccessoryInformation.UUID &&
|
|
76
|
+
!this.#currentServices.has(service)) {
|
|
77
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `Removing stale speaker service: ${service.displayName || service.UUID}`)
|
|
78
|
+
this.#accessory.removeService(service)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.setupEventListeners()
|
|
83
|
+
|
|
84
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `✓ ${this.#device.name} speaker ready`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Setup AccessoryInformation service
|
|
89
|
+
*/
|
|
90
|
+
setupAccessoryInformation () {
|
|
91
|
+
const { Service, Characteristic } = this.#platform
|
|
92
|
+
const service = this.#accessory.getService(Service.AccessoryInformation) ||
|
|
93
|
+
this.#accessory.addService(Service.AccessoryInformation)
|
|
94
|
+
const displayName = sanitizeName(this.#accessory.displayName)
|
|
95
|
+
const nameCharacteristic = service.getCharacteristic(Characteristic.Name)
|
|
96
|
+
const configuredCharacteristic = service.getCharacteristic(Characteristic.ConfiguredName)
|
|
97
|
+
const previousName = nameCharacteristic.value
|
|
98
|
+
const configuredName = configuredCharacteristic.value
|
|
99
|
+
|
|
100
|
+
const hardwareRevision = [
|
|
101
|
+
this.#device.generation,
|
|
102
|
+
this.#device.formFactor,
|
|
103
|
+
].filter(Boolean).join(' ') || 'Unknown'
|
|
104
|
+
|
|
105
|
+
const model = this.#device.deviceFamily || this.#device.deviceType || DEFAULT_MODEL
|
|
106
|
+
|
|
107
|
+
service
|
|
108
|
+
.setCharacteristic(Characteristic.Name, displayName)
|
|
109
|
+
.setCharacteristic(Characteristic.Manufacturer, DEFAULT_MANUFACTURER)
|
|
110
|
+
.setCharacteristic(Characteristic.Model, model)
|
|
111
|
+
.setCharacteristic(Characteristic.SerialNumber, this.#device.deviceId)
|
|
112
|
+
.setCharacteristic(Characteristic.HardwareRevision, hardwareRevision)
|
|
113
|
+
|
|
114
|
+
if (typeof configuredName !== 'string' || configuredName === previousName) {
|
|
115
|
+
service.setCharacteristic(Characteristic.ConfiguredName, displayName)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (this.#deviceModel.status.firmwareVersion) {
|
|
119
|
+
service.setCharacteristic(
|
|
120
|
+
Characteristic.FirmwareRevision,
|
|
121
|
+
this.#deviceModel.status.firmwareVersion
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.#currentServices.add(service)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Setup SmartSpeaker service (PRIMARY)
|
|
130
|
+
*/
|
|
131
|
+
setupSmartSpeakerService () {
|
|
132
|
+
const { Service, Characteristic } = this.#platform
|
|
133
|
+
const serviceName = sanitizeName(this.#accessory.displayName)
|
|
134
|
+
|
|
135
|
+
const service = this.#accessory.getService(Service.SmartSpeaker) ||
|
|
136
|
+
this.#accessory.addService(Service.SmartSpeaker, serviceName)
|
|
137
|
+
|
|
138
|
+
service.setPrimaryService(true)
|
|
139
|
+
|
|
140
|
+
syncServiceNames({ Characteristic, service, name: serviceName })
|
|
141
|
+
|
|
142
|
+
service
|
|
143
|
+
.getCharacteristic(Characteristic.CurrentMediaState)
|
|
144
|
+
.onGet(this.getCurrentMediaState.bind(this))
|
|
145
|
+
|
|
146
|
+
service
|
|
147
|
+
.getCharacteristic(Characteristic.TargetMediaState)
|
|
148
|
+
.onGet(this.getTargetMediaState.bind(this))
|
|
149
|
+
.onSet(this.setTargetMediaState.bind(this))
|
|
150
|
+
|
|
151
|
+
// Advertise AirPlay support so Home keeps transport controls enabled.
|
|
152
|
+
// AirPlayEnable is a writable uint8 (0/1); ignore writes and always report enabled.
|
|
153
|
+
service
|
|
154
|
+
.getCharacteristic(Characteristic.AirPlayEnable)
|
|
155
|
+
.onGet(() => 1)
|
|
156
|
+
.onSet(() => {
|
|
157
|
+
service.getCharacteristic(Characteristic.AirPlayEnable).updateValue(1)
|
|
158
|
+
})
|
|
159
|
+
.updateValue(1)
|
|
160
|
+
|
|
161
|
+
service
|
|
162
|
+
.getCharacteristic(Characteristic.Volume)
|
|
163
|
+
.setProps({
|
|
164
|
+
minValue: 0,
|
|
165
|
+
maxValue: 100,
|
|
166
|
+
minStep: 1,
|
|
167
|
+
})
|
|
168
|
+
.onGet(this.getVolume.bind(this))
|
|
169
|
+
.onSet(this.setVolume.bind(this))
|
|
170
|
+
|
|
171
|
+
service
|
|
172
|
+
.getCharacteristic(Characteristic.Mute)
|
|
173
|
+
.onGet(this.getMute.bind(this))
|
|
174
|
+
.onSet(this.setMute.bind(this))
|
|
175
|
+
|
|
176
|
+
// StatusActive isn't in the SmartSpeaker spec; declare it optional to avoid HAP warnings
|
|
177
|
+
const statusActiveUuid = Characteristic.StatusActive.UUID
|
|
178
|
+
const hasStatusActive = service.characteristics.some(c => c.UUID === statusActiveUuid)
|
|
179
|
+
const hasStatusActiveOptional = service.optionalCharacteristics.some(c => c.UUID === statusActiveUuid)
|
|
180
|
+
if (!hasStatusActive && !hasStatusActiveOptional) {
|
|
181
|
+
service.addOptionalCharacteristic(Characteristic.StatusActive)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
service
|
|
185
|
+
.getCharacteristic(Characteristic.StatusActive)
|
|
186
|
+
.onGet(this.getStatusActive.bind(this))
|
|
187
|
+
|
|
188
|
+
this.speakerService = service
|
|
189
|
+
this.#currentServices.add(service)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Setup event listeners for device model updates
|
|
194
|
+
*/
|
|
195
|
+
setupEventListeners () {
|
|
196
|
+
this.#listeners.on('statusUpdate', (status, _source, changedFields) => {
|
|
197
|
+
for (const field of changedFields) {
|
|
198
|
+
switch (field) {
|
|
199
|
+
case 'volume':
|
|
200
|
+
this.updateVolumeCharacteristic(status.volume)
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
case 'isOnline':
|
|
204
|
+
this.updateOnlineStatusCharacteristic(status.isOnline)
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
case 'firmwareVersion':
|
|
208
|
+
this.updateFirmwareVersionCharacteristic(status.firmwareVersion)
|
|
209
|
+
break
|
|
210
|
+
|
|
211
|
+
// Available but not mapped to SmartSpeaker characteristics
|
|
212
|
+
case 'batteryLevelPercentage':
|
|
213
|
+
case 'isCharging':
|
|
214
|
+
case 'maxVolume':
|
|
215
|
+
case 'temperatureCelsius':
|
|
216
|
+
case 'nightlightMode':
|
|
217
|
+
case 'dayMode':
|
|
218
|
+
case 'cardInsertionState':
|
|
219
|
+
case 'activeCardId':
|
|
220
|
+
case 'powerSource':
|
|
221
|
+
case 'wifiStrength':
|
|
222
|
+
case 'freeDiskSpaceBytes':
|
|
223
|
+
case 'totalDiskSpaceBytes':
|
|
224
|
+
case 'isAudioDeviceConnected':
|
|
225
|
+
case 'isBluetoothAudioConnected':
|
|
226
|
+
case 'ambientLightSensorReading':
|
|
227
|
+
case 'displayBrightness':
|
|
228
|
+
case 'timeFormat':
|
|
229
|
+
case 'uptime':
|
|
230
|
+
case 'updatedAt':
|
|
231
|
+
case 'source':
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
default: {
|
|
235
|
+
/** @type {never} */
|
|
236
|
+
const _exhaustive = field
|
|
237
|
+
this.#log.debug('Unhandled speaker status field:', _exhaustive)
|
|
238
|
+
break
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
this.#listeners.on('playbackUpdate', (playback, changedFields) => {
|
|
245
|
+
for (const field of changedFields) {
|
|
246
|
+
switch (field) {
|
|
247
|
+
case 'playbackStatus':
|
|
248
|
+
this.updateSmartSpeakerMediaStateCharacteristic(playback.playbackStatus)
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
case 'sleepTimerActive':
|
|
252
|
+
case 'position':
|
|
253
|
+
case 'trackLength':
|
|
254
|
+
case 'cardId':
|
|
255
|
+
case 'cardTitle':
|
|
256
|
+
case 'cardSlug':
|
|
257
|
+
case 'cardCoverImageUrl':
|
|
258
|
+
case 'cardAuthor':
|
|
259
|
+
case 'cardReadBy':
|
|
260
|
+
case 'cardDurationSeconds':
|
|
261
|
+
case 'source':
|
|
262
|
+
case 'trackTitle':
|
|
263
|
+
case 'trackKey':
|
|
264
|
+
case 'chapterTitle':
|
|
265
|
+
case 'chapterKey':
|
|
266
|
+
case 'sleepTimerSeconds':
|
|
267
|
+
case 'streaming':
|
|
268
|
+
case 'updatedAt':
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
default: {
|
|
272
|
+
/** @type {never} */
|
|
273
|
+
const _exhaustive = field
|
|
274
|
+
this.#log.debug('Unhandled speaker playback field:', _exhaustive)
|
|
275
|
+
break
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
this.#listeners.on('online', () => {
|
|
282
|
+
this.updateOnlineStatusCharacteristic(true)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
this.#listeners.on('offline', () => {
|
|
286
|
+
this.updateOnlineStatusCharacteristic(false)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
this.#listeners.on('error', (error) => {
|
|
290
|
+
const details = formatError(error)
|
|
291
|
+
this.#log.error(`[${this.#device.name}] Speaker device error:`, details)
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus
|
|
297
|
+
* @returns {{ current: CharacteristicValue, target: CharacteristicValue }}
|
|
298
|
+
*/
|
|
299
|
+
getMediaStateValues (playbackStatus) {
|
|
300
|
+
const { Characteristic } = this.#platform
|
|
301
|
+
|
|
302
|
+
if (playbackStatus === 'playing') {
|
|
303
|
+
return {
|
|
304
|
+
current: Characteristic.CurrentMediaState.PLAY,
|
|
305
|
+
target: Characteristic.TargetMediaState.PLAY,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (playbackStatus === 'paused') {
|
|
310
|
+
return {
|
|
311
|
+
current: Characteristic.CurrentMediaState.PAUSE,
|
|
312
|
+
target: Characteristic.TargetMediaState.PAUSE,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (playbackStatus === 'loading') {
|
|
317
|
+
return {
|
|
318
|
+
current: Characteristic.CurrentMediaState.LOADING,
|
|
319
|
+
target: Characteristic.TargetMediaState.PLAY,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
current: Characteristic.CurrentMediaState.STOP,
|
|
325
|
+
target: Characteristic.TargetMediaState.STOP,
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Get current media state from live playback state
|
|
331
|
+
* @returns {Promise<CharacteristicValue>}
|
|
332
|
+
*/
|
|
333
|
+
async getCurrentMediaState () {
|
|
334
|
+
const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
|
|
335
|
+
const current = this.getMediaStateValues(playbackStatus).current
|
|
336
|
+
this.#log.debug(
|
|
337
|
+
LOG_PREFIX.ACCESSORY,
|
|
338
|
+
`[${this.#device.name}] Get current media state -> ${current} (${playbackStatus ?? 'unknown'})`
|
|
339
|
+
)
|
|
340
|
+
return current
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get target media state (follows current state)
|
|
345
|
+
* @returns {Promise<CharacteristicValue>}
|
|
346
|
+
*/
|
|
347
|
+
async getTargetMediaState () {
|
|
348
|
+
const playbackStatus = this.#deviceModel.playback.playbackStatus ?? null
|
|
349
|
+
const target = this.getMediaStateValues(playbackStatus).target
|
|
350
|
+
this.#log.debug(
|
|
351
|
+
LOG_PREFIX.ACCESSORY,
|
|
352
|
+
`[${this.#device.name}] Get target media state -> ${target} (${playbackStatus ?? 'unknown'})`
|
|
353
|
+
)
|
|
354
|
+
return target
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Set target media state (play/pause/stop)
|
|
359
|
+
* @param {CharacteristicValue} value - Target state
|
|
360
|
+
* @returns {Promise<void>}
|
|
361
|
+
*/
|
|
362
|
+
async setTargetMediaState (value) {
|
|
363
|
+
const { Characteristic } = this.#platform
|
|
364
|
+
const targetValue = typeof value === 'number' ? value : Number(value)
|
|
365
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set target media state:`, value)
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
if (targetValue === Characteristic.TargetMediaState.PLAY) {
|
|
369
|
+
await this.#deviceModel.resumeCard()
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (targetValue === Characteristic.TargetMediaState.PAUSE) {
|
|
374
|
+
await this.#deviceModel.pauseCard()
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (targetValue === Characteristic.TargetMediaState.STOP) {
|
|
379
|
+
// Pause rather than stop so playback can be resumed (matches the TV accessory)
|
|
380
|
+
await this.#deviceModel.pauseCard()
|
|
381
|
+
}
|
|
382
|
+
} catch (error) {
|
|
383
|
+
this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set media state:`, formatError(error))
|
|
384
|
+
throw new this.#platform.api.hap.HapStatusError(
|
|
385
|
+
this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Get volume level as percentage (mapped from 0-16 steps)
|
|
392
|
+
* @returns {Promise<CharacteristicValue>}
|
|
393
|
+
*/
|
|
394
|
+
async getVolume () {
|
|
395
|
+
const volumeSteps = this.#deviceModel.status.volume
|
|
396
|
+
const percent = stepsToPercent(volumeSteps)
|
|
397
|
+
this.#log.debug(
|
|
398
|
+
LOG_PREFIX.ACCESSORY,
|
|
399
|
+
`[${this.#device.name}] Get speaker volume -> ${percent} (rawSteps=${volumeSteps})`
|
|
400
|
+
)
|
|
401
|
+
return percent
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Set volume level as percentage (mapped to 0-16 steps)
|
|
406
|
+
* @param {CharacteristicValue} value - Volume level percent
|
|
407
|
+
* @returns {Promise<void>}
|
|
408
|
+
*/
|
|
409
|
+
async setVolume (value) {
|
|
410
|
+
const deviceModel = this.#deviceModel
|
|
411
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set speaker volume:`, value)
|
|
412
|
+
|
|
413
|
+
const requestedPercent = typeof value === 'number' ? value : Number(value)
|
|
414
|
+
if (!Number.isFinite(requestedPercent)) {
|
|
415
|
+
throw new this.#platform.api.hap.HapStatusError(
|
|
416
|
+
this.#platform.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const normalizedPercent = clampPercent(requestedPercent)
|
|
421
|
+
const requestedSteps = percentToSteps(normalizedPercent)
|
|
422
|
+
const steps = clampSteps(requestedSteps)
|
|
423
|
+
|
|
424
|
+
if (steps > 0) {
|
|
425
|
+
this.#lastNonZeroVolume = stepsToPercent(steps)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
await setDeviceVolume(deviceModel, steps)
|
|
430
|
+
this.updateVolumeCharacteristic(steps)
|
|
431
|
+
} catch (error) {
|
|
432
|
+
this.#log.error(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Failed to set speaker volume:`, formatError(error))
|
|
433
|
+
throw new this.#platform.api.hap.HapStatusError(
|
|
434
|
+
this.#platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Get mute state (derived from volume === 0)
|
|
441
|
+
* @returns {Promise<CharacteristicValue>}
|
|
442
|
+
*/
|
|
443
|
+
async getMute () {
|
|
444
|
+
const isMuted = this.#deviceModel.status.volume === 0
|
|
445
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get speaker mute -> ${isMuted}`)
|
|
446
|
+
return isMuted
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Set mute state
|
|
451
|
+
* @param {CharacteristicValue} value - Mute state
|
|
452
|
+
* @returns {Promise<void>}
|
|
453
|
+
*/
|
|
454
|
+
async setMute (value) {
|
|
455
|
+
const isMuted = Boolean(value)
|
|
456
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Set speaker mute:`, isMuted)
|
|
457
|
+
|
|
458
|
+
if (isMuted) {
|
|
459
|
+
await this.setVolume(0)
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
await this.setVolume(this.#lastNonZeroVolume)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Get status active (online/offline)
|
|
468
|
+
* @returns {Promise<CharacteristicValue>}
|
|
469
|
+
*/
|
|
470
|
+
async getStatusActive () {
|
|
471
|
+
const isOnline = this.#deviceModel.status.isOnline
|
|
472
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `[${this.#device.name}] Get speaker status active -> ${isOnline}`)
|
|
473
|
+
return isOnline
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Update online status characteristic
|
|
478
|
+
* @param {boolean} isOnline - Online status
|
|
479
|
+
*/
|
|
480
|
+
updateOnlineStatusCharacteristic (isOnline) {
|
|
481
|
+
if (!this.speakerService) return
|
|
482
|
+
|
|
483
|
+
this.speakerService
|
|
484
|
+
.getCharacteristic(this.#platform.Characteristic.StatusActive)
|
|
485
|
+
.updateValue(isOnline)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Update SmartSpeaker media state characteristics
|
|
490
|
+
* @param { "playing" | "paused" | "stopped" | "loading" | null} playbackStatus - Playback status
|
|
491
|
+
*/
|
|
492
|
+
updateSmartSpeakerMediaStateCharacteristic (playbackStatus) {
|
|
493
|
+
if (!this.speakerService) return
|
|
494
|
+
|
|
495
|
+
const { Characteristic } = this.#platform
|
|
496
|
+
const { current, target } = this.getMediaStateValues(playbackStatus)
|
|
497
|
+
|
|
498
|
+
this.speakerService
|
|
499
|
+
.getCharacteristic(Characteristic.CurrentMediaState)
|
|
500
|
+
.updateValue(current)
|
|
501
|
+
|
|
502
|
+
this.speakerService
|
|
503
|
+
.getCharacteristic(Characteristic.TargetMediaState)
|
|
504
|
+
.updateValue(target)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Update volume and mute characteristics
|
|
509
|
+
* @param {number} volumeSteps - Volume level (0-16)
|
|
510
|
+
*/
|
|
511
|
+
updateVolumeCharacteristic (volumeSteps) {
|
|
512
|
+
if (!this.speakerService) return
|
|
513
|
+
|
|
514
|
+
const percent = stepsToPercent(volumeSteps)
|
|
515
|
+
const isMuted = clampSteps(volumeSteps) === 0
|
|
516
|
+
|
|
517
|
+
this.speakerService
|
|
518
|
+
.getCharacteristic(this.#platform.Characteristic.Volume)
|
|
519
|
+
.updateValue(percent)
|
|
520
|
+
|
|
521
|
+
this.speakerService
|
|
522
|
+
.getCharacteristic(this.#platform.Characteristic.Mute)
|
|
523
|
+
.updateValue(isMuted)
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Update firmware version characteristic
|
|
528
|
+
* @param {string} firmwareVersion - Firmware version
|
|
529
|
+
*/
|
|
530
|
+
updateFirmwareVersionCharacteristic (firmwareVersion) {
|
|
531
|
+
const { Service, Characteristic } = this.#platform
|
|
532
|
+
const infoService = this.#accessory.getService(Service.AccessoryInformation)
|
|
533
|
+
if (!infoService) return
|
|
534
|
+
|
|
535
|
+
infoService.setCharacteristic(
|
|
536
|
+
Characteristic.FirmwareRevision,
|
|
537
|
+
firmwareVersion
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Stop accessory - cleanup event listeners
|
|
543
|
+
* @returns {Promise<void>}
|
|
544
|
+
*/
|
|
545
|
+
async stop () {
|
|
546
|
+
this.#log.debug(LOG_PREFIX.ACCESSORY, `Stopping speaker for ${this.#device.name}`)
|
|
547
|
+
|
|
548
|
+
// Only remove our own listeners; the device model is shared
|
|
549
|
+
this.#listeners.removeAll()
|
|
550
|
+
}
|
|
551
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Use syncServiceNames for every visible service so HomeKit labels stay stable.
|
|
2
|
+
// Exceptions: AccessoryInformation (named in platform) and Battery (set Characteristic.Name only).
|
|
3
|
+
/** @import { Service, Characteristic } from 'homebridge' */
|
|
4
|
+
import { sanitizeName } from './utils/sanitize-name.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Apply HomeKit-visible naming to a service.
|
|
8
|
+
*
|
|
9
|
+
* We set both `Name` and `ConfiguredName` on every service we manage so HomeKit tiles are consistently labeled.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} params
|
|
12
|
+
* @param {Service} params.service
|
|
13
|
+
* @param {string} params.name
|
|
14
|
+
* @param {typeof Characteristic} params.Characteristic
|
|
15
|
+
* @returns {void}
|
|
16
|
+
*/
|
|
17
|
+
export function syncServiceNames ({
|
|
18
|
+
Characteristic,
|
|
19
|
+
service,
|
|
20
|
+
name
|
|
21
|
+
}) {
|
|
22
|
+
const sanitizedName = sanitizeName(name)
|
|
23
|
+
service.displayName = sanitizedName
|
|
24
|
+
|
|
25
|
+
service.updateCharacteristic(Characteristic.Name, sanitizedName)
|
|
26
|
+
|
|
27
|
+
// Add ConfiguredName when missing so we avoid HAP warnings on update.
|
|
28
|
+
const configuredNameUuid = Characteristic.ConfiguredName.UUID
|
|
29
|
+
const hasConfiguredNameCharacteristic = service.characteristics
|
|
30
|
+
.some((characteristic) => characteristic.UUID === configuredNameUuid)
|
|
31
|
+
const hasConfiguredNameOptional = service.optionalCharacteristics
|
|
32
|
+
.some((characteristic) => characteristic.UUID === configuredNameUuid)
|
|
33
|
+
|
|
34
|
+
if (!hasConfiguredNameCharacteristic && !hasConfiguredNameOptional) {
|
|
35
|
+
service.addOptionalCharacteristic(Characteristic.ConfiguredName)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
service.updateCharacteristic(Characteristic.ConfiguredName, sanitizedName)
|
|
39
|
+
}
|