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,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;
@@ -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;