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,461 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 tbaur
4
+ *
5
+ * Licensed under the Apache License, Version 2.0
6
+ * See LICENSE file for full license text
7
+ *
8
+ * @fileoverview Configuration validation.
9
+ *
10
+ * The split between fatal and non-fatal is deliberate. A structural problem —
11
+ * `devices` present but not an array — means the file does not describe anything
12
+ * we can act on, so the platform disables itself while leaving cached
13
+ * accessories registered, and HomeKit shows them as No Response rather than
14
+ * losing the rooms and automations built on them.
15
+ *
16
+ * A problem with one device is different: rejecting the whole fleet because one
17
+ * entry is malformed would be a worse outcome than skipping that entry. Skipped
18
+ * devices are warned about by name and reason, because a device that silently
19
+ * fails to appear is the hardest kind of bug for a user to report.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.forLog = forLog;
23
+ exports.forDisplay = forDisplay;
24
+ exports.isIpv4 = isIpv4;
25
+ exports.isValidHost = isValidHost;
26
+ exports.isNonPrivateIpv4 = isNonPrivateIpv4;
27
+ exports.isProbeableHost = isProbeableHost;
28
+ exports.resolveDiscoveryTimeoutSec = resolveDiscoveryTimeoutSec;
29
+ exports.resolveSliderService = resolveSliderService;
30
+ exports.validateConfig = validateConfig;
31
+ exports.resolveAccessories = resolveAccessories;
32
+ const identity_1 = require("../api/identity");
33
+ const settings_1 = require("../settings");
34
+ const types_1 = require("../types");
35
+ const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/;
36
+ /**
37
+ * The same class, global, for replacement.
38
+ * Kept separate because a global regex carries `lastIndex` state, which would
39
+ * make the `test` calls above return alternating answers for one input.
40
+ */
41
+ const ALL_CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/g;
42
+ const HOSTNAME = /^[A-Za-z0-9]([A-Za-z0-9._-]{0,252}[A-Za-z0-9])?$/;
43
+ const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
44
+ /**
45
+ * Make an untrusted string safe to interpolate into a log line.
46
+ *
47
+ * Device names come from configuration and from the players themselves, so they
48
+ * are attacker-influenced in the threat model where someone can write to either.
49
+ * A newline in a log line lets them forge entries; truncation stops one long
50
+ * name from burying everything else.
51
+ */
52
+ function forLog(value) {
53
+ const text = typeof value === 'string' ? value : String(value);
54
+ const sanitized = text.replace(ALL_CONTROL_CHARACTERS, '\uFFFD');
55
+ return sanitized.length > settings_1.MAX_LOG_FIELD_LENGTH
56
+ ? `${sanitized.slice(0, settings_1.MAX_LOG_FIELD_LENGTH)}\u2026`
57
+ : sanitized;
58
+ }
59
+ /**
60
+ * Make an untrusted string safe to publish to HomeKit.
61
+ *
62
+ * Same sanitising as {@link forLog} but capped at HomeKit's name budget rather
63
+ * than the log field budget, for values that become characteristic values and
64
+ * are written into the accessory cache. An empty result becomes undefined, so a
65
+ * caller keeps the value it already had instead of publishing a blank.
66
+ */
67
+ function forDisplay(value) {
68
+ const cleaned = value.replace(ALL_CONTROL_CHARACTERS, '').trim();
69
+ if (cleaned.length === 0) {
70
+ return undefined;
71
+ }
72
+ return cleaned.length > settings_1.MAX_NAME_LENGTH
73
+ ? `${cleaned.slice(0, settings_1.MAX_NAME_LENGTH - 1)}\u2026`
74
+ : cleaned;
75
+ }
76
+ /** True for an IPv4 literal whose octets are all in range. */
77
+ function isIpv4(value) {
78
+ const match = IPV4.exec(value);
79
+ if (match === null) {
80
+ return false;
81
+ }
82
+ // Canonical form only. A leading zero is rejected rather than normalised
83
+ // because `010` is decimal ten to this check and octal eight to some
84
+ // resolvers, and the address we validate must be the address that gets dialled.
85
+ return match.slice(1).every((octet) => {
86
+ const number = Number(octet);
87
+ return number >= 0 && number <= 255 && String(number) === octet;
88
+ });
89
+ }
90
+ /** True for something usable as an HTTP host: an IPv4 literal or a hostname. */
91
+ function isValidHost(value) {
92
+ if (typeof value !== 'string') {
93
+ return false;
94
+ }
95
+ const trimmed = value.trim();
96
+ if (trimmed.length === 0 || trimmed.length > 253) {
97
+ return false;
98
+ }
99
+ // A four-octet dotted quad is an address, never a hostname. Falling through
100
+ // to HOSTNAME would accept `192.168.04.11` and `999.999.999.999`, undoing
101
+ // isIpv4's canonical-form check — the exact octal-ambiguity case that check
102
+ // exists to reject.
103
+ if (IPV4.test(trimmed)) {
104
+ return isIpv4(trimmed);
105
+ }
106
+ return HOSTNAME.test(trimmed);
107
+ }
108
+ /**
109
+ * True for an address outside the ranges treated as local.
110
+ *
111
+ * Local here is RFC 1918, RFC 6598 shared address space (CGNAT / Tailscale),
112
+ * loopback, and link-local. Not blocked in configuration, only warned about:
113
+ * the BluOS API is unauthenticated and intended for a local network, and a
114
+ * routable address in the configuration is much more likely to be a typo than
115
+ * an intention. Hostnames cannot be classified this way and are left alone
116
+ * in configuration; the settings-page probe uses {@link isProbeableHost}.
117
+ */
118
+ function isNonPrivateIpv4(value) {
119
+ if (!isIpv4(value)) {
120
+ return false;
121
+ }
122
+ const [a = 0, b = 0] = value.split('.').map(Number);
123
+ if (a === 10 || a === 127) {
124
+ return false;
125
+ }
126
+ if (a === 192 && b === 168) {
127
+ return false;
128
+ }
129
+ if (a === 172 && b >= 16 && b <= 31) {
130
+ return false;
131
+ }
132
+ if (a === 169 && b === 254) {
133
+ return false;
134
+ }
135
+ // RFC 6598: 100.64.0.0/10. Tailscale and carrier-grade NAT live here.
136
+ if (a === 100 && b >= 64 && b <= 127) {
137
+ return false;
138
+ }
139
+ return true;
140
+ }
141
+ /**
142
+ * Suffixes that name a host on this network rather than on the public internet.
143
+ *
144
+ * `.local` is mDNS. `.localhost`, `.internal` and `.home.arpa` are IANA
145
+ * special-use. A single-label name (no dots) is a DHCP / local-resolver name.
146
+ */
147
+ const PROBE_LOCAL_SUFFIXES = ['.local', '.localhost', '.internal', '.home.arpa'];
148
+ /**
149
+ * True for a host the settings-page probe is allowed to dial.
150
+ *
151
+ * Narrower than {@link isValidHost}: configuration may name a split-DNS
152
+ * hostname that happens to look public, but the probe is an authenticated
153
+ * Homebridge administrator asking this host to open a connection, so it must
154
+ * not become a scanner. Public IPv4 literals and multi-label public names
155
+ * (`example.com`) are refused. Private IPv4 (including CGNAT) and local
156
+ * hostnames are accepted.
157
+ */
158
+ function isProbeableHost(value) {
159
+ if (!isValidHost(value)) {
160
+ return false;
161
+ }
162
+ const host = value.trim();
163
+ if (isIpv4(host)) {
164
+ return !isNonPrivateIpv4(host);
165
+ }
166
+ const lower = host.toLowerCase();
167
+ if (!lower.includes('.')) {
168
+ return true;
169
+ }
170
+ return PROBE_LOCAL_SUFFIXES.some((suffix) => lower.endsWith(suffix) && lower.length > suffix.length);
171
+ }
172
+ /** Clamp the discovery window into the supported range. */
173
+ function resolveDiscoveryTimeoutSec(value, warnings) {
174
+ if (value === undefined) {
175
+ return settings_1.DEFAULT_DISCOVERY_TIMEOUT_SEC;
176
+ }
177
+ const numeric = typeof value === 'number' ? value : Number(value);
178
+ if (!Number.isFinite(numeric)) {
179
+ warnings?.push(`options.discoveryTimeoutSec is not a number; using ${settings_1.DEFAULT_DISCOVERY_TIMEOUT_SEC}s`);
180
+ return settings_1.DEFAULT_DISCOVERY_TIMEOUT_SEC;
181
+ }
182
+ const clamped = Math.min(settings_1.MAX_DISCOVERY_TIMEOUT_SEC, Math.max(settings_1.MIN_DISCOVERY_TIMEOUT_SEC, Math.round(numeric)));
183
+ if (clamped !== numeric) {
184
+ warnings?.push(`options.discoveryTimeoutSec clamped to ${clamped}s`);
185
+ }
186
+ return clamped;
187
+ }
188
+ /**
189
+ * Resolve a slider service, falling back to the platform default then `fan`.
190
+ *
191
+ * An empty string counts as absent, because that is what the Homebridge form
192
+ * writes for the per-device "use the platform setting" option; treating it as a
193
+ * bad value would make choosing that option override the platform setting.
194
+ */
195
+ function resolveSliderService(deviceValue, platformValue, warnings, label) {
196
+ const where = label === undefined ? '' : `${label} `;
197
+ for (const candidate of [deviceValue, platformValue]) {
198
+ if (candidate === undefined || candidate === '') {
199
+ continue;
200
+ }
201
+ if ((0, types_1.isSliderService)(candidate)) {
202
+ return candidate;
203
+ }
204
+ warnings?.push(`${where}sliderService ${JSON.stringify(forLog(candidate))} is not "fan" or "lightbulb"; `
205
+ + 'using "fan"');
206
+ return 'fan';
207
+ }
208
+ // Fanv2 by default: a Lightbulb renders the same slider but is swept up by
209
+ // "turn off all the lights", which would silence the house.
210
+ return 'fan';
211
+ }
212
+ function validateName(value, label, problems) {
213
+ if (typeof value !== 'string' || value.trim().length === 0) {
214
+ problems.push(`${label} is missing a name`);
215
+ return undefined;
216
+ }
217
+ const name = value.trim();
218
+ if (CONTROL_CHARACTERS.test(name)) {
219
+ problems.push(`${label} name contains control characters`);
220
+ return undefined;
221
+ }
222
+ if (name.length > settings_1.MAX_NAME_LENGTH) {
223
+ problems.push(`${label} name is longer than ${settings_1.MAX_NAME_LENGTH} characters`);
224
+ return undefined;
225
+ }
226
+ return name;
227
+ }
228
+ function validatePort(value, label, warnings) {
229
+ if (value === undefined) {
230
+ return settings_1.DEFAULT_BLUOS_PORT;
231
+ }
232
+ const numeric = typeof value === 'number' ? value : Number(value);
233
+ if (!Number.isInteger(numeric) || numeric < settings_1.MIN_PORT || numeric > settings_1.MAX_PORT) {
234
+ warnings.push(`${label} port ${forLog(value)} is invalid; using ${settings_1.DEFAULT_BLUOS_PORT}`);
235
+ return settings_1.DEFAULT_BLUOS_PORT;
236
+ }
237
+ if (!settings_1.DOCUMENTED_BLUOS_PORTS.includes(numeric)) {
238
+ // Accepted anyway: the SRV record, not this list, is the authority on which
239
+ // port a zone listens to.
240
+ warnings.push(`${label} uses port ${numeric}, which is outside the documented BluOS ports `
241
+ + `(${settings_1.DOCUMENTED_BLUOS_PORTS.join(', ')})`);
242
+ }
243
+ return numeric;
244
+ }
245
+ /**
246
+ * Validate volume presets, dropping any that cannot be exposed.
247
+ *
248
+ * Two presets on one device with the same level would generate the same
249
+ * accessory UUID, so the duplicate is dropped rather than allowed to collide.
250
+ */
251
+ function validatePresets(value, label, warnings) {
252
+ if (value === undefined) {
253
+ return [];
254
+ }
255
+ if (!Array.isArray(value)) {
256
+ warnings.push(`${label} volumePresets is not a list; ignoring it`);
257
+ return [];
258
+ }
259
+ const presets = [];
260
+ const levels = new Set();
261
+ value.forEach((entry, index) => {
262
+ const presetLabel = `${label} volumePresets[${index}]`;
263
+ if (typeof entry !== 'object' || entry === null) {
264
+ warnings.push(`${presetLabel} is not an object; skipping it`);
265
+ return;
266
+ }
267
+ const candidate = entry;
268
+ const problems = [];
269
+ const name = validateName(candidate.name, presetLabel, problems);
270
+ const volume = typeof candidate.volume === 'number' ? candidate.volume : Number(candidate.volume);
271
+ if (!Number.isInteger(volume) || volume < settings_1.VOLUME_MIN || volume > settings_1.VOLUME_MAX) {
272
+ problems.push(`${presetLabel} volume must be an integer ${settings_1.VOLUME_MIN}-${settings_1.VOLUME_MAX}`);
273
+ }
274
+ if (name === undefined || problems.length > 0) {
275
+ warnings.push(`${problems.join('; ')}; skipping this preset`);
276
+ return;
277
+ }
278
+ if (levels.has(volume)) {
279
+ warnings.push(`${presetLabel} repeats volume ${volume}; skipping the duplicate`);
280
+ return;
281
+ }
282
+ levels.add(volume);
283
+ presets.push({ name, volume });
284
+ });
285
+ return presets;
286
+ }
287
+ function validateDevice(input) {
288
+ const { entry, index, platformSlider, warnings } = input;
289
+ const label = `devices[${index}]`;
290
+ if (typeof entry !== 'object' || entry === null) {
291
+ warnings.push(`${label} is not an object; skipping it`);
292
+ return undefined;
293
+ }
294
+ const device = entry;
295
+ const problems = [];
296
+ const name = validateName(device.name, label, problems);
297
+ if (!(0, identity_1.isValidPlayerId)(device.id)) {
298
+ problems.push(`${label} has no usable id (re-run discovery in the plugin settings)`);
299
+ }
300
+ if (!isValidHost(device.host)) {
301
+ problems.push(`${label} host ${forLog(device.host)} is not a valid address or hostname`);
302
+ }
303
+ if (problems.length > 0) {
304
+ warnings.push(`${problems.join('; ')}; skipping ${name === undefined ? label : forLog(name)}`);
305
+ return undefined;
306
+ }
307
+ // Narrowed by the guards above; the early return covers every failing case.
308
+ const id = device.id;
309
+ const host = device.host.trim();
310
+ const port = validatePort(device.port, label, warnings);
311
+ if (isNonPrivateIpv4(host)) {
312
+ warnings.push(`${label} host ${forLog(host)} is not a private address; the BluOS API is `
313
+ + 'unauthenticated and meant for a local network');
314
+ }
315
+ const resolved = {
316
+ id,
317
+ name: name,
318
+ host,
319
+ port,
320
+ // Absent means on, matching the schema default, the settings page and the
321
+ // documented default. The other three accessories are opt-in, so they read
322
+ // the other way round; a hand-written entry of just id, name and host is
323
+ // meant to give you a working slider and nothing else.
324
+ volumeSlider: device.volumeSlider !== false,
325
+ sliderService: resolveSliderService(device.sliderService, platformSlider, warnings, label),
326
+ mute: device.mute === true,
327
+ battery: device.battery === true,
328
+ volumePresets: validatePresets(device.volumePresets, label, warnings),
329
+ };
330
+ if (typeof device.model === 'string' && device.model.trim().length > 0) {
331
+ resolved.model = forLog(device.model.trim());
332
+ }
333
+ if (typeof device.brand === 'string' && device.brand.trim().length > 0) {
334
+ resolved.brand = forLog(device.brand.trim());
335
+ }
336
+ return resolved;
337
+ }
338
+ /**
339
+ * Validate a platform configuration block.
340
+ *
341
+ * Never throws: the platform needs the errors and warnings in order to report
342
+ * them, and a configuration problem should produce a diagnosable log rather than
343
+ * an exception during Homebridge startup.
344
+ */
345
+ function validateConfig(config) {
346
+ const errors = [];
347
+ const warnings = [];
348
+ if (typeof config !== 'object' || config === null) {
349
+ return { errors: ['platform configuration is missing'], warnings, devices: [] };
350
+ }
351
+ const platform = config;
352
+ const rawDevices = platform.devices;
353
+ if (rawDevices === undefined) {
354
+ return {
355
+ errors: ['configuration has no "devices" list; open the plugin settings and run discovery'],
356
+ warnings,
357
+ devices: [],
358
+ };
359
+ }
360
+ if (!Array.isArray(rawDevices)) {
361
+ return { errors: ['configuration "devices" must be a list'], warnings, devices: [] };
362
+ }
363
+ const platformSlider = platform.options?.sliderService;
364
+ const devices = [];
365
+ const seenIds = new Set();
366
+ rawDevices.forEach((entry, index) => {
367
+ const device = validateDevice({ entry, index, platformSlider, warnings });
368
+ if (device === undefined) {
369
+ return;
370
+ }
371
+ if (seenIds.has(device.id)) {
372
+ warnings.push(`devices[${index}] repeats id ${forLog(device.id)}; skipping the duplicate`);
373
+ return;
374
+ }
375
+ seenIds.add(device.id);
376
+ devices.push(device);
377
+ });
378
+ if (rawDevices.length > 0 && devices.length === 0) {
379
+ // Every entry was rejected. The user plainly meant to configure something,
380
+ // so this is fatal rather than an idle platform.
381
+ errors.push(`all ${rawDevices.length} configured device(s) were rejected; see the warnings above`);
382
+ }
383
+ else if (rawDevices.length === 0) {
384
+ warnings.push('no devices are configured; open the plugin settings and run discovery');
385
+ }
386
+ const exposed = devices.filter((device) => device.volumeSlider || device.mute || device.battery || device.volumePresets.length > 0);
387
+ if (devices.length > 0 && exposed.length === 0) {
388
+ warnings.push('no device has a volume slider, mute switch, battery sensor or volume preset enabled, '
389
+ + 'so nothing will appear in HomeKit');
390
+ }
391
+ return { errors, warnings, devices };
392
+ }
393
+ /**
394
+ * Expand validated devices into the accessories to expose.
395
+ *
396
+ * Names are derived here rather than at use time so that duplicates can be
397
+ * detected once: two accessories sharing a name still work, but they make Siri
398
+ * ambiguous, which is worth a warning.
399
+ */
400
+ function resolveAccessories(devices, warnings) {
401
+ const accessories = [];
402
+ for (const device of devices) {
403
+ // Whether a player reports a fixed output level is only knowable from a live
404
+ // `/SyncStatus`, so the slider is created here and disables itself on first
405
+ // observation if the player turns out to have no adjustable volume.
406
+ if (device.volumeSlider) {
407
+ accessories.push({
408
+ kind: 'volume',
409
+ deviceId: device.id,
410
+ // Suffixed like the others rather than taking the player's name bare: the
411
+ // slider is a fan tile, and a bare room name sitting among real fans is
412
+ // ambiguous both on screen and to Siri.
413
+ name: suffixName(device.name, 'Volume'),
414
+ sliderService: device.sliderService,
415
+ });
416
+ }
417
+ if (device.mute) {
418
+ accessories.push({
419
+ kind: 'mute',
420
+ deviceId: device.id,
421
+ name: suffixName(device.name, 'Mute'),
422
+ sliderService: device.sliderService,
423
+ });
424
+ }
425
+ if (device.battery) {
426
+ accessories.push({
427
+ kind: 'battery',
428
+ deviceId: device.id,
429
+ name: suffixName(device.name, 'Battery'),
430
+ sliderService: device.sliderService,
431
+ });
432
+ }
433
+ for (const preset of device.volumePresets) {
434
+ accessories.push({
435
+ kind: 'volumePreset',
436
+ deviceId: device.id,
437
+ name: preset.name,
438
+ sliderService: device.sliderService,
439
+ volume: preset.volume,
440
+ });
441
+ }
442
+ }
443
+ const names = new Map();
444
+ for (const accessory of accessories) {
445
+ names.set(accessory.name, (names.get(accessory.name) ?? 0) + 1);
446
+ }
447
+ for (const [name, count] of names) {
448
+ if (count > 1) {
449
+ warnings?.push(`${count} accessories are named ${forLog(name)}; Siri cannot tell them apart`);
450
+ }
451
+ }
452
+ return accessories;
453
+ }
454
+ /** Append a suffix to a device name without exceeding HomeKit's name budget. */
455
+ function suffixName(name, suffix) {
456
+ const combined = `${name} ${suffix}`;
457
+ if (combined.length <= settings_1.MAX_NAME_LENGTH) {
458
+ return combined;
459
+ }
460
+ return `${name.slice(0, settings_1.MAX_NAME_LENGTH - suffix.length - 2)}\u2026 ${suffix}`;
461
+ }
@@ -0,0 +1,91 @@
1
+ # Features
2
+
3
+ **homebridge-bluos**
4
+
5
+ A checklist of what is built. The plugin aims to cover everything about a BluOS player that HomeKit can express well, so this list is expected to grow; see the [roadmap](../README.md#roadmap) for what is coming and [PROTOCOL.md](PROTOCOL.md) for the API surface already mapped.
6
+
7
+ ## Built
8
+
9
+ - ✅ Multi-player platform: as many BluOS zones as you like in one platform block
10
+ - ✅ Volume slider per zone, 0–100, on the same scale as the BluOS app
11
+ - ✅ Slider exposed as a fan (default) or a lightbulb — the fan is not swept up by Siri commands aimed at lights
12
+ - ✅ Mute switch, with unmute restoring the level the player remembered rather than a guess
13
+ - ✅ Volume preset switches: one exact level per switch, addressable by name with Siri; set On sets the level, set Off is a no-op
14
+ - ✅ Battery sensor (level, charging state, low-battery) for players with a battery pack fitted
15
+ - ✅ Fixed-output players detected from `volume="-1"` and given no slider, with one explanatory log line
16
+ - ✅ Multi-zone chassis support (NAD CI-S2, CI 580): each zone is a separate player on its own port
17
+ - ✅ mDNS discovery in the settings page, with manual address entry for networks that filter multicast
18
+ - ✅ Discovery writes configuration for you, including the stable identity the platform will look for
19
+ - ✅ Long-polling `/SyncStatus`, so a change made at the front panel, on the remote or in the BluOS app reaches HomeKit in about a second
20
+ - ✅ One poll loop per zone, because the long-poll etag is per zone and not per chassis
21
+ - ✅ The API's one-second same-resource rule enforced centrally, plus a control rate limit and per-chassis write serialisation
22
+ - ✅ Separate connect and total timeouts, and a capped response size
23
+ - ✅ A connection per request, so a held long-poll cannot queue a write behind it
24
+ - ✅ Writes scoped by the zone's current grouping role, verified against a live group: a group leader carries its followers (`tell_slaves=1`), every other zone moves alone (`tell_slaves=0`), and the scope is always stated rather than left to the firmware default
25
+ - ✅ No volume ceiling of its own: the player's configured limit is the one limit, enforced by the hardware for every controller
26
+ - ✅ The player's own clamped answer is adopted rather than the requested value
27
+ - ✅ HomeKit writes that reach the player are logged as `Name: SET n` / `ON` / `OFF`, with `(group)` when the zone is leading
28
+ - ✅ Writes answer inside HomeKit's write window and finish slower work in the background
29
+ - ✅ The pair of writes HomeKit sends when a slider leaves zero is coalesced into one command
30
+ - ✅ Set/poll race protection with a generation counter, so a slider never springs back
31
+ - ✅ A deliberately cancelled poll is not counted as a failure
32
+ - ✅ Exponential backoff to a one-minute ceiling for an unreachable player; the first failure warns, the rest go to debug
33
+ - ✅ Automatic address re-resolution after repeated failures, rate-limited, so a DHCP lease change needs no user action
34
+ - ✅ New addresses persisted into the accessory cache so they survive a restart
35
+ - ✅ Accessory identity is `MAC:port:kind` (plus the preset level), never the address, so an IP change keeps your accessories
36
+ - ✅ Cached accessories adopted by identity rather than replaced, preserving rooms, scenes and automations
37
+ - ✅ Renaming a player applied in place
38
+ - ✅ Reports HomeKit "No Response" until real state has been observed, and again once the player stops answering
39
+ - ✅ An unusable configuration disables the platform and keeps every accessory registered, rather than deleting anything
40
+ - ✅ Per-device validation: one bad entry is skipped with a warning instead of stopping the rest
41
+ - ✅ Size-, depth-, element- and attribute-capped XML parsing, sanitised log output, and a length cap on any identity a player reports for itself
42
+ - ✅ Bounded discovery: the records kept from a browse, the candidates verified from it and the verifications in flight are all capped
43
+ - ✅ Clean shutdown: poll loops, backoff delays and mDNS browses are cancelled rather than run out
44
+ - ✅ A cached accessory the plugin cannot drive reports No Response and says what to do about it, rather than showing a stale value forever
45
+ - ✅ Custom Homebridge UI settings page, plus a plain `config.schema.json` form
46
+ - ✅ Homebridge v1.6.0+ and v2.0+ support
47
+ - ✅ Node.js 20+ support
48
+
49
+ ## Not built yet
50
+
51
+ Planned, in roughly this order. Each needs its protocol behaviour verified against hardware first, so none of it is committed to a date:
52
+
53
+ - ⏳ Group scenes: one switch that forms or breaks a named group (`/AddSlave`, `/RemoveSlave`), which the BluOS app cannot put into an automation
54
+ - ⏳ A chime (`/Doorbell?play=1`), so a HomeKit doorbell or door sensor can sound on your speakers
55
+ - ⏳ Transport control: play and pause as a switch a scene or a spoken command can drive, with skip and back
56
+ - ⏳ Station preset switches, recalling a saved BluOS preset by name (`/Presets`, `/Preset?id=`)
57
+ - ⏳ Input switches for the physical inputs on players that have them (`/RadioBrowse?service=Capture`, `/Play?url=`)
58
+ - ⏳ A playback sensor, so "when music starts in here" can trigger other accessories
59
+ - ⏳ Relative volume nudges in dB (`/Volume?db=±2`), for mapping physical buttons to a step up or down
60
+ - ⏳ Shuffle and repeat
61
+ - ⏳ A sleep timer, once the way to set one is confirmed: `/Status` reports the minutes remaining, but API v1.7 documents no endpoint that sets it
62
+
63
+ Anything reading playback state needs a second long-poll per zone, because `/Status` carries its own etag. That cost is why it will be opt-in per player rather than always on. Note also that the firmware proxies `/Status` and playback control from a group follower to its leader, so a follower's transport tile necessarily acts on the group.
64
+
65
+ Being weighed, because the Home app's rendering of them varies by iOS version: a single media tile per player (`SmartSpeaker` or `Television`) instead of separate switches.
66
+
67
+ ## Not planned
68
+
69
+ HomeKit has no way to render a library or a queue, and no vocabulary for "play the third album by that artist". The BluOS app does these properly, and a second controller with its own idea of the state is worse than none:
70
+
71
+ - ❌ Browsing, search and favourites
72
+ - ❌ Queue building and editing
73
+ - ❌ Now-playing metadata and artwork
74
+ - ❌ Player setup, streaming-service sign-in, firmware updates
75
+
76
+ ## Accessories per player
77
+
78
+ | Configuration | HomeKit service |
79
+ | --- | --- |
80
+ | `volumeSlider` | Fanv2 (default) or Lightbulb, as a 0–100 slider |
81
+ | `mute` | Switch |
82
+ | `volumePresets[]` | Switch, one per level |
83
+ | `battery` | Battery |
84
+
85
+ ## Protocol surface
86
+
87
+ The subset of the BluOS Custom Integration API this plugin uses, and the places where real hardware disagrees with the specification: [PROTOCOL.md](PROTOCOL.md).
88
+
89
+ ## Architecture
90
+
91
+ See [DEVELOPMENT.md](../DEVELOPMENT.md) for how to build, test and add a capability.