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,135 @@
1
+ {
2
+ "pluginAlias": "BluOS",
3
+ "pluginType": "platform",
4
+ "singular": true,
5
+ "customUi": true,
6
+ "customUiPath": "./homebridge-ui",
7
+ "headerDisplay": "Exposes BluOS players to HomeKit as a volume slider, a mute switch, volume-preset switches and a battery sensor. Verified on NAD and Bluesound hardware; other BluOS brands run the same firmware and are expected to work. Use **Discover Players** to find zones on your network; every field below can also be edited by hand.",
8
+ "footerDisplay": "HomeKit has no speaker volume control that the Home app renders, so the volume slider is exposed as a fan. See the [documentation](https://github.com/tbaur/homebridge-bluos#readme) for what this plugin does and does not do.",
9
+ "schema": {
10
+ "type": "object",
11
+ "required": ["name"],
12
+ "properties": {
13
+ "name": {
14
+ "title": "Platform Name",
15
+ "type": "string",
16
+ "default": "BluOS",
17
+ "description": "Shown in the Homebridge log."
18
+ },
19
+ "options": {
20
+ "title": "Options",
21
+ "type": "object",
22
+ "properties": {
23
+ "sliderService": {
24
+ "title": "Volume Slider Style",
25
+ "type": "string",
26
+ "default": "fan",
27
+ "oneOf": [
28
+ { "title": "Fan — recommended", "enum": ["fan"] },
29
+ { "title": "Lightbulb — affected by \"turn off all the lights\"", "enum": ["lightbulb"] }
30
+ ],
31
+ "description": "Both render an identical slider. A lightbulb is swept up by Siri commands aimed at lights, which can silence or max out your speakers."
32
+ },
33
+ "discoveryTimeoutSec": {
34
+ "title": "Discovery Timeout (seconds)",
35
+ "type": "integer",
36
+ "default": 5,
37
+ "minimum": 1,
38
+ "maximum": 30,
39
+ "description": "How long to listen for players. Raise it on a busy or slow network."
40
+ }
41
+ }
42
+ },
43
+ "devices": {
44
+ "title": "Players",
45
+ "type": "array",
46
+ "items": {
47
+ "type": "object",
48
+ "title": "Player",
49
+ "required": ["id", "name", "host"],
50
+ "properties": {
51
+ "id": {
52
+ "title": "Identity",
53
+ "type": "string",
54
+ "minLength": 1,
55
+ "maxLength": 128,
56
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9:._-]{0,127}$",
57
+ "description": "Stable identity, normally the MAC address and zone port. Written by discovery. Changing it detaches the accessories from their HomeKit rooms and automations."
58
+ },
59
+ "name": {
60
+ "title": "Name",
61
+ "type": "string",
62
+ "maxLength": 64,
63
+ "description": "The room's name. Accessories are named from it: \"Study\" gives \"Study Volume\", \"Study Mute\" and \"Study Battery\"."
64
+ },
65
+ "host": {
66
+ "title": "Address",
67
+ "type": "string",
68
+ "minLength": 1,
69
+ "maxLength": 254,
70
+ "pattern": "^[A-Za-z0-9]([A-Za-z0-9._-]{0,252}[A-Za-z0-9])?$",
71
+ "description": "IP address or hostname. Re-resolved automatically if the player's address changes."
72
+ },
73
+ "port": {
74
+ "title": "Port",
75
+ "type": "integer",
76
+ "default": 11000,
77
+ "minimum": 1,
78
+ "maximum": 65535,
79
+ "description": "11000 for a normal player. Multi-zone models such as the NAD CI-S2 and CI 580 use 11010, 11020 and 11030 for their extra zones."
80
+ },
81
+ "volumeSlider": {
82
+ "title": "Volume Slider",
83
+ "type": "boolean",
84
+ "default": true,
85
+ "description": "A 0-100 slider for this player's volume."
86
+ },
87
+ "sliderService": {
88
+ "title": "Slider Style Override",
89
+ "type": "string",
90
+ "oneOf": [
91
+ { "title": "Use the platform setting", "enum": [""] },
92
+ { "title": "Fan", "enum": ["fan"] },
93
+ { "title": "Lightbulb", "enum": ["lightbulb"] }
94
+ ]
95
+ },
96
+ "mute": {
97
+ "title": "Mute Switch",
98
+ "type": "boolean",
99
+ "default": false,
100
+ "description": "A switch that is on when the player is muted. Independent of the slider."
101
+ },
102
+ "battery": {
103
+ "title": "Battery Sensor",
104
+ "type": "boolean",
105
+ "default": false,
106
+ "description": "State of charge. Only for players with a battery pack fitted, such as a PULSE FLEX with the BP100."
107
+ },
108
+ "volumePresets": {
109
+ "title": "Volume Presets",
110
+ "type": "array",
111
+ "description": "Switches that set one specific level. Safer than a slider in automations, and addressable by name with Siri.",
112
+ "items": {
113
+ "type": "object",
114
+ "required": ["name", "volume"],
115
+ "properties": {
116
+ "name": {
117
+ "title": "Name",
118
+ "type": "string",
119
+ "maxLength": 64
120
+ },
121
+ "volume": {
122
+ "title": "Volume",
123
+ "type": "integer",
124
+ "minimum": 0,
125
+ "maximum": 100
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,131 @@
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 BluOS Custom Integration API client.
8
+ *
9
+ * Three scheduling rules are enforced here rather than at the call sites, so no
10
+ * caller can accidentally violate them:
11
+ *
12
+ * 1. At least one second between consecutive requests for the same resource on
13
+ * the same endpoint. API v1.7 section 2 requires this of long-polling
14
+ * clients, and phrases it as a requirement rather than advice.
15
+ * 2. At least 100 ms between control calls to one endpoint, so a HomeKit scene
16
+ * that touches several tiles at once cannot burst a player.
17
+ * 3. Writes to one chassis are serialised. A NAD CI-S2 or CI 580 exposes several
18
+ * zones on one IP; concurrent writes to `:11000` and `:11010` are writes to
19
+ * the same box. Different chassis still run in parallel.
20
+ *
21
+ * Note that a chassis and a group are different things. Serialisation above is
22
+ * per chassis, because that is one piece of hardware; grouping is a logical
23
+ * relationship between zones that may live on different chassis entirely, and it
24
+ * is expressed per write through {@link WriteScope}.
25
+ */
26
+ import type { PlayerObservation, PluginLogger } from '../types';
27
+ import { type HttpGet } from './http';
28
+ import { type VolumeResult } from './sync-status';
29
+ /** Where to reach one player zone. */
30
+ export interface Endpoint {
31
+ host: string;
32
+ port: number;
33
+ }
34
+ /**
35
+ * How far a volume write should reach.
36
+ *
37
+ * `tellSlaves` maps to the API's `tell_slaves` parameter. False confines the
38
+ * write to the addressed zone; true lets a group leader carry its followers with
39
+ * it, which is what the BluOS app does when you move a leader's slider.
40
+ *
41
+ * Defaults to false everywhere, so a caller that has not thought about grouping
42
+ * gets the conservative answer.
43
+ */
44
+ export interface WriteScope {
45
+ tellSlaves: boolean;
46
+ }
47
+ /** Injectable collaborators, so tests need neither sockets nor real clocks. */
48
+ export interface BluOSClientOptions {
49
+ log: PluginLogger;
50
+ httpGet?: HttpGet;
51
+ now?: () => number;
52
+ sleep?: (ms: number) => Promise<void>;
53
+ }
54
+ /**
55
+ * Talks to BluOS players over the LAN.
56
+ *
57
+ * One instance serves the whole fleet: the rate limits and the per-chassis write
58
+ * lock are only meaningful if every request goes through the same bookkeeping.
59
+ */
60
+ export declare class BluOSClient {
61
+ private readonly log;
62
+ private readonly httpGet;
63
+ private readonly now;
64
+ private readonly sleep;
65
+ /** Last request time, keyed by `endpoint|resource`, for the one-second rule. */
66
+ private readonly lastResourceRequest;
67
+ /** Last control call time, keyed by endpoint. */
68
+ private readonly lastControlRequest;
69
+ /** Tail of the write queue for each chassis host. */
70
+ private readonly chassisWriteQueue;
71
+ constructor(options: BluOSClientOptions);
72
+ /** Read `/SyncStatus` once, without long-polling. */
73
+ readSyncStatus(endpoint: Endpoint): Promise<PlayerObservation>;
74
+ /**
75
+ * Long-poll `/SyncStatus`, returning when the player's state changes or the
76
+ * poll window elapses.
77
+ *
78
+ * Measured on firmware 4.16.6: with a current etag the request holds for the
79
+ * full requested window (15.03 s for `timeout=15`), and with a stale etag it
80
+ * answers in 44 ms. Verified per-zone rather than per-chassis, so a sibling
81
+ * zone changing volume does not wake this poll.
82
+ */
83
+ pollSyncStatus(endpoint: Endpoint, etag: string, signal?: AbortSignal): Promise<PlayerObservation>;
84
+ /**
85
+ * Read `/Volume` without changing anything.
86
+ *
87
+ * Not on the polling path: `/SyncStatus` is the source of truth for level and
88
+ * mute, and it long-polls. This exists for the diagnostic scripts and for
89
+ * confirming what a player reports when a write result looks wrong.
90
+ */
91
+ readVolume(endpoint: Endpoint): Promise<VolumeResult>;
92
+ /**
93
+ * Set this zone's absolute level.
94
+ *
95
+ * `tell_slaves` is always sent explicitly rather than left to the firmware's
96
+ * default, because the two answers are both defensible and the caller is the
97
+ * only one that knows which applies: a tile addressing a zone that leads a
98
+ * group should move the group, and a tile addressing any other zone must move
99
+ * nothing else. See {@link WriteScope}.
100
+ *
101
+ * The player clamps the level into its own configured dB range, so the result
102
+ * is authoritative and the caller should adopt it rather than assume the
103
+ * requested value took effect.
104
+ */
105
+ setVolume(endpoint: Endpoint, level: number, scope?: WriteScope): Promise<VolumeResult>;
106
+ /**
107
+ * Mute or unmute this zone.
108
+ *
109
+ * `mute=1` mutes and `mute=0` unmutes. API v1.7's parameter table in section
110
+ * 3.1 states the opposite, but sections 3.4 and 3.5, the response attribute
111
+ * tables, and firmware 4.16.6 all agree with the mapping used here: writing
112
+ * `mute=1` produced `mute="1" muteVolume="72"` on a real player.
113
+ */
114
+ setMute(endpoint: Endpoint, muted: boolean, scope?: WriteScope): Promise<VolumeResult>;
115
+ private control;
116
+ /**
117
+ * Queue work behind anything already writing to this chassis.
118
+ *
119
+ * The queue tail is stored per host; failures are swallowed for the purpose of
120
+ * chaining so one rejected write does not poison later ones, while the
121
+ * original promise still rejects for its own caller.
122
+ */
123
+ private withChassisLock;
124
+ private respectControlRate;
125
+ /**
126
+ * Wait out the API's one-second minimum gap between consecutive requests for
127
+ * the same resource on the same endpoint.
128
+ */
129
+ private respectResourceGap;
130
+ private get;
131
+ }
@@ -0,0 +1,226 @@
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 BluOS Custom Integration API client.
9
+ *
10
+ * Three scheduling rules are enforced here rather than at the call sites, so no
11
+ * caller can accidentally violate them:
12
+ *
13
+ * 1. At least one second between consecutive requests for the same resource on
14
+ * the same endpoint. API v1.7 section 2 requires this of long-polling
15
+ * clients, and phrases it as a requirement rather than advice.
16
+ * 2. At least 100 ms between control calls to one endpoint, so a HomeKit scene
17
+ * that touches several tiles at once cannot burst a player.
18
+ * 3. Writes to one chassis are serialised. A NAD CI-S2 or CI 580 exposes several
19
+ * zones on one IP; concurrent writes to `:11000` and `:11010` are writes to
20
+ * the same box. Different chassis still run in parallel.
21
+ *
22
+ * Note that a chassis and a group are different things. Serialisation above is
23
+ * per chassis, because that is one piece of hardware; grouping is a logical
24
+ * relationship between zones that may live on different chassis entirely, and it
25
+ * is expressed per write through {@link WriteScope}.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.BluOSClient = void 0;
29
+ const settings_1 = require("../settings");
30
+ const errors_1 = require("../utils/errors");
31
+ const timing_1 = require("../utils/timing");
32
+ const validators_1 = require("../utils/validators");
33
+ const http_1 = require("./http");
34
+ const identity_1 = require("./identity");
35
+ const sync_status_1 = require("./sync-status");
36
+ /**
37
+ * Talks to BluOS players over the LAN.
38
+ *
39
+ * One instance serves the whole fleet: the rate limits and the per-chassis write
40
+ * lock are only meaningful if every request goes through the same bookkeeping.
41
+ */
42
+ class BluOSClient {
43
+ log;
44
+ httpGet;
45
+ now;
46
+ sleep;
47
+ /** Last request time, keyed by `endpoint|resource`, for the one-second rule. */
48
+ lastResourceRequest = new Map();
49
+ /** Last control call time, keyed by endpoint. */
50
+ lastControlRequest = new Map();
51
+ /** Tail of the write queue for each chassis host. */
52
+ chassisWriteQueue = new Map();
53
+ constructor(options) {
54
+ this.log = options.log;
55
+ this.httpGet = options.httpGet ?? http_1.httpGet;
56
+ this.now = options.now ?? Date.now;
57
+ this.sleep = options.sleep ?? timing_1.sleep;
58
+ }
59
+ /** Read `/SyncStatus` once, without long-polling. */
60
+ async readSyncStatus(endpoint) {
61
+ const body = await this.get({
62
+ endpoint,
63
+ resource: 'SyncStatus',
64
+ query: {},
65
+ totalTimeoutMs: settings_1.STATUS_TIMEOUT_MS,
66
+ });
67
+ return (0, sync_status_1.parseSyncStatus)(body, (0, identity_1.formatEndpoint)(endpoint.host, endpoint.port));
68
+ }
69
+ /**
70
+ * Long-poll `/SyncStatus`, returning when the player's state changes or the
71
+ * poll window elapses.
72
+ *
73
+ * Measured on firmware 4.16.6: with a current etag the request holds for the
74
+ * full requested window (15.03 s for `timeout=15`), and with a stale etag it
75
+ * answers in 44 ms. Verified per-zone rather than per-chassis, so a sibling
76
+ * zone changing volume does not wake this poll.
77
+ */
78
+ async pollSyncStatus(endpoint, etag, signal) {
79
+ const body = await this.get({
80
+ endpoint,
81
+ resource: 'SyncStatus',
82
+ query: { timeout: String(settings_1.LONG_POLL_SEC), etag },
83
+ totalTimeoutMs: settings_1.LONG_POLL_SEC * 1_000 + settings_1.LONG_POLL_READ_SLACK_MS,
84
+ signal,
85
+ });
86
+ return (0, sync_status_1.parseSyncStatus)(body, (0, identity_1.formatEndpoint)(endpoint.host, endpoint.port));
87
+ }
88
+ /**
89
+ * Read `/Volume` without changing anything.
90
+ *
91
+ * Not on the polling path: `/SyncStatus` is the source of truth for level and
92
+ * mute, and it long-polls. This exists for the diagnostic scripts and for
93
+ * confirming what a player reports when a write result looks wrong.
94
+ */
95
+ async readVolume(endpoint) {
96
+ const body = await this.get({
97
+ endpoint,
98
+ resource: 'Volume',
99
+ query: {},
100
+ totalTimeoutMs: settings_1.STATUS_TIMEOUT_MS,
101
+ });
102
+ return (0, sync_status_1.parseVolume)(body);
103
+ }
104
+ /**
105
+ * Set this zone's absolute level.
106
+ *
107
+ * `tell_slaves` is always sent explicitly rather than left to the firmware's
108
+ * default, because the two answers are both defensible and the caller is the
109
+ * only one that knows which applies: a tile addressing a zone that leads a
110
+ * group should move the group, and a tile addressing any other zone must move
111
+ * nothing else. See {@link WriteScope}.
112
+ *
113
+ * The player clamps the level into its own configured dB range, so the result
114
+ * is authoritative and the caller should adopt it rather than assume the
115
+ * requested value took effect.
116
+ */
117
+ async setVolume(endpoint, level, scope = { tellSlaves: false }) {
118
+ if (!Number.isInteger(level) || level < settings_1.VOLUME_MIN || level > settings_1.VOLUME_MAX) {
119
+ throw new RangeError(`volume must be an integer ${settings_1.VOLUME_MIN}-${settings_1.VOLUME_MAX}, got ${level}`);
120
+ }
121
+ return this.control(endpoint, {
122
+ level: String(level),
123
+ tell_slaves: scope.tellSlaves ? '1' : '0',
124
+ });
125
+ }
126
+ /**
127
+ * Mute or unmute this zone.
128
+ *
129
+ * `mute=1` mutes and `mute=0` unmutes. API v1.7's parameter table in section
130
+ * 3.1 states the opposite, but sections 3.4 and 3.5, the response attribute
131
+ * tables, and firmware 4.16.6 all agree with the mapping used here: writing
132
+ * `mute=1` produced `mute="1" muteVolume="72"` on a real player.
133
+ */
134
+ async setMute(endpoint, muted, scope = { tellSlaves: false }) {
135
+ return this.control(endpoint, {
136
+ mute: muted ? '1' : '0',
137
+ tell_slaves: scope.tellSlaves ? '1' : '0',
138
+ });
139
+ }
140
+ async control(endpoint, query) {
141
+ // Serialised per chassis: zones of a multi-zone player share one box.
142
+ return this.withChassisLock(endpoint.host, async () => {
143
+ await this.respectControlRate(endpoint);
144
+ const body = await this.get({
145
+ endpoint,
146
+ resource: 'Volume',
147
+ query,
148
+ totalTimeoutMs: settings_1.CONTROL_TIMEOUT_MS,
149
+ });
150
+ return (0, sync_status_1.parseVolume)(body);
151
+ });
152
+ }
153
+ /**
154
+ * Queue work behind anything already writing to this chassis.
155
+ *
156
+ * The queue tail is stored per host; failures are swallowed for the purpose of
157
+ * chaining so one rejected write does not poison later ones, while the
158
+ * original promise still rejects for its own caller.
159
+ */
160
+ async withChassisLock(host, work) {
161
+ const previous = this.chassisWriteQueue.get(host) ?? Promise.resolve();
162
+ const run = previous.then(work, work);
163
+ this.chassisWriteQueue.set(host, run.catch(() => undefined));
164
+ try {
165
+ return await run;
166
+ }
167
+ finally {
168
+ // Only clear if nothing else queued behind us in the meantime.
169
+ if (this.chassisWriteQueue.get(host) === run) {
170
+ this.chassisWriteQueue.delete(host);
171
+ }
172
+ }
173
+ }
174
+ async respectControlRate(endpoint) {
175
+ const key = (0, identity_1.formatEndpoint)(endpoint.host, endpoint.port);
176
+ const last = this.lastControlRequest.get(key);
177
+ const elapsed = last === undefined ? Number.POSITIVE_INFINITY : this.now() - last;
178
+ if (elapsed < settings_1.CONTROL_RATE_LIMIT_MS) {
179
+ await this.sleep(settings_1.CONTROL_RATE_LIMIT_MS - elapsed);
180
+ }
181
+ this.lastControlRequest.set(key, this.now());
182
+ }
183
+ /**
184
+ * Wait out the API's one-second minimum gap between consecutive requests for
185
+ * the same resource on the same endpoint.
186
+ */
187
+ async respectResourceGap(key) {
188
+ const last = this.lastResourceRequest.get(key);
189
+ const elapsed = last === undefined ? Number.POSITIVE_INFINITY : this.now() - last;
190
+ if (elapsed < settings_1.SAME_RESOURCE_MIN_GAP_MS) {
191
+ await this.sleep(settings_1.SAME_RESOURCE_MIN_GAP_MS - elapsed);
192
+ }
193
+ this.lastResourceRequest.set(key, this.now());
194
+ }
195
+ async get(request) {
196
+ const { endpoint, resource, query, totalTimeoutMs, signal } = request;
197
+ const target = (0, identity_1.formatEndpoint)(endpoint.host, endpoint.port);
198
+ // The same check the settings page and the configuration validator apply.
199
+ // Shared rather than restated: two copies of the one defence against a
200
+ // configuration or cache value altering a request URL would eventually
201
+ // disagree, and the disagreement would be the security regression.
202
+ if (!(0, validators_1.isValidHost)(endpoint.host)) {
203
+ throw new errors_1.ConnectionError(`refusing to contact an invalid host: ${JSON.stringify(endpoint.host)}`);
204
+ }
205
+ await this.respectResourceGap(`${target}|${resource}`);
206
+ const search = new URLSearchParams(query).toString();
207
+ const url = `http://${target}/${resource}${search.length > 0 ? `?${search}` : ''}`;
208
+ // Safe to log unsanitised, and the only place in the plugin where wire data
209
+ // reaches a log line without passing through forLog: the query carries a
210
+ // player-supplied etag, which URLSearchParams percent-encodes, so no control
211
+ // character can reach the log. Templating the query by hand would reintroduce
212
+ // log injection.
213
+ this.log.debug(`GET ${url}`);
214
+ const response = await this.httpGet(url, {
215
+ connectTimeoutMs: settings_1.CONNECT_TIMEOUT_MS,
216
+ totalTimeoutMs,
217
+ maxBytes: settings_1.MAX_XML_BYTES,
218
+ ...(signal === undefined ? {} : { signal }),
219
+ });
220
+ if (response.status !== 200) {
221
+ throw new errors_1.ProtocolError(`${resource} on ${target} answered HTTP ${response.status}`);
222
+ }
223
+ return response.body;
224
+ }
225
+ }
226
+ exports.BluOSClient = BluOSClient;
@@ -0,0 +1,136 @@
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 mDNS discovery of BluOS zones.
8
+ *
9
+ * Two service types are browsed, because a multi-zone chassis advertises each
10
+ * zone separately: `_musc._tcp` for primary zones and `_musp._tcp` for the
11
+ * secondaries of a CI-S2 or CI 580. API v1.7 section 1 is explicit that the port
12
+ * "should be discovered by use of the MDNS protocol using the services musc.tcp
13
+ * and musp.tcp", and only the SRV record knows whether a zone is on 11000,
14
+ * 11010, 11020 or 11030.
15
+ *
16
+ * The appendix also describes LSDP, a UDP-broadcast alternative that Lenbrook
17
+ * says is more reliable than multicast on consumer networks. It is not
18
+ * implemented: on the network this plugin was developed against, mDNS returned
19
+ * every zone with full metadata while LSDP returned nothing at all, twice —
20
+ * first with a global broadcast and then with per-interface subnet broadcasts
21
+ * and an all-classes query. LSDP also never carries a zone's port, so it could
22
+ * not replace this path even where it does answer. Manual entry by address
23
+ * covers networks where multicast is filtered.
24
+ *
25
+ * Nothing here is trusted: an endpoint only counts as a player once it has
26
+ * answered `/SyncStatus`, which is also where authoritative identity comes from.
27
+ */
28
+ import type { Answer, Question } from 'dns-packet';
29
+ import type { DiscoveredPlayer, PluginLogger } from '../types';
30
+ import type { BluOSClient, Endpoint } from './client';
31
+ /** An mDNS response packet, narrowed to the parts used here. */
32
+ export interface MdnsPacket {
33
+ answers?: Answer[];
34
+ additionals?: Answer[];
35
+ }
36
+ /** Where a packet came from. Used as a fallback when no A record is offered. */
37
+ export interface MdnsRemote {
38
+ address: string;
39
+ }
40
+ /**
41
+ * The slice of `multicast-dns` this module needs.
42
+ *
43
+ * Declared structurally so a test can supply a fake without a socket, and so a
44
+ * future move to a different mDNS implementation does not ripple outwards.
45
+ */
46
+ export interface MdnsSession {
47
+ on(event: 'response', listener: (packet: MdnsPacket, remote: MdnsRemote) => void): void;
48
+ /**
49
+ * `error` is fatal for the session (the socket could not be bound); `warning`
50
+ * covers recoverable trouble such as an undecodable packet. Both must be
51
+ * subscribed, because an unheard `error` on an EventEmitter is thrown, and a
52
+ * throw here would take Homebridge down with it.
53
+ */
54
+ on(event: 'error' | 'warning', listener: (error: Error) => void): void;
55
+ query(request: {
56
+ questions: Question[];
57
+ }): void;
58
+ destroy(callback?: () => void): void;
59
+ }
60
+ /** Creates an mDNS session. */
61
+ export type MdnsFactory = () => MdnsSession;
62
+ /** Injectable collaborators for {@link BluOSDiscovery}. */
63
+ export interface DiscoveryOptions {
64
+ log: PluginLogger;
65
+ client: BluOSClient;
66
+ createMdns?: MdnsFactory;
67
+ }
68
+ /** Discovers BluOS zones and confirms them against the API. */
69
+ export declare class BluOSDiscovery {
70
+ private readonly log;
71
+ private readonly client;
72
+ private readonly createMdns;
73
+ /** Live browse windows, so a shutdown does not have to wait one out. */
74
+ private readonly openWindows;
75
+ private cancelled;
76
+ constructor(options: DiscoveryOptions);
77
+ /**
78
+ * Abandon every browse in flight and refuse any that start afterwards.
79
+ *
80
+ * A browse window is a referenced timer holding a bound multicast socket, so
81
+ * without this a shutdown during an address re-resolution keeps the Homebridge
82
+ * process alive for the rest of the window — up to 30 s per unreachable
83
+ * player. One-way by design: it is only called when the platform is stopping.
84
+ */
85
+ cancelAll(): void;
86
+ /**
87
+ * Browse for zones and return the ones that answer `/SyncStatus`.
88
+ *
89
+ * Failures are logged and skipped rather than thrown: a single unreachable
90
+ * player must not deny the user the rest of the fleet.
91
+ */
92
+ discover(timeoutSec: number): Promise<DiscoveredPlayer[]>;
93
+ /**
94
+ * Verify candidates a few at a time.
95
+ *
96
+ * Verification opens a connection and holds it for up to the status timeout,
97
+ * and nothing throttles distinct endpoints against each other, so verifying
98
+ * every advertisement at once would let whatever answered the browse decide how
99
+ * many sockets this plugin opens at one moment.
100
+ */
101
+ private verifyAll;
102
+ /**
103
+ * Find the current address of an already-known player.
104
+ *
105
+ * Called at launch and after a player goes silent, so a DHCP lease change does
106
+ * not require the user to re-save configuration.
107
+ */
108
+ resolveEndpoint(playerId: string, timeoutSec: number): Promise<Endpoint | undefined>;
109
+ /** Probe one endpoint directly, for the UI's manual-entry path. */
110
+ probe(endpoint: Endpoint): Promise<DiscoveredPlayer | undefined>;
111
+ private verify;
112
+ /**
113
+ * Turn service instances into addressable endpoints.
114
+ *
115
+ * A zone is only usable once its SRV record (for the port) and an IPv4 address
116
+ * are both known. The address comes from an A record when one was offered, and
117
+ * otherwise from the responder's own source address, which for a player
118
+ * advertising its own service is the same machine.
119
+ */
120
+ private toEndpoints;
121
+ /** Collect mDNS records for the configured window. */
122
+ private browse;
123
+ }
124
+ /**
125
+ * Default mDNS session.
126
+ *
127
+ * `loopback: false` stops us from answering our own queries, and the require is
128
+ * deferred so that importing this module — which the config UI does — cannot
129
+ * fail merely because a socket could not be bound.
130
+ *
131
+ * Interfaces are enumerated up front because `multicast-dns` does the same from
132
+ * inside its `listening` handler, where a throw would surface as an uncaught
133
+ * exception instead of a failed call. Doing it here keeps the failure catchable
134
+ * by {@link BluOSDiscovery.browse}, which degrades to "discovery unavailable".
135
+ */
136
+ export declare const defaultMdnsFactory: MdnsFactory;