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.
Files changed (64) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/DEVELOPMENT.md +65 -0
  3. package/LICENSE +202 -0
  4. package/README.md +251 -0
  5. package/SECURITY.md +43 -0
  6. package/config.schema.json +135 -0
  7. package/dist/api/client.d.ts +131 -0
  8. package/dist/api/client.js +226 -0
  9. package/dist/api/discovery.d.ts +136 -0
  10. package/dist/api/discovery.js +402 -0
  11. package/dist/api/http.d.ts +52 -0
  12. package/dist/api/http.js +136 -0
  13. package/dist/api/identity.d.ts +73 -0
  14. package/dist/api/identity.js +120 -0
  15. package/dist/api/index.d.ts +14 -0
  16. package/dist/api/index.js +30 -0
  17. package/dist/api/sync-status.d.ts +50 -0
  18. package/dist/api/sync-status.js +191 -0
  19. package/dist/api/xml.d.ts +76 -0
  20. package/dist/api/xml.js +365 -0
  21. package/dist/devices/base-accessory.d.ts +131 -0
  22. package/dist/devices/base-accessory.js +236 -0
  23. package/dist/devices/battery-accessory.d.ts +28 -0
  24. package/dist/devices/battery-accessory.js +85 -0
  25. package/dist/devices/host.d.ts +45 -0
  26. package/dist/devices/host.js +14 -0
  27. package/dist/devices/index.d.ts +14 -0
  28. package/dist/devices/index.js +30 -0
  29. package/dist/devices/mute-accessory.d.ts +35 -0
  30. package/dist/devices/mute-accessory.js +71 -0
  31. package/dist/devices/volume-accessory.d.ts +66 -0
  32. package/dist/devices/volume-accessory.js +218 -0
  33. package/dist/devices/volume-preset-accessory.d.ts +32 -0
  34. package/dist/devices/volume-preset-accessory.js +89 -0
  35. package/dist/index.d.ts +15 -0
  36. package/dist/index.js +19 -0
  37. package/dist/platform.d.ts +124 -0
  38. package/dist/platform.js +489 -0
  39. package/dist/poller.d.ts +109 -0
  40. package/dist/poller.js +300 -0
  41. package/dist/settings.d.ts +184 -0
  42. package/dist/settings.js +210 -0
  43. package/dist/types/index.d.ts +218 -0
  44. package/dist/types/index.js +38 -0
  45. package/dist/ui-api.d.ts +20 -0
  46. package/dist/ui-api.js +32 -0
  47. package/dist/utils/context.d.ts +18 -0
  48. package/dist/utils/context.js +56 -0
  49. package/dist/utils/errors.d.ts +37 -0
  50. package/dist/utils/errors.js +92 -0
  51. package/dist/utils/index.d.ts +13 -0
  52. package/dist/utils/index.js +29 -0
  53. package/dist/utils/serial.d.ts +22 -0
  54. package/dist/utils/serial.js +37 -0
  55. package/dist/utils/timing.d.ts +52 -0
  56. package/dist/utils/timing.js +74 -0
  57. package/dist/utils/validators.d.ts +99 -0
  58. package/dist/utils/validators.js +461 -0
  59. package/docs/FEATURES.md +91 -0
  60. package/docs/PROTOCOL.md +194 -0
  61. package/homebridge-ui/public/index.html +87 -0
  62. package/homebridge-ui/public/index.js +475 -0
  63. package/homebridge-ui/server.js +189 -0
  64. 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())()