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,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
|
+
}
|
package/docs/FEATURES.md
ADDED
|
@@ -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.
|