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,402 @@
|
|
|
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 mDNS discovery of BluOS zones.
|
|
9
|
+
*
|
|
10
|
+
* Two service types are browsed, because a multi-zone chassis advertises each
|
|
11
|
+
* zone separately: `_musc._tcp` for primary zones and `_musp._tcp` for the
|
|
12
|
+
* secondaries of a CI-S2 or CI 580. API v1.7 section 1 is explicit that the port
|
|
13
|
+
* "should be discovered by use of the MDNS protocol using the services musc.tcp
|
|
14
|
+
* and musp.tcp", and only the SRV record knows whether a zone is on 11000,
|
|
15
|
+
* 11010, 11020 or 11030.
|
|
16
|
+
*
|
|
17
|
+
* The appendix also describes LSDP, a UDP-broadcast alternative that Lenbrook
|
|
18
|
+
* says is more reliable than multicast on consumer networks. It is not
|
|
19
|
+
* implemented: on the network this plugin was developed against, mDNS returned
|
|
20
|
+
* every zone with full metadata while LSDP returned nothing at all, twice —
|
|
21
|
+
* first with a global broadcast and then with per-interface subnet broadcasts
|
|
22
|
+
* and an all-classes query. LSDP also never carries a zone's port, so it could
|
|
23
|
+
* not replace this path even where it does answer. Manual entry by address
|
|
24
|
+
* covers networks where multicast is filtered.
|
|
25
|
+
*
|
|
26
|
+
* Nothing here is trusted: an endpoint only counts as a player once it has
|
|
27
|
+
* answered `/SyncStatus`, which is also where authoritative identity comes from.
|
|
28
|
+
*/
|
|
29
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
30
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
31
|
+
};
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.defaultMdnsFactory = exports.BluOSDiscovery = void 0;
|
|
34
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
35
|
+
const settings_1 = require("../settings");
|
|
36
|
+
const errors_1 = require("../utils/errors");
|
|
37
|
+
const timing_1 = require("../utils/timing");
|
|
38
|
+
const identity_1 = require("./identity");
|
|
39
|
+
/** Re-query schedule, in milliseconds, to survive dropped multicast packets. */
|
|
40
|
+
const QUERY_SCHEDULE_MS = [0, 400, 1_200];
|
|
41
|
+
/** When to chase missing SRV, TXT and A records for known instances. */
|
|
42
|
+
const FOLLOW_UP_SCHEDULE_MS = [700, 1_600];
|
|
43
|
+
const IPV4 = /^(\d{1,3}\.){3}\d{1,3}$/;
|
|
44
|
+
function isSrv(record) {
|
|
45
|
+
return record.type === 'SRV';
|
|
46
|
+
}
|
|
47
|
+
function isTxt(record) {
|
|
48
|
+
return record.type === 'TXT';
|
|
49
|
+
}
|
|
50
|
+
function isPtr(record) {
|
|
51
|
+
return record.type === 'PTR';
|
|
52
|
+
}
|
|
53
|
+
function isA(record) {
|
|
54
|
+
return record.type === 'A';
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Decode TXT record data into key/value pairs.
|
|
58
|
+
*
|
|
59
|
+
* BluOS primaries advertise `model`, `version`, `mac` and `zs`; secondary zones
|
|
60
|
+
* omit `mac`, which is why identity is confirmed from `/SyncStatus` instead.
|
|
61
|
+
*/
|
|
62
|
+
function decodeTxt(data) {
|
|
63
|
+
// Null-prototyped: the keys come from an unauthenticated multicast packet, so
|
|
64
|
+
// a record of `__proto__=x` must land as an ordinary key rather than reaching
|
|
65
|
+
// the prototype chain.
|
|
66
|
+
const entries = Object.create(null);
|
|
67
|
+
const items = Array.isArray(data) ? data : [data];
|
|
68
|
+
for (const item of items) {
|
|
69
|
+
const text = typeof item === 'string' ? item : Buffer.from(item).toString('utf8');
|
|
70
|
+
const separator = text.indexOf('=');
|
|
71
|
+
if (separator <= 0) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
entries[text.slice(0, separator).toLowerCase()] = text.slice(separator + 1);
|
|
75
|
+
}
|
|
76
|
+
return entries;
|
|
77
|
+
}
|
|
78
|
+
/** Discovers BluOS zones and confirms them against the API. */
|
|
79
|
+
class BluOSDiscovery {
|
|
80
|
+
log;
|
|
81
|
+
client;
|
|
82
|
+
createMdns;
|
|
83
|
+
/** Live browse windows, so a shutdown does not have to wait one out. */
|
|
84
|
+
openWindows = new Set();
|
|
85
|
+
cancelled = false;
|
|
86
|
+
constructor(options) {
|
|
87
|
+
this.log = options.log;
|
|
88
|
+
this.client = options.client;
|
|
89
|
+
this.createMdns = options.createMdns ?? exports.defaultMdnsFactory;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Abandon every browse in flight and refuse any that start afterwards.
|
|
93
|
+
*
|
|
94
|
+
* A browse window is a referenced timer holding a bound multicast socket, so
|
|
95
|
+
* without this a shutdown during an address re-resolution keeps the Homebridge
|
|
96
|
+
* process alive for the rest of the window — up to 30 s per unreachable
|
|
97
|
+
* player. One-way by design: it is only called when the platform is stopping.
|
|
98
|
+
*/
|
|
99
|
+
cancelAll() {
|
|
100
|
+
this.cancelled = true;
|
|
101
|
+
for (const window of this.openWindows) {
|
|
102
|
+
window.interrupt();
|
|
103
|
+
}
|
|
104
|
+
this.openWindows.clear();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Browse for zones and return the ones that answer `/SyncStatus`.
|
|
108
|
+
*
|
|
109
|
+
* Failures are logged and skipped rather than thrown: a single unreachable
|
|
110
|
+
* player must not deny the user the rest of the fleet.
|
|
111
|
+
*/
|
|
112
|
+
async discover(timeoutSec) {
|
|
113
|
+
const candidates = await this.browse(timeoutSec);
|
|
114
|
+
const endpoints = this.toEndpoints(candidates);
|
|
115
|
+
this.log.debug(`discovery: ${endpoints.length} candidate endpoint(s) to verify`);
|
|
116
|
+
const verified = await this.verifyAll(endpoints);
|
|
117
|
+
return verified.sort((left, right) => left.name.localeCompare(right.name));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Verify candidates a few at a time.
|
|
121
|
+
*
|
|
122
|
+
* Verification opens a connection and holds it for up to the status timeout,
|
|
123
|
+
* and nothing throttles distinct endpoints against each other, so verifying
|
|
124
|
+
* every advertisement at once would let whatever answered the browse decide how
|
|
125
|
+
* many sockets this plugin opens at one moment.
|
|
126
|
+
*/
|
|
127
|
+
async verifyAll(entries) {
|
|
128
|
+
const found = [];
|
|
129
|
+
let next = 0;
|
|
130
|
+
const take = () => {
|
|
131
|
+
const entry = entries[next];
|
|
132
|
+
next += 1;
|
|
133
|
+
return entry;
|
|
134
|
+
};
|
|
135
|
+
const worker = async () => {
|
|
136
|
+
for (let entry = take(); entry !== undefined; entry = take()) {
|
|
137
|
+
if (this.cancelled) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const player = await this.verify(entry);
|
|
141
|
+
if (player !== undefined) {
|
|
142
|
+
found.push(player);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const width = Math.min(settings_1.DISCOVERY_VERIFY_CONCURRENCY, entries.length);
|
|
147
|
+
await Promise.all(Array.from({ length: width }, async () => worker()));
|
|
148
|
+
return found;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Find the current address of an already-known player.
|
|
152
|
+
*
|
|
153
|
+
* Called at launch and after a player goes silent, so a DHCP lease change does
|
|
154
|
+
* not require the user to re-save configuration.
|
|
155
|
+
*/
|
|
156
|
+
async resolveEndpoint(playerId, timeoutSec) {
|
|
157
|
+
const players = await this.discover(timeoutSec);
|
|
158
|
+
const match = players.find((player) => player.id === playerId);
|
|
159
|
+
return match === undefined ? undefined : { host: match.host, port: match.port };
|
|
160
|
+
}
|
|
161
|
+
/** Probe one endpoint directly, for the UI's manual-entry path. */
|
|
162
|
+
async probe(endpoint) {
|
|
163
|
+
return this.verify({ endpoint, txt: {} });
|
|
164
|
+
}
|
|
165
|
+
async verify(entry) {
|
|
166
|
+
const target = (0, identity_1.formatEndpoint)(entry.endpoint.host, entry.endpoint.port);
|
|
167
|
+
try {
|
|
168
|
+
const observation = await this.client.readSyncStatus(entry.endpoint);
|
|
169
|
+
// A multi-zone secondary reports its chassis NIC with a port suffix, so the
|
|
170
|
+
// MAC alone is ambiguous; the zone's own port disambiguates it.
|
|
171
|
+
const mac = (0, identity_1.parseMac)(observation.mac)?.mac ?? (0, identity_1.parseMac)(entry.txt.mac)?.mac;
|
|
172
|
+
const player = {
|
|
173
|
+
id: mac === undefined ? '' : (0, identity_1.makePlayerId)(mac, entry.endpoint.port),
|
|
174
|
+
name: observation.name.length > 0 ? observation.name : target,
|
|
175
|
+
host: entry.endpoint.host,
|
|
176
|
+
port: entry.endpoint.port,
|
|
177
|
+
fixedVolume: observation.fixedVolume,
|
|
178
|
+
hasBattery: observation.battery !== undefined,
|
|
179
|
+
};
|
|
180
|
+
if (observation.brand !== undefined) {
|
|
181
|
+
player.brand = observation.brand;
|
|
182
|
+
}
|
|
183
|
+
if (observation.model !== undefined) {
|
|
184
|
+
player.model = observation.model;
|
|
185
|
+
}
|
|
186
|
+
if (observation.modelName !== undefined) {
|
|
187
|
+
player.modelName = observation.modelName;
|
|
188
|
+
}
|
|
189
|
+
if (observation.firmware !== undefined) {
|
|
190
|
+
player.firmware = observation.firmware;
|
|
191
|
+
}
|
|
192
|
+
if (mac !== undefined) {
|
|
193
|
+
player.mac = mac;
|
|
194
|
+
}
|
|
195
|
+
return player;
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
this.log.debug(`discovery: ${target} did not answer SyncStatus: ${(0, errors_1.describeError)(error)}`);
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Turn service instances into addressable endpoints.
|
|
204
|
+
*
|
|
205
|
+
* A zone is only usable once its SRV record (for the port) and an IPv4 address
|
|
206
|
+
* are both known. The address comes from an A record when one was offered, and
|
|
207
|
+
* otherwise from the responder's own source address, which for a player
|
|
208
|
+
* advertising its own service is the same machine.
|
|
209
|
+
*/
|
|
210
|
+
toEndpoints(candidates) {
|
|
211
|
+
const seen = new Set();
|
|
212
|
+
const results = [];
|
|
213
|
+
for (const candidate of candidates.instances) {
|
|
214
|
+
const fromA = candidates.addresses.get(candidate.target.toLowerCase());
|
|
215
|
+
const host = fromA ?? candidate.responder;
|
|
216
|
+
if (host === undefined || !IPV4.test(host)) {
|
|
217
|
+
this.log.debug(`discovery: no IPv4 address for ${candidate.instance} (target ${candidate.target})`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const key = (0, identity_1.formatEndpoint)(host, candidate.port);
|
|
221
|
+
if (seen.has(key)) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
seen.add(key);
|
|
225
|
+
if (results.length >= settings_1.MAX_DISCOVERY_CANDIDATES) {
|
|
226
|
+
this.log.warn(`discovery found more than ${settings_1.MAX_DISCOVERY_CANDIDATES} candidate endpoints; `
|
|
227
|
+
+ 'verifying the first ones only. Add the player by address in the plugin settings '
|
|
228
|
+
+ 'if it is missing');
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
results.push({ endpoint: { host, port: candidate.port }, txt: candidate.txt });
|
|
232
|
+
}
|
|
233
|
+
return results;
|
|
234
|
+
}
|
|
235
|
+
/** Collect mDNS records for the configured window. */
|
|
236
|
+
async browse(timeoutSec) {
|
|
237
|
+
const services = [settings_1.MDNS_SERVICE_PRIMARY, settings_1.MDNS_SERVICE_SECONDARY];
|
|
238
|
+
const instanceService = new Map();
|
|
239
|
+
const srv = new Map();
|
|
240
|
+
const txt = new Map();
|
|
241
|
+
const addresses = new Map();
|
|
242
|
+
const responders = new Map();
|
|
243
|
+
if (this.cancelled) {
|
|
244
|
+
return { instances: [], addresses };
|
|
245
|
+
}
|
|
246
|
+
let session;
|
|
247
|
+
try {
|
|
248
|
+
session = this.createMdns();
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
this.log.warn(`discovery unavailable: ${(0, errors_1.describeError)(error)}`);
|
|
252
|
+
return { instances: [], addresses };
|
|
253
|
+
}
|
|
254
|
+
const windowMs = Math.max(1, timeoutSec) * 1_000;
|
|
255
|
+
const browseWindow = (0, timing_1.interruptibleSleep)(windowMs);
|
|
256
|
+
this.openWindows.add(browseWindow);
|
|
257
|
+
/** Belongs to one of the browsed service types, so worth remembering. */
|
|
258
|
+
const isBrowsedInstance = (name) => services.some((service) => name.endsWith(`.${service}`));
|
|
259
|
+
let capacityWarned = false;
|
|
260
|
+
/** Record into a capped map, so a chatty or hostile segment cannot grow the heap. */
|
|
261
|
+
const remember = (map, key, value) => {
|
|
262
|
+
if (!map.has(key) && map.size >= settings_1.MAX_DISCOVERY_RECORDS) {
|
|
263
|
+
if (!capacityWarned) {
|
|
264
|
+
capacityWarned = true;
|
|
265
|
+
this.log.debug(`discovery: ignoring mDNS records past ${settings_1.MAX_DISCOVERY_RECORDS} of one kind`);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
map.set(key, value);
|
|
270
|
+
};
|
|
271
|
+
// A dead socket will never answer, so stop waiting on it. Discovery then
|
|
272
|
+
// returns empty and the caller falls back to configured addresses.
|
|
273
|
+
session.on('error', (error) => {
|
|
274
|
+
this.log.warn(`mDNS unavailable, discovery cannot run: ${(0, errors_1.describeError)(error)}`);
|
|
275
|
+
browseWindow.interrupt();
|
|
276
|
+
});
|
|
277
|
+
session.on('warning', (error) => {
|
|
278
|
+
this.log.debug(`mDNS warning: ${(0, errors_1.describeError)(error)}`);
|
|
279
|
+
});
|
|
280
|
+
session.on('response', (packet, remote) => {
|
|
281
|
+
const records = [...(packet.answers ?? []), ...(packet.additionals ?? [])];
|
|
282
|
+
for (const record of records) {
|
|
283
|
+
// SRV and TXT are matched on the service suffix rather than against the
|
|
284
|
+
// instances seen so far, because a single packet may carry the SRV ahead
|
|
285
|
+
// of the PTR that introduces it. Records for unrelated services on the
|
|
286
|
+
// segment are dropped rather than accumulated.
|
|
287
|
+
if (isPtr(record) && services.includes(record.name)) {
|
|
288
|
+
remember(instanceService, record.data, record.name);
|
|
289
|
+
remember(responders, record.data, remote.address);
|
|
290
|
+
}
|
|
291
|
+
else if (isSrv(record) && isBrowsedInstance(record.name)) {
|
|
292
|
+
remember(srv, record.name, { port: record.data.port, target: record.data.target });
|
|
293
|
+
remember(responders, record.name, remote.address);
|
|
294
|
+
}
|
|
295
|
+
else if (isTxt(record) && isBrowsedInstance(record.name)) {
|
|
296
|
+
remember(txt, record.name, decodeTxt(record.data));
|
|
297
|
+
}
|
|
298
|
+
else if (isA(record)) {
|
|
299
|
+
remember(addresses, record.name.toLowerCase(), record.data);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
const ask = (questions) => {
|
|
304
|
+
if (questions.length === 0) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
session.query({ questions });
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
this.log.debug(`mDNS query failed: ${(0, errors_1.describeError)(error)}`);
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
const timers = [];
|
|
315
|
+
const schedule = (delayMs, work) => {
|
|
316
|
+
if (delayMs >= windowMs) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
timers.push(setTimeout(work, delayMs));
|
|
320
|
+
};
|
|
321
|
+
for (const delay of QUERY_SCHEDULE_MS) {
|
|
322
|
+
schedule(delay, () => {
|
|
323
|
+
ask(services.map((service) => ({ name: service, type: 'PTR' })));
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
for (const delay of FOLLOW_UP_SCHEDULE_MS) {
|
|
327
|
+
schedule(delay, () => {
|
|
328
|
+
const questions = [];
|
|
329
|
+
for (const instance of instanceService.keys()) {
|
|
330
|
+
if (!srv.has(instance)) {
|
|
331
|
+
questions.push({ name: instance, type: 'SRV' });
|
|
332
|
+
}
|
|
333
|
+
if (!txt.has(instance)) {
|
|
334
|
+
questions.push({ name: instance, type: 'TXT' });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
for (const entry of srv.values()) {
|
|
338
|
+
if (!addresses.has(entry.target.toLowerCase())) {
|
|
339
|
+
questions.push({ name: entry.target, type: 'A' });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
ask(questions);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
await browseWindow.promise;
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
this.openWindows.delete(browseWindow);
|
|
350
|
+
browseWindow.interrupt();
|
|
351
|
+
for (const timer of timers) {
|
|
352
|
+
clearTimeout(timer);
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
session.destroy();
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
this.log.debug(`mDNS teardown failed: ${(0, errors_1.describeError)(error)}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const instances = [];
|
|
362
|
+
for (const [instance, service] of instanceService) {
|
|
363
|
+
const record = srv.get(instance);
|
|
364
|
+
if (record === undefined) {
|
|
365
|
+
this.log.debug(`discovery: no SRV record for ${instance}`);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const candidate = {
|
|
369
|
+
instance,
|
|
370
|
+
service,
|
|
371
|
+
port: record.port > 0 ? record.port : settings_1.DEFAULT_BLUOS_PORT,
|
|
372
|
+
target: record.target,
|
|
373
|
+
txt: txt.get(instance) ?? {},
|
|
374
|
+
};
|
|
375
|
+
const responder = responders.get(instance);
|
|
376
|
+
if (responder !== undefined) {
|
|
377
|
+
candidate.responder = responder;
|
|
378
|
+
}
|
|
379
|
+
instances.push(candidate);
|
|
380
|
+
}
|
|
381
|
+
return { instances, addresses };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
exports.BluOSDiscovery = BluOSDiscovery;
|
|
385
|
+
/**
|
|
386
|
+
* Default mDNS session.
|
|
387
|
+
*
|
|
388
|
+
* `loopback: false` stops us from answering our own queries, and the require is
|
|
389
|
+
* deferred so that importing this module — which the config UI does — cannot
|
|
390
|
+
* fail merely because a socket could not be bound.
|
|
391
|
+
*
|
|
392
|
+
* Interfaces are enumerated up front because `multicast-dns` does the same from
|
|
393
|
+
* inside its `listening` handler, where a throw would surface as an uncaught
|
|
394
|
+
* exception instead of a failed call. Doing it here keeps the failure catchable
|
|
395
|
+
* by {@link BluOSDiscovery.browse}, which degrades to "discovery unavailable".
|
|
396
|
+
*/
|
|
397
|
+
const defaultMdnsFactory = () => {
|
|
398
|
+
node_os_1.default.networkInterfaces();
|
|
399
|
+
const makeMdns = require('multicast-dns');
|
|
400
|
+
return makeMdns({ loopback: false, reuseAddr: true });
|
|
401
|
+
};
|
|
402
|
+
exports.defaultMdnsFactory = defaultMdnsFactory;
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
* @fileoverview Minimal HTTP GET against a BluOS player.
|
|
8
|
+
*
|
|
9
|
+
* Built on `node:http` rather than `fetch` for two reasons.
|
|
10
|
+
*
|
|
11
|
+
* Separate connect and total timeouts. A long-poll legitimately takes 100
|
|
12
|
+
* seconds, but a powered-off player must fail in a couple of seconds rather
|
|
13
|
+
* than holding a slot for the full poll window. `AbortSignal.timeout` only
|
|
14
|
+
* expresses one deadline, so `fetch` cannot say "connect fast, then wait".
|
|
15
|
+
*
|
|
16
|
+
* No connection reuse, deliberately. Measured on firmware 4.16.6, a control
|
|
17
|
+
* call to a player completes in ~31 ms while that same endpoint has a
|
|
18
|
+
* `/SyncStatus` long-poll held open — but only because the two used different
|
|
19
|
+
* TCP connections. Sharing one keep-alive socket per endpoint would queue the
|
|
20
|
+
* write behind the held poll, which is the problem other BluOS clients solve
|
|
21
|
+
* with an explicit "drop the hold before writing" dance. Opening a fresh
|
|
22
|
+
* connection each time removes the failure mode instead of managing it, and on
|
|
23
|
+
* a LAN a handshake every hundred seconds costs nothing.
|
|
24
|
+
*/
|
|
25
|
+
/** A completed HTTP response. */
|
|
26
|
+
export interface HttpResponse {
|
|
27
|
+
status: number;
|
|
28
|
+
body: string;
|
|
29
|
+
}
|
|
30
|
+
/** Per-request timing and size limits. */
|
|
31
|
+
export interface HttpGetOptions {
|
|
32
|
+
/** Deadline for establishing the TCP connection. */
|
|
33
|
+
connectTimeoutMs: number;
|
|
34
|
+
/** Deadline for the whole exchange, including a long-poll hold. */
|
|
35
|
+
totalTimeoutMs: number;
|
|
36
|
+
/** Largest response body accepted, in bytes. */
|
|
37
|
+
maxBytes: number;
|
|
38
|
+
/** Cancels the request; used to drop long-polls at shutdown. */
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
}
|
|
41
|
+
/** Performs one GET. Injectable so tests never touch a socket. */
|
|
42
|
+
export type HttpGet = (url: string, options: HttpGetOptions) => Promise<HttpResponse>;
|
|
43
|
+
/**
|
|
44
|
+
* GET a URL with independent connect and total deadlines.
|
|
45
|
+
*
|
|
46
|
+
* Rejects with {@link ConnectionError} for anything that prevented an answer,
|
|
47
|
+
* and {@link ProtocolError} when the answer arrived but was unusable (over the
|
|
48
|
+
* size cap). Redirects are not followed: BluOS control endpoints do not issue
|
|
49
|
+
* them, and blindly following one would let a compromised player redirect us
|
|
50
|
+
* at an arbitrary host.
|
|
51
|
+
*/
|
|
52
|
+
export declare const httpGet: HttpGet;
|
package/dist/api/http.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
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 Minimal HTTP GET against a BluOS player.
|
|
9
|
+
*
|
|
10
|
+
* Built on `node:http` rather than `fetch` for two reasons.
|
|
11
|
+
*
|
|
12
|
+
* Separate connect and total timeouts. A long-poll legitimately takes 100
|
|
13
|
+
* seconds, but a powered-off player must fail in a couple of seconds rather
|
|
14
|
+
* than holding a slot for the full poll window. `AbortSignal.timeout` only
|
|
15
|
+
* expresses one deadline, so `fetch` cannot say "connect fast, then wait".
|
|
16
|
+
*
|
|
17
|
+
* No connection reuse, deliberately. Measured on firmware 4.16.6, a control
|
|
18
|
+
* call to a player completes in ~31 ms while that same endpoint has a
|
|
19
|
+
* `/SyncStatus` long-poll held open — but only because the two used different
|
|
20
|
+
* TCP connections. Sharing one keep-alive socket per endpoint would queue the
|
|
21
|
+
* write behind the held poll, which is the problem other BluOS clients solve
|
|
22
|
+
* with an explicit "drop the hold before writing" dance. Opening a fresh
|
|
23
|
+
* connection each time removes the failure mode instead of managing it, and on
|
|
24
|
+
* a LAN a handshake every hundred seconds costs nothing.
|
|
25
|
+
*/
|
|
26
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
27
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
28
|
+
};
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.httpGet = void 0;
|
|
31
|
+
const node_http_1 = __importDefault(require("node:http"));
|
|
32
|
+
const errors_1 = require("../utils/errors");
|
|
33
|
+
/**
|
|
34
|
+
* GET a URL with independent connect and total deadlines.
|
|
35
|
+
*
|
|
36
|
+
* Rejects with {@link ConnectionError} for anything that prevented an answer,
|
|
37
|
+
* and {@link ProtocolError} when the answer arrived but was unusable (over the
|
|
38
|
+
* size cap). Redirects are not followed: BluOS control endpoints do not issue
|
|
39
|
+
* them, and blindly following one would let a compromised player redirect us
|
|
40
|
+
* at an arbitrary host.
|
|
41
|
+
*/
|
|
42
|
+
const httpGet = (url, options) => {
|
|
43
|
+
const { connectTimeoutMs, totalTimeoutMs, maxBytes, signal } = options;
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
if (signal?.aborted === true) {
|
|
46
|
+
reject(new errors_1.ConnectionError('request aborted before it started'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
let settled = false;
|
|
50
|
+
let onAbort;
|
|
51
|
+
// Held in a container because `cleanup` closes over it before the timer that
|
|
52
|
+
// fills it in can be created: the timer's callback needs `fail`, and `fail`
|
|
53
|
+
// needs `cleanup`.
|
|
54
|
+
const timers = {};
|
|
55
|
+
// `agent: false` gives this request its own connection and closes it after.
|
|
56
|
+
const request = node_http_1.default.get(url, { agent: false }, (response) => {
|
|
57
|
+
const chunks = [];
|
|
58
|
+
let received = 0;
|
|
59
|
+
response.on('data', (chunk) => {
|
|
60
|
+
received += chunk.length;
|
|
61
|
+
if (received > maxBytes) {
|
|
62
|
+
fail(new errors_1.ProtocolError(`response exceeds ${maxBytes} bytes`));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
chunks.push(chunk);
|
|
66
|
+
});
|
|
67
|
+
response.on('end', () => {
|
|
68
|
+
finish({
|
|
69
|
+
status: response.statusCode ?? 0,
|
|
70
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
response.on('error', (error) => {
|
|
74
|
+
fail(new errors_1.ConnectionError('response stream failed', { cause: error }));
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
const cleanup = () => {
|
|
78
|
+
if (timers.total !== undefined) {
|
|
79
|
+
clearTimeout(timers.total);
|
|
80
|
+
}
|
|
81
|
+
if (onAbort !== undefined) {
|
|
82
|
+
signal?.removeEventListener('abort', onAbort);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const finish = (response) => {
|
|
86
|
+
if (settled) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
settled = true;
|
|
90
|
+
cleanup();
|
|
91
|
+
resolve(response);
|
|
92
|
+
};
|
|
93
|
+
const fail = (error) => {
|
|
94
|
+
if (settled) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
settled = true;
|
|
98
|
+
cleanup();
|
|
99
|
+
request.destroy();
|
|
100
|
+
reject(error);
|
|
101
|
+
};
|
|
102
|
+
// Applies until the socket connects, then swapped for the total deadline.
|
|
103
|
+
request.setTimeout(connectTimeoutMs, () => {
|
|
104
|
+
fail(new errors_1.ConnectionError(`connect timed out after ${connectTimeoutMs}ms`));
|
|
105
|
+
});
|
|
106
|
+
request.on('socket', (socket) => {
|
|
107
|
+
const onConnect = () => {
|
|
108
|
+
// Connected: inactivity is now expected, because a long-poll is idle by
|
|
109
|
+
// design. The total deadline below is what bounds the request from here.
|
|
110
|
+
request.setTimeout(0);
|
|
111
|
+
socket.setNoDelay(true);
|
|
112
|
+
};
|
|
113
|
+
if (socket.connecting) {
|
|
114
|
+
socket.once('connect', onConnect);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
onConnect();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
request.on('error', (error) => {
|
|
121
|
+
fail(new errors_1.ConnectionError('request failed', { cause: error }));
|
|
122
|
+
});
|
|
123
|
+
// Referenced on purpose, and always cleared in `cleanup`: a caller is
|
|
124
|
+
// awaiting this request, so the process must not be free to exit under it.
|
|
125
|
+
timers.total = setTimeout(() => {
|
|
126
|
+
fail(new errors_1.ConnectionError(`request timed out after ${totalTimeoutMs}ms`));
|
|
127
|
+
}, totalTimeoutMs);
|
|
128
|
+
if (signal !== undefined) {
|
|
129
|
+
onAbort = () => {
|
|
130
|
+
fail(new errors_1.ConnectionError('request aborted'));
|
|
131
|
+
};
|
|
132
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
exports.httpGet = httpGet;
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
* @fileoverview Stable player identity.
|
|
8
|
+
*
|
|
9
|
+
* Accessory identity must survive a DHCP lease change, so it is built from the
|
|
10
|
+
* chassis MAC and the zone's control port, never from an address. Verified on
|
|
11
|
+
* firmware 4.16.6 against a NAD CI-S2, where the primary zone reports
|
|
12
|
+
* `mac="90:56:82:0A:00:01"` and its secondary zone reports the same NIC with a
|
|
13
|
+
* port suffix, `mac="90:56:82:0A:00:01:11010"`.
|
|
14
|
+
*/
|
|
15
|
+
import type { AccessoryKind, ResolvedAccessory } from '../types';
|
|
16
|
+
/** A MAC split into its chassis part and, when present, a zone-port suffix. */
|
|
17
|
+
export interface ParsedMac {
|
|
18
|
+
/** Six upper-case octets joined by colons. */
|
|
19
|
+
mac: string;
|
|
20
|
+
/** Zone control port carried in the MAC suffix, when the player reported one. */
|
|
21
|
+
suffixPort?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse a MAC as reported by `/SyncStatus` or an mDNS TXT record.
|
|
25
|
+
*
|
|
26
|
+
* Accepts three shapes seen in the field: six colon-separated octets, six
|
|
27
|
+
* octets plus a numeric zone-port suffix (multi-zone secondaries), and twelve
|
|
28
|
+
* bare hex digits (mDNS TXT records report `mac=9056820A0002`).
|
|
29
|
+
*
|
|
30
|
+
* Returns undefined rather than guessing, so a caller can fall back to a
|
|
31
|
+
* persisted identity instead of inventing an unstable one.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseMac(value: unknown): ParsedMac | undefined;
|
|
34
|
+
/** Normalise a MAC to six upper-case colon-separated octets, dropping any suffix. */
|
|
35
|
+
export declare function normalizeMac(value: unknown): string | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Build a player id from a chassis MAC and the zone's control port.
|
|
38
|
+
*
|
|
39
|
+
* The result deliberately matches the shape a multi-zone secondary already
|
|
40
|
+
* reports for itself (`90:56:82:0A:00:01:11010`), so ids read the same whether
|
|
41
|
+
* they were derived here or observed on the wire.
|
|
42
|
+
*/
|
|
43
|
+
export declare function makePlayerId(mac: string, port: number): string;
|
|
44
|
+
/**
|
|
45
|
+
* Generate a persisted identity for a player that reports no usable MAC.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately random rather than derived from name or address: both change,
|
|
48
|
+
* and a changing id silently orphans the accessory. The discovery UI writes
|
|
49
|
+
* this into configuration once and it is stable from then on.
|
|
50
|
+
*/
|
|
51
|
+
export declare function makeGeneratedPlayerId(): string;
|
|
52
|
+
/** True when a value is safe to use as a player id and accessory UUID seed. */
|
|
53
|
+
export declare function isValidPlayerId(value: unknown): value is string;
|
|
54
|
+
/** Canonical `host:port` string for an endpoint. */
|
|
55
|
+
export declare function formatEndpoint(host: string, port?: number): string;
|
|
56
|
+
/**
|
|
57
|
+
* The identity of an accessory: what it does, for which player.
|
|
58
|
+
*
|
|
59
|
+
* Two accessories with the same key are the same accessory, and one whose key
|
|
60
|
+
* changes is a different accessory. Contains no address, so re-addressing a
|
|
61
|
+
* player leaves every UUID untouched.
|
|
62
|
+
*/
|
|
63
|
+
export declare function accessoryIdentityKey(accessory: {
|
|
64
|
+
kind: AccessoryKind;
|
|
65
|
+
deviceId: string;
|
|
66
|
+
volume?: number;
|
|
67
|
+
}): string;
|
|
68
|
+
/** True when a cached context describes the same accessory as a resolved one. */
|
|
69
|
+
export declare function hasAccessoryIdentity(context: {
|
|
70
|
+
kind?: unknown;
|
|
71
|
+
deviceId?: unknown;
|
|
72
|
+
volume?: unknown;
|
|
73
|
+
}, accessory: ResolvedAccessory): boolean;
|