homebridge-bluos 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/CHANGELOG.md +7 -0
- package/DEVELOPMENT.md +65 -0
- package/LICENSE +202 -0
- package/README.md +251 -0
- package/SECURITY.md +43 -0
- package/config.schema.json +135 -0
- package/dist/api/client.d.ts +131 -0
- package/dist/api/client.js +226 -0
- package/dist/api/discovery.d.ts +136 -0
- package/dist/api/discovery.js +402 -0
- package/dist/api/http.d.ts +52 -0
- package/dist/api/http.js +136 -0
- package/dist/api/identity.d.ts +73 -0
- package/dist/api/identity.js +120 -0
- package/dist/api/index.d.ts +14 -0
- package/dist/api/index.js +30 -0
- package/dist/api/sync-status.d.ts +50 -0
- package/dist/api/sync-status.js +191 -0
- package/dist/api/xml.d.ts +76 -0
- package/dist/api/xml.js +365 -0
- package/dist/devices/base-accessory.d.ts +131 -0
- package/dist/devices/base-accessory.js +236 -0
- package/dist/devices/battery-accessory.d.ts +28 -0
- package/dist/devices/battery-accessory.js +85 -0
- package/dist/devices/host.d.ts +45 -0
- package/dist/devices/host.js +14 -0
- package/dist/devices/index.d.ts +14 -0
- package/dist/devices/index.js +30 -0
- package/dist/devices/mute-accessory.d.ts +35 -0
- package/dist/devices/mute-accessory.js +71 -0
- package/dist/devices/volume-accessory.d.ts +66 -0
- package/dist/devices/volume-accessory.js +218 -0
- package/dist/devices/volume-preset-accessory.d.ts +32 -0
- package/dist/devices/volume-preset-accessory.js +89 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +19 -0
- package/dist/platform.d.ts +124 -0
- package/dist/platform.js +489 -0
- package/dist/poller.d.ts +109 -0
- package/dist/poller.js +300 -0
- package/dist/settings.d.ts +184 -0
- package/dist/settings.js +210 -0
- package/dist/types/index.d.ts +218 -0
- package/dist/types/index.js +38 -0
- package/dist/ui-api.d.ts +20 -0
- package/dist/ui-api.js +32 -0
- package/dist/utils/context.d.ts +18 -0
- package/dist/utils/context.js +56 -0
- package/dist/utils/errors.d.ts +37 -0
- package/dist/utils/errors.js +92 -0
- package/dist/utils/index.d.ts +13 -0
- package/dist/utils/index.js +29 -0
- package/dist/utils/serial.d.ts +22 -0
- package/dist/utils/serial.js +37 -0
- package/dist/utils/timing.d.ts +52 -0
- package/dist/utils/timing.js +74 -0
- package/dist/utils/validators.d.ts +99 -0
- package/dist/utils/validators.js +461 -0
- package/docs/FEATURES.md +91 -0
- package/docs/PROTOCOL.md +194 -0
- package/homebridge-ui/public/index.html +87 -0
- package/homebridge-ui/public/index.js +475 -0
- package/homebridge-ui/server.js +189 -0
- package/package.json +91 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 tbaur
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0
|
|
5
|
+
* See LICENSE file for full license text
|
|
6
|
+
*
|
|
7
|
+
* Custom configuration UI. Discovers BluOS zones and writes them into the plugin
|
|
8
|
+
* configuration, so a user never has to find an IP address or work out which
|
|
9
|
+
* port a multi-zone amplifier uses for its second zone.
|
|
10
|
+
*
|
|
11
|
+
* Player names and models arrive from the network and are therefore untrusted.
|
|
12
|
+
* Every value from a player is inserted with textContent or as a form value,
|
|
13
|
+
* never as HTML, which is why this file builds nodes instead of assembling markup
|
|
14
|
+
* from strings.
|
|
15
|
+
*
|
|
16
|
+
* A separate file rather than an inline script so that the linter and the tests
|
|
17
|
+
* can reach it: it is shipped to users and it writes their configuration.
|
|
18
|
+
*/
|
|
19
|
+
(() => {
|
|
20
|
+
'use strict'
|
|
21
|
+
|
|
22
|
+
const PLATFORM = 'BluOS'
|
|
23
|
+
|
|
24
|
+
/** Control port of a primary player, matching DEFAULT_BLUOS_PORT in settings.ts. */
|
|
25
|
+
const DEFAULT_PORT = 11000
|
|
26
|
+
|
|
27
|
+
/** Default discovery window, matching DEFAULT_DISCOVERY_TIMEOUT_SEC in settings.ts. */
|
|
28
|
+
const DEFAULT_TIMEOUT_SEC = 5
|
|
29
|
+
|
|
30
|
+
/** Every player known to the page, keyed by its stable identity. */
|
|
31
|
+
const players = new Map()
|
|
32
|
+
|
|
33
|
+
let platformConfig = { platform: PLATFORM, name: PLATFORM, devices: [], options: {} }
|
|
34
|
+
let schemaFormVisible = false
|
|
35
|
+
|
|
36
|
+
const byId = (id) => document.getElementById(id)
|
|
37
|
+
const playersEl = byId('players')
|
|
38
|
+
|
|
39
|
+
/** Build an element. Text is always set as text, never parsed as HTML. */
|
|
40
|
+
function el(tag, attributes = {}, text) {
|
|
41
|
+
const node = document.createElement(tag)
|
|
42
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
43
|
+
if (value === undefined || value === false) {
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
if (key === 'class') {
|
|
47
|
+
node.className = value
|
|
48
|
+
} else if (key === 'dataset') {
|
|
49
|
+
Object.assign(node.dataset, value)
|
|
50
|
+
} else {
|
|
51
|
+
node.setAttribute(key, value === true ? '' : String(value))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (text !== undefined) {
|
|
55
|
+
node.textContent = text
|
|
56
|
+
}
|
|
57
|
+
return node
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function describeError(error) {
|
|
61
|
+
if (error && typeof error === 'object') {
|
|
62
|
+
if (error.error) {
|
|
63
|
+
return String(error.error)
|
|
64
|
+
}
|
|
65
|
+
if (error.message) {
|
|
66
|
+
return String(error.message)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return String(error)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Configuration ------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
async function loadConfig() {
|
|
75
|
+
const blocks = await homebridge.getPluginConfig()
|
|
76
|
+
const existing = Array.isArray(blocks) && blocks.length > 0 ? blocks[0] : undefined
|
|
77
|
+
platformConfig = Object.assign({ platform: PLATFORM, name: PLATFORM }, existing)
|
|
78
|
+
platformConfig.platform = PLATFORM
|
|
79
|
+
if (!Array.isArray(platformConfig.devices)) {
|
|
80
|
+
platformConfig.devices = []
|
|
81
|
+
}
|
|
82
|
+
if (typeof platformConfig.options !== 'object' || platformConfig.options === null) {
|
|
83
|
+
platformConfig.options = {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Configured players are shown even before discovery runs, so a user can
|
|
87
|
+
// adjust an existing setup without waiting for the network.
|
|
88
|
+
for (const device of platformConfig.devices) {
|
|
89
|
+
if (!device || typeof device.id !== 'string') {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
players.set(device.id, {
|
|
93
|
+
id: device.id,
|
|
94
|
+
name: device.name || device.host || device.id,
|
|
95
|
+
host: device.host || '',
|
|
96
|
+
port: device.port || DEFAULT_PORT,
|
|
97
|
+
brand: device.brand || 'BluOS',
|
|
98
|
+
model: device.model || '',
|
|
99
|
+
firmware: '',
|
|
100
|
+
fixedVolume: false,
|
|
101
|
+
hasBattery: device.battery === true,
|
|
102
|
+
derivedIdentity: false,
|
|
103
|
+
online: undefined,
|
|
104
|
+
selected: true,
|
|
105
|
+
volumeSlider: device.volumeSlider !== false,
|
|
106
|
+
mute: device.mute === true,
|
|
107
|
+
battery: device.battery === true,
|
|
108
|
+
presets: Array.isArray(device.volumePresets)
|
|
109
|
+
? device.volumePresets.filter((preset) => preset && typeof preset.name === 'string')
|
|
110
|
+
.map((preset) => ({ name: preset.name, volume: Number(preset.volume) || 0 }))
|
|
111
|
+
: [],
|
|
112
|
+
// Kept so that saving preserves settings this page does not model, such
|
|
113
|
+
// as a per-device sliderService written in the advanced editor. Without
|
|
114
|
+
// it, opening this page and pressing Save would silently discard them.
|
|
115
|
+
saved: device,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function mergeDiscovered(found) {
|
|
121
|
+
for (const player of found) {
|
|
122
|
+
const existing = players.get(player.id)
|
|
123
|
+
if (existing !== undefined) {
|
|
124
|
+
// Discovery is authoritative about where a player is and what it is,
|
|
125
|
+
// never about what the user chose to expose.
|
|
126
|
+
existing.host = player.host
|
|
127
|
+
existing.port = player.port
|
|
128
|
+
existing.brand = player.brand
|
|
129
|
+
existing.model = player.model
|
|
130
|
+
existing.firmware = player.firmware
|
|
131
|
+
existing.fixedVolume = player.fixedVolume
|
|
132
|
+
existing.hasBattery = player.hasBattery
|
|
133
|
+
existing.online = true
|
|
134
|
+
if (existing.fixedVolume) {
|
|
135
|
+
existing.volumeSlider = false
|
|
136
|
+
}
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
players.set(player.id, {
|
|
140
|
+
id: player.id,
|
|
141
|
+
name: player.name,
|
|
142
|
+
host: player.host,
|
|
143
|
+
port: player.port,
|
|
144
|
+
brand: player.brand,
|
|
145
|
+
model: player.model,
|
|
146
|
+
firmware: player.firmware,
|
|
147
|
+
fixedVolume: player.fixedVolume,
|
|
148
|
+
hasBattery: player.hasBattery,
|
|
149
|
+
derivedIdentity: player.derivedIdentity,
|
|
150
|
+
online: true,
|
|
151
|
+
selected: false,
|
|
152
|
+
volumeSlider: player.suggested.volumeSlider,
|
|
153
|
+
mute: player.suggested.mute,
|
|
154
|
+
battery: player.suggested.battery,
|
|
155
|
+
presets: [],
|
|
156
|
+
saved: undefined,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function toDevices() {
|
|
162
|
+
const devices = []
|
|
163
|
+
for (const player of players.values()) {
|
|
164
|
+
if (!player.selected) {
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
const device = Object.assign({}, player.saved, {
|
|
168
|
+
id: player.id,
|
|
169
|
+
name: player.name.trim() || player.host,
|
|
170
|
+
host: player.host,
|
|
171
|
+
port: player.port,
|
|
172
|
+
volumeSlider: player.volumeSlider === true,
|
|
173
|
+
mute: player.mute === true,
|
|
174
|
+
battery: player.battery === true,
|
|
175
|
+
})
|
|
176
|
+
if (player.brand) {
|
|
177
|
+
device.brand = player.brand
|
|
178
|
+
}
|
|
179
|
+
if (player.model) {
|
|
180
|
+
device.model = player.model
|
|
181
|
+
}
|
|
182
|
+
const presets = player.presets
|
|
183
|
+
.filter((preset) => preset.name.trim().length > 0)
|
|
184
|
+
.map((preset) => ({
|
|
185
|
+
name: preset.name.trim(),
|
|
186
|
+
volume: Math.max(0, Math.min(100, Math.round(Number(preset.volume) || 0))),
|
|
187
|
+
}))
|
|
188
|
+
if (presets.length > 0) {
|
|
189
|
+
device.volumePresets = presets
|
|
190
|
+
} else {
|
|
191
|
+
// Removing every preset has to remove the key, or a carried-forward list
|
|
192
|
+
// would reinstate switches the user just deleted.
|
|
193
|
+
delete device.volumePresets
|
|
194
|
+
}
|
|
195
|
+
devices.push(device)
|
|
196
|
+
}
|
|
197
|
+
return devices
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function save() {
|
|
201
|
+
const devices = toDevices()
|
|
202
|
+
platformConfig.devices = devices
|
|
203
|
+
try {
|
|
204
|
+
await homebridge.updatePluginConfig([platformConfig])
|
|
205
|
+
await homebridge.savePluginConfig()
|
|
206
|
+
homebridge.toast.success(
|
|
207
|
+
devices.length === 1 ? '1 player saved.' : `${devices.length} players saved.`,
|
|
208
|
+
'Saved',
|
|
209
|
+
)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
homebridge.toast.error(describeError(error), 'Could not save')
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- Rendering ----------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
function renderPresets(player) {
|
|
218
|
+
const wrapper = el('div', { class: 'bluos-presets' })
|
|
219
|
+
player.presets.forEach((preset, index) => {
|
|
220
|
+
const row = el('div', { class: 'bluos-preset-row' })
|
|
221
|
+
|
|
222
|
+
const name = el('input', {
|
|
223
|
+
type: 'text',
|
|
224
|
+
class: 'form-control form-control-sm',
|
|
225
|
+
placeholder: 'Preset name, e.g. Bedtime Volume',
|
|
226
|
+
})
|
|
227
|
+
name.value = preset.name
|
|
228
|
+
name.addEventListener('input', () => {
|
|
229
|
+
preset.name = name.value
|
|
230
|
+
refreshSummary()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const volume = el('input', {
|
|
234
|
+
type: 'number', min: '0', max: '100', class: 'form-control form-control-sm',
|
|
235
|
+
})
|
|
236
|
+
volume.value = String(preset.volume)
|
|
237
|
+
volume.addEventListener('input', () => {
|
|
238
|
+
preset.volume = Number(volume.value)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
const remove = el('button', { class: 'btn btn-outline-danger btn-sm', type: 'button' }, 'Remove')
|
|
242
|
+
remove.addEventListener('click', () => {
|
|
243
|
+
player.presets.splice(index, 1)
|
|
244
|
+
render()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
row.append(name, volume, remove)
|
|
248
|
+
wrapper.append(row)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const add = el('button', { class: 'btn btn-outline-secondary btn-sm', type: 'button' }, 'Add volume preset')
|
|
252
|
+
add.addEventListener('click', () => {
|
|
253
|
+
player.presets.push({ name: '', volume: 30 })
|
|
254
|
+
render()
|
|
255
|
+
})
|
|
256
|
+
wrapper.append(add)
|
|
257
|
+
return wrapper
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function checkbox(label, checked, disabled, onChange) {
|
|
261
|
+
const wrapper = el('div', { class: 'custom-control custom-switch' })
|
|
262
|
+
const id = `opt-${Math.random().toString(36).slice(2)}`
|
|
263
|
+
const input = el('input', { type: 'checkbox', class: 'custom-control-input', id })
|
|
264
|
+
input.checked = checked
|
|
265
|
+
input.disabled = disabled === true
|
|
266
|
+
input.addEventListener('change', () => onChange(input.checked))
|
|
267
|
+
const text = el('label', { class: 'custom-control-label', for: id }, label)
|
|
268
|
+
wrapper.append(input, text)
|
|
269
|
+
return wrapper
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function renderPlayer(player) {
|
|
273
|
+
const card = el('div', { class: `bluos-card${player.selected ? ' is-selected' : ''}` })
|
|
274
|
+
const head = el('div', { class: 'bluos-card-head' })
|
|
275
|
+
|
|
276
|
+
const include = el('input', { type: 'checkbox', class: 'mr-1' })
|
|
277
|
+
include.checked = player.selected
|
|
278
|
+
include.setAttribute('aria-label', 'Expose this player in HomeKit')
|
|
279
|
+
include.addEventListener('change', () => {
|
|
280
|
+
player.selected = include.checked
|
|
281
|
+
render()
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
const title = el('div', { class: 'bluos-card-title' })
|
|
285
|
+
const nameInput = el('input', { type: 'text', class: 'form-control form-control-sm', maxlength: '64' })
|
|
286
|
+
nameInput.value = player.name
|
|
287
|
+
nameInput.addEventListener('input', () => {
|
|
288
|
+
player.name = nameInput.value
|
|
289
|
+
refreshSummary()
|
|
290
|
+
})
|
|
291
|
+
title.append(nameInput)
|
|
292
|
+
|
|
293
|
+
head.append(include, title)
|
|
294
|
+
|
|
295
|
+
if (player.port !== DEFAULT_PORT) {
|
|
296
|
+
head.append(el('span', { class: 'bluos-badge' }, `zone ${player.port}`))
|
|
297
|
+
}
|
|
298
|
+
if (player.fixedVolume) {
|
|
299
|
+
head.append(el('span', { class: 'bluos-badge warn' }, 'fixed volume'))
|
|
300
|
+
}
|
|
301
|
+
if (player.hasBattery) {
|
|
302
|
+
head.append(el('span', { class: 'bluos-badge' }, 'battery'))
|
|
303
|
+
}
|
|
304
|
+
if (player.online === false) {
|
|
305
|
+
head.append(el('span', { class: 'bluos-badge warn' }, 'not found'))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const metaParts = [player.brand, player.model].filter((part) => part)
|
|
309
|
+
const meta = el('div', { class: 'bluos-meta' })
|
|
310
|
+
meta.textContent = `${metaParts.join(' ')} · ${player.host}:${player.port}`
|
|
311
|
+
+ (player.firmware ? ` · firmware ${player.firmware}` : '')
|
|
312
|
+
card.append(head, meta)
|
|
313
|
+
|
|
314
|
+
if (player.derivedIdentity) {
|
|
315
|
+
card.append(el(
|
|
316
|
+
'div',
|
|
317
|
+
{ class: 'bluos-meta' },
|
|
318
|
+
'This player did not report a MAC address, so an identity was generated for it. '
|
|
319
|
+
+ 'Keep it as it is: changing it detaches the accessories from their HomeKit rooms.',
|
|
320
|
+
))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const options = el('div', { class: 'bluos-options' })
|
|
324
|
+
options.append(checkbox(
|
|
325
|
+
player.fixedVolume ? 'Volume slider (unavailable: fixed output)' : 'Volume slider',
|
|
326
|
+
player.volumeSlider && !player.fixedVolume,
|
|
327
|
+
player.fixedVolume,
|
|
328
|
+
(value) => { player.volumeSlider = value },
|
|
329
|
+
))
|
|
330
|
+
options.append(checkbox('Mute switch', player.mute, false, (value) => { player.mute = value }))
|
|
331
|
+
options.append(checkbox(
|
|
332
|
+
player.hasBattery ? 'Battery sensor' : 'Battery sensor (no pack fitted)',
|
|
333
|
+
player.battery && player.hasBattery,
|
|
334
|
+
!player.hasBattery,
|
|
335
|
+
(value) => { player.battery = value },
|
|
336
|
+
))
|
|
337
|
+
card.append(options)
|
|
338
|
+
|
|
339
|
+
if (player.selected) {
|
|
340
|
+
card.append(renderPresets(player))
|
|
341
|
+
}
|
|
342
|
+
return card
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function refreshSummary() {
|
|
346
|
+
const devices = toDevices()
|
|
347
|
+
let tiles = 0
|
|
348
|
+
for (const device of devices) {
|
|
349
|
+
tiles += device.volumeSlider ? 1 : 0
|
|
350
|
+
tiles += device.mute ? 1 : 0
|
|
351
|
+
tiles += device.battery ? 1 : 0
|
|
352
|
+
tiles += Array.isArray(device.volumePresets) ? device.volumePresets.length : 0
|
|
353
|
+
}
|
|
354
|
+
byId('summary').textContent = devices.length === 0
|
|
355
|
+
? 'Nothing selected.'
|
|
356
|
+
: `${devices.length} player(s), ${tiles} HomeKit accessory(s).`
|
|
357
|
+
byId('save').disabled = false
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function render() {
|
|
361
|
+
playersEl.textContent = ''
|
|
362
|
+
if (players.size === 0) {
|
|
363
|
+
playersEl.append(el(
|
|
364
|
+
'div',
|
|
365
|
+
{ class: 'bluos-empty' },
|
|
366
|
+
'No players yet. Run discovery, or add one by address.',
|
|
367
|
+
))
|
|
368
|
+
} else {
|
|
369
|
+
const sorted = [...players.values()].sort((left, right) => {
|
|
370
|
+
if (left.host !== right.host) {
|
|
371
|
+
return left.host.localeCompare(right.host)
|
|
372
|
+
}
|
|
373
|
+
return left.port - right.port
|
|
374
|
+
})
|
|
375
|
+
for (const player of sorted) {
|
|
376
|
+
playersEl.append(renderPlayer(player))
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
refreshSummary()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// --- Actions ------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
async function discover() {
|
|
385
|
+
const timeoutSec = Number(byId('timeout').value) || DEFAULT_TIMEOUT_SEC
|
|
386
|
+
homebridge.showSpinner()
|
|
387
|
+
try {
|
|
388
|
+
const response = await homebridge.request('/discover', { timeoutSec })
|
|
389
|
+
const found = Array.isArray(response && response.players) ? response.players : []
|
|
390
|
+
// Anything previously discovered but missing now is flagged rather than
|
|
391
|
+
// removed: a player that is merely switched off should not disappear from a
|
|
392
|
+
// configuration the user already built.
|
|
393
|
+
for (const player of players.values()) {
|
|
394
|
+
if (player.online === true && !found.some((entry) => entry.id === player.id)) {
|
|
395
|
+
player.online = false
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
mergeDiscovered(found)
|
|
399
|
+
render()
|
|
400
|
+
if (found.length === 0) {
|
|
401
|
+
homebridge.toast.warning(
|
|
402
|
+
'No players answered. If multicast is filtered on your network, add a player by address.',
|
|
403
|
+
'Nothing found',
|
|
404
|
+
)
|
|
405
|
+
} else {
|
|
406
|
+
homebridge.toast.success(`Found ${found.length} zone(s).`, 'Discovery complete')
|
|
407
|
+
}
|
|
408
|
+
} catch (error) {
|
|
409
|
+
homebridge.toast.error(describeError(error), 'Discovery failed')
|
|
410
|
+
} finally {
|
|
411
|
+
homebridge.hideSpinner()
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function probe() {
|
|
416
|
+
const host = byId('manual-host').value.trim()
|
|
417
|
+
const portValue = byId('manual-port').value.trim()
|
|
418
|
+
if (host.length === 0) {
|
|
419
|
+
homebridge.toast.error('Enter an IP address or hostname.', 'Nothing to probe')
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
const payload = { host }
|
|
423
|
+
if (portValue.length > 0) {
|
|
424
|
+
payload.port = Number(portValue)
|
|
425
|
+
}
|
|
426
|
+
homebridge.showSpinner()
|
|
427
|
+
try {
|
|
428
|
+
const response = await homebridge.request('/probe', payload)
|
|
429
|
+
const found = Array.isArray(response && response.players) ? response.players : []
|
|
430
|
+
mergeDiscovered(found)
|
|
431
|
+
for (const entry of found) {
|
|
432
|
+
const player = players.get(entry.id)
|
|
433
|
+
if (player !== undefined) {
|
|
434
|
+
player.selected = true
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
render()
|
|
438
|
+
homebridge.toast.success(`Found ${found.length} zone(s) at ${host}.`, 'Player added')
|
|
439
|
+
} catch (error) {
|
|
440
|
+
homebridge.toast.error(describeError(error), 'Probe failed')
|
|
441
|
+
} finally {
|
|
442
|
+
homebridge.hideSpinner()
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
byId('discover').addEventListener('click', () => void discover())
|
|
447
|
+
byId('manual-probe').addEventListener('click', () => void probe())
|
|
448
|
+
byId('save').addEventListener('click', () => void save())
|
|
449
|
+
byId('toggle-manual').addEventListener('click', () => {
|
|
450
|
+
const panel = byId('manual')
|
|
451
|
+
panel.hidden = !panel.hidden
|
|
452
|
+
})
|
|
453
|
+
byId('toggle-json').addEventListener('click', () => {
|
|
454
|
+
schemaFormVisible = !schemaFormVisible
|
|
455
|
+
if (schemaFormVisible) {
|
|
456
|
+
homebridge.showSchemaForm()
|
|
457
|
+
} else {
|
|
458
|
+
homebridge.hideSchemaForm()
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
// The schema form is hidden by default: this page is the primary way to
|
|
463
|
+
// configure the plugin, and showing both at once invites edits in one that the
|
|
464
|
+
// other silently overwrites.
|
|
465
|
+
homebridge.hideSchemaForm()
|
|
466
|
+
|
|
467
|
+
loadConfig()
|
|
468
|
+
.then(() => {
|
|
469
|
+
render()
|
|
470
|
+
})
|
|
471
|
+
.catch((error) => {
|
|
472
|
+
homebridge.toast.error(describeError(error), 'Could not read the configuration')
|
|
473
|
+
render()
|
|
474
|
+
})
|
|
475
|
+
})()
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 tbaur
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0
|
|
5
|
+
* See LICENSE file for full license text
|
|
6
|
+
*
|
|
7
|
+
* Backend for the plugin's custom configuration UI.
|
|
8
|
+
*
|
|
9
|
+
* Runs in its own short-lived process, separate from Homebridge, and exists only
|
|
10
|
+
* while a user has the settings page open. It reuses the compiled discovery and
|
|
11
|
+
* client code from `dist/` rather than reimplementing either: a second
|
|
12
|
+
* implementation of identity derivation would be a second thing to get wrong, and
|
|
13
|
+
* the ids written here have to match exactly what the platform expects.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { HomebridgePluginUiServer, RequestError } = require('@homebridge/plugin-ui-utils')
|
|
17
|
+
|
|
18
|
+
class BluOSUiServer extends HomebridgePluginUiServer {
|
|
19
|
+
constructor() {
|
|
20
|
+
super()
|
|
21
|
+
|
|
22
|
+
this.onRequest('/discover', (payload) => this.handleDiscover(payload))
|
|
23
|
+
this.onRequest('/probe', (payload) => this.handleProbe(payload))
|
|
24
|
+
|
|
25
|
+
// Must be last: the page is not told the server is up until this fires.
|
|
26
|
+
this.ready()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load the compiled plugin API.
|
|
31
|
+
*
|
|
32
|
+
* Deferred rather than required at module scope so that a missing build
|
|
33
|
+
* produces an actionable message in the UI instead of the settings page
|
|
34
|
+
* failing to open at all.
|
|
35
|
+
*/
|
|
36
|
+
loadApi() {
|
|
37
|
+
if (this.api === undefined) {
|
|
38
|
+
try {
|
|
39
|
+
// `ui-api` is the explicit contract for this process, not the whole plugin.
|
|
40
|
+
this.api = require('../dist/ui-api')
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new RequestError(
|
|
43
|
+
'The plugin is not built. Run "npm run build" in the plugin directory.',
|
|
44
|
+
{ message: String(error && error.message ? error.message : error) },
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return this.api
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A logger that keeps UI-process output out of the Homebridge log. */
|
|
52
|
+
makeLogger() {
|
|
53
|
+
const messages = []
|
|
54
|
+
return {
|
|
55
|
+
logger: {
|
|
56
|
+
info: (message) => messages.push(`info ${message}`),
|
|
57
|
+
warn: (message) => messages.push(`warn ${message}`),
|
|
58
|
+
error: (message) => messages.push(`error ${message}`),
|
|
59
|
+
debug: (message) => messages.push(`debug ${message}`),
|
|
60
|
+
},
|
|
61
|
+
messages,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
buildDiscovery() {
|
|
66
|
+
const api = this.loadApi()
|
|
67
|
+
const { logger, messages } = this.makeLogger()
|
|
68
|
+
const client = new api.BluOSClient({ log: logger })
|
|
69
|
+
const discovery = new api.BluOSDiscovery({ log: logger, client })
|
|
70
|
+
return { api, discovery, messages }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Browse the network and return every zone that answered. */
|
|
74
|
+
async handleDiscover(payload) {
|
|
75
|
+
// Bounds come from the compiled settings rather than being restated here, so
|
|
76
|
+
// raising the ceiling in one place raises it everywhere.
|
|
77
|
+
const api = this.loadApi()
|
|
78
|
+
const requested = Number(payload && payload.timeoutSec)
|
|
79
|
+
const timeoutSec = Number.isFinite(requested)
|
|
80
|
+
? Math.min(
|
|
81
|
+
api.MAX_DISCOVERY_TIMEOUT_SEC,
|
|
82
|
+
Math.max(api.MIN_DISCOVERY_TIMEOUT_SEC, Math.round(requested)),
|
|
83
|
+
)
|
|
84
|
+
: api.DEFAULT_DISCOVERY_TIMEOUT_SEC
|
|
85
|
+
|
|
86
|
+
const { discovery, messages } = this.buildDiscovery()
|
|
87
|
+
try {
|
|
88
|
+
const players = await discovery.discover(timeoutSec)
|
|
89
|
+
return { players: players.map((player) => this.describe(player)), log: messages }
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new RequestError('Discovery failed.', {
|
|
92
|
+
message: String(error && error.message ? error.message : error),
|
|
93
|
+
log: messages,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Probe an address the user typed in, for networks where multicast is filtered.
|
|
100
|
+
*
|
|
101
|
+
* Every documented port is tried, because a multi-zone chassis answers on
|
|
102
|
+
* several and the user cannot be expected to know which.
|
|
103
|
+
*
|
|
104
|
+
* Deliberately narrow: this endpoint makes the Homebridge host open a connection
|
|
105
|
+
* to an address a caller chose, so it is only useful as a network probe to the
|
|
106
|
+
* extent it is allowed to be one. The host must be a private IPv4 address
|
|
107
|
+
* (including CGNAT / Tailscale) or a local hostname, and the port must be one
|
|
108
|
+
* BluOS actually uses — which is what stops it being a general port scanner
|
|
109
|
+
* run from the Homebridge host's network position.
|
|
110
|
+
*/
|
|
111
|
+
async handleProbe(payload) {
|
|
112
|
+
const host = payload && typeof payload.host === 'string' ? payload.host.trim() : ''
|
|
113
|
+
const api = this.loadApi()
|
|
114
|
+
if (!api.isValidHost(host)) {
|
|
115
|
+
throw new RequestError('Enter a valid IP address or hostname.', { host })
|
|
116
|
+
}
|
|
117
|
+
if (!api.isProbeableHost(host)) {
|
|
118
|
+
throw new RequestError(
|
|
119
|
+
'That address is outside your local network. The BluOS API is unauthenticated, '
|
|
120
|
+
+ 'so only private addresses and local names can be probed from here.',
|
|
121
|
+
{ host },
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const requestedPort = Number(payload && payload.port)
|
|
126
|
+
let ports = api.DOCUMENTED_BLUOS_PORTS
|
|
127
|
+
if (Number.isInteger(requestedPort)) {
|
|
128
|
+
if (!api.DOCUMENTED_BLUOS_PORTS.includes(requestedPort)) {
|
|
129
|
+
throw new RequestError(
|
|
130
|
+
`Port ${requestedPort} is not a BluOS control port. `
|
|
131
|
+
+ `Use one of ${api.DOCUMENTED_BLUOS_PORTS.join(', ')}, or leave it blank to try each.`,
|
|
132
|
+
{ host, port: requestedPort },
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
ports = [requestedPort]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { discovery, messages } = this.buildDiscovery()
|
|
139
|
+
const found = []
|
|
140
|
+
for (const port of ports) {
|
|
141
|
+
// Sequential on purpose: these are all the same chassis, and hitting one
|
|
142
|
+
// box with parallel requests is exactly what the client's rate limits are
|
|
143
|
+
// there to prevent.
|
|
144
|
+
const player = await discovery.probe({ host, port })
|
|
145
|
+
if (player !== undefined) {
|
|
146
|
+
found.push(this.describe(player))
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (found.length === 0) {
|
|
150
|
+
throw new RequestError(`Nothing answered at ${host} on port ${ports.join(', ')}.`, {
|
|
151
|
+
log: messages,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
return { players: found, log: messages }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Shape a discovered player for the page, including sensible defaults.
|
|
159
|
+
*
|
|
160
|
+
* A player that reports a fixed output level gets no slider suggested, because
|
|
161
|
+
* writing a level to it does nothing. A player with no usable MAC is given a
|
|
162
|
+
* generated identity here, once, so that it stays stable from then on.
|
|
163
|
+
*/
|
|
164
|
+
describe(player) {
|
|
165
|
+
const api = this.loadApi()
|
|
166
|
+
const id = player.id && player.id.length > 0 ? player.id : api.makeGeneratedPlayerId()
|
|
167
|
+
return {
|
|
168
|
+
id,
|
|
169
|
+
name: player.name,
|
|
170
|
+
host: player.host,
|
|
171
|
+
port: player.port,
|
|
172
|
+
brand: player.brand || 'BluOS',
|
|
173
|
+
model: player.modelName || player.model || 'BluOS Player',
|
|
174
|
+
firmware: player.firmware || '',
|
|
175
|
+
fixedVolume: player.fixedVolume === true,
|
|
176
|
+
hasBattery: player.hasBattery === true,
|
|
177
|
+
derivedIdentity: !(player.mac && player.mac.length > 0),
|
|
178
|
+
suggested: {
|
|
179
|
+
volumeSlider: player.fixedVolume !== true,
|
|
180
|
+
mute: false,
|
|
181
|
+
battery: player.hasBattery === true,
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Homebridge starts this file as a child process and expects the instance to be
|
|
188
|
+
// constructed immediately.
|
|
189
|
+
;(() => new BluOSUiServer())()
|