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
package/dist/poller.js ADDED
@@ -0,0 +1,300 @@
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 One long-poll loop per player zone.
9
+ *
10
+ * The loop holds a `/SyncStatus?timeout=100&etag=…` request open and is woken by
11
+ * the player the moment that zone's state changes. Verified against firmware
12
+ * 4.16.6: with a current etag the request holds for exactly the requested window,
13
+ * with a stale etag it answers in 44 ms, and the etag is per-zone — a sibling zone
14
+ * on the same chassis changing volume does not wake this poll. That last point is
15
+ * why one loop per zone is correct rather than wasteful.
16
+ *
17
+ * The set/poll race is handled with a generation counter. A response that was
18
+ * computed before a local write cannot be distinguished from a fresh one by its
19
+ * contents, so any response that arrives across a write is discarded and replaced
20
+ * by an immediate re-read. That costs one cheap request — a stale etag answers
21
+ * immediately — and removes the class of bug where a HomeKit slider springs back
22
+ * to its old position a moment after being moved.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.DevicePoller = void 0;
26
+ const settings_1 = require("./settings");
27
+ const utils_1 = require("./utils");
28
+ /**
29
+ * Marks a read that this plugin cancelled on purpose.
30
+ *
31
+ * A write, a refresh request or an address change drops the poll in flight, and
32
+ * the resulting rejection is indistinguishable from a network failure by its
33
+ * type. Counting it as one would be actively harmful: three writes in a row
34
+ * would trip the unreachable threshold and show every accessory for that player
35
+ * as No Response, and each write would pay a backoff delay before the state it
36
+ * just changed could be read back.
37
+ */
38
+ class PollInterrupted extends Error {
39
+ constructor() {
40
+ super('poll interrupted');
41
+ this.name = 'PollInterrupted';
42
+ }
43
+ }
44
+ /** Drives one player zone. */
45
+ class DevicePoller {
46
+ options;
47
+ currentEndpoint;
48
+ observation;
49
+ /** Opaque long-poll token. Cleared to force a plain read next time round. */
50
+ etag;
51
+ /** Incremented by every local write, to invalidate responses that predate it. */
52
+ generation = 0;
53
+ consecutiveFailures = 0;
54
+ lastRediscoveryAt = 0;
55
+ stopped = false;
56
+ loop;
57
+ abort;
58
+ /** Resolves the current backoff sleep early when a refresh is requested. */
59
+ wake;
60
+ constructor(options) {
61
+ this.options = options;
62
+ this.currentEndpoint = options.endpoint;
63
+ }
64
+ get endpoint() {
65
+ return this.currentEndpoint;
66
+ }
67
+ get lastObservation() {
68
+ return this.observation;
69
+ }
70
+ /** Point this poller at a new address, dropping any request in flight. */
71
+ setEndpoint(endpoint) {
72
+ if (endpoint.host === this.currentEndpoint.host && endpoint.port === this.currentEndpoint.port) {
73
+ return;
74
+ }
75
+ this.options.log.info(`${(0, utils_1.forLog)(this.options.displayName)} moved to ${endpoint.host}:${endpoint.port}`);
76
+ this.currentEndpoint = endpoint;
77
+ this.etag = undefined;
78
+ this.options.onEndpointChanged?.(endpoint);
79
+ this.interrupt();
80
+ }
81
+ /** Begin polling. Safe to call more than once. */
82
+ start() {
83
+ if (this.loop !== undefined) {
84
+ return;
85
+ }
86
+ this.stopped = false;
87
+ // The loop is deliberately not awaited by its caller, so its promise needs a
88
+ // rejection handler of its own. Without one, an unexpected throw anywhere in
89
+ // the loop becomes an unhandled rejection, which on Node 20+ terminates the
90
+ // process — taking Homebridge and every other plugin down with it.
91
+ this.loop = this.run().catch((error) => {
92
+ this.options.log.error(`${this.label()} polling stopped after an unexpected error, so its state will `
93
+ + `no longer update until Homebridge restarts: ${(0, utils_1.describeError)(error)}`);
94
+ });
95
+ }
96
+ /** Stop polling and drop any request in flight. */
97
+ async stop() {
98
+ this.stopped = true;
99
+ this.interrupt();
100
+ const loop = this.loop;
101
+ this.loop = undefined;
102
+ if (loop !== undefined) {
103
+ await loop;
104
+ }
105
+ }
106
+ /**
107
+ * Adopt the state a write reported, and invalidate anything in flight.
108
+ *
109
+ * The player's answer to a write is authoritative — it clamps the level into
110
+ * its own configured range — so this is both the fastest and the most accurate
111
+ * update available.
112
+ */
113
+ adoptWriteResult(result) {
114
+ this.generation += 1;
115
+ const previous = this.observation;
116
+ const merged = {
117
+ ...(previous ?? {
118
+ name: this.options.displayName,
119
+ fixedVolume: false,
120
+ muted: false,
121
+ syncRole: 'standalone',
122
+ }),
123
+ fixedVolume: result.fixedVolume,
124
+ muted: result.muted,
125
+ };
126
+ if (result.level !== undefined) {
127
+ merged.volume = result.level;
128
+ }
129
+ if (result.muteVolume !== undefined) {
130
+ merged.muteVolume = result.muteVolume;
131
+ }
132
+ else if (!result.muted) {
133
+ // The firmware only publishes muteVolume while muted, so carrying the old
134
+ // one forward past an unmute would let a stale pre-mute level win the
135
+ // restore decision in restoreLevelFrom and jump the room loud.
136
+ delete merged.muteVolume;
137
+ }
138
+ if (result.db !== undefined) {
139
+ merged.db = result.db;
140
+ }
141
+ // The etag from a /Volume response is not a /SyncStatus etag, so the next
142
+ // poll starts from a plain read rather than a token from the wrong resource.
143
+ this.etag = undefined;
144
+ this.observation = merged;
145
+ this.options.onObservation(merged, 'post-set');
146
+ this.interrupt();
147
+ }
148
+ /** Cancel the request in flight and wake any backoff sleep. */
149
+ interrupt() {
150
+ this.abort?.abort();
151
+ this.abort = undefined;
152
+ this.wake?.();
153
+ this.wake = undefined;
154
+ }
155
+ async run() {
156
+ let first = true;
157
+ while (!this.stopped) {
158
+ const generation = this.generation;
159
+ try {
160
+ const observation = await this.readOnce();
161
+ if (this.stopped) {
162
+ return;
163
+ }
164
+ if (this.generation !== generation) {
165
+ // A write landed while this was in flight, so the response may describe
166
+ // the state before it. Discard and re-read rather than risk showing the
167
+ // user their change being undone.
168
+ this.options.log.debug(`${(0, utils_1.forLog)(this.options.displayName)} discarding a poll response that crossed a write`);
169
+ this.etag = undefined;
170
+ continue;
171
+ }
172
+ this.consecutiveFailures = 0;
173
+ this.etag = observation.etag;
174
+ this.observation = observation;
175
+ this.options.onObservation(observation, first ? 'startup' : 'poll');
176
+ first = false;
177
+ }
178
+ catch (error) {
179
+ if (this.stopped) {
180
+ return;
181
+ }
182
+ if (error instanceof PollInterrupted) {
183
+ // Our own doing, so it is not a failure: start a fresh read at once.
184
+ this.etag = undefined;
185
+ continue;
186
+ }
187
+ await this.handleFailure(error);
188
+ }
189
+ }
190
+ }
191
+ async readOnce() {
192
+ const etag = this.etag;
193
+ if (etag === undefined) {
194
+ return this.options.client.readSyncStatus(this.currentEndpoint);
195
+ }
196
+ const abort = new AbortController();
197
+ this.abort = abort;
198
+ try {
199
+ return await this.options.client.pollSyncStatus(this.currentEndpoint, etag, abort.signal);
200
+ }
201
+ catch (error) {
202
+ throw abort.signal.aborted ? new PollInterrupted() : error;
203
+ }
204
+ finally {
205
+ if (this.abort === abort) {
206
+ this.abort = undefined;
207
+ }
208
+ }
209
+ }
210
+ async handleFailure(error) {
211
+ this.consecutiveFailures += 1;
212
+ // A failed long-poll leaves the token untrustworthy; the next attempt starts
213
+ // from a plain read so a stale etag cannot mask a recovered player.
214
+ this.etag = undefined;
215
+ if (this.consecutiveFailures === 1) {
216
+ this.options.log.debug(`${this.label()} poll failed: ${(0, utils_1.describeError)(error)}`);
217
+ }
218
+ // Two separate policies, deliberately read from two constants: how long
219
+ // before HomeKit is told the truth, and how long before we go looking for a
220
+ // new address.
221
+ if (this.consecutiveFailures >= settings_1.POLL_FAILURES_BEFORE_UNKNOWN) {
222
+ this.options.onUnreachable(error);
223
+ }
224
+ if (this.consecutiveFailures >= settings_1.FAILURES_BEFORE_REDISCOVERY) {
225
+ const moved = await this.tryRediscovery();
226
+ if (moved) {
227
+ // The address is known to have changed, so there is nothing to wait for:
228
+ // sleeping out a backoff that may already be at its one-minute ceiling
229
+ // would delay recovery for no reason.
230
+ this.consecutiveFailures = 0;
231
+ return;
232
+ }
233
+ }
234
+ if (this.stopped) {
235
+ return;
236
+ }
237
+ const exponent = Math.min(this.consecutiveFailures - 1, 10);
238
+ const ceiling = Math.min(settings_1.POLL_BACKOFF_MAX_MS, settings_1.POLL_BACKOFF_BASE_MS * 2 ** exponent);
239
+ // Full jitter, so a rebooting fleet does not synchronise its retries into a
240
+ // burst against the network.
241
+ const delay = Math.round(ceiling * (0.5 + Math.random() * 0.5));
242
+ await this.sleepInterruptibly(delay);
243
+ }
244
+ /**
245
+ * Look for a new address for this player. True when the address changed.
246
+ *
247
+ * Rate-limited: a player that is switched off would otherwise trigger a
248
+ * multicast sweep on every backoff cycle.
249
+ */
250
+ async tryRediscovery() {
251
+ const now = Date.now();
252
+ if (this.stopped || now - this.lastRediscoveryAt < settings_1.REDISCOVERY_MIN_INTERVAL_MS) {
253
+ return false;
254
+ }
255
+ this.lastRediscoveryAt = now;
256
+ try {
257
+ const found = await this.options.resolveEndpoint(this.options.deviceId);
258
+ // A sweep can outlive a shutdown request, and re-pointing a stopping poller
259
+ // would persist an address change nobody asked for.
260
+ if (this.stopped || found === undefined) {
261
+ return false;
262
+ }
263
+ const moved = found.host !== this.currentEndpoint.host
264
+ || found.port !== this.currentEndpoint.port;
265
+ this.setEndpoint(found);
266
+ return moved;
267
+ }
268
+ catch (error) {
269
+ this.options.log.debug(`${this.label()} address lookup failed: ${(0, utils_1.describeError)(error)}`);
270
+ return false;
271
+ }
272
+ }
273
+ /**
274
+ * Name plus stable id, for a log line someone has to act on.
275
+ *
276
+ * Two players can share a display name — the configuration validator warns
277
+ * about it rather than refusing it — so a failure line naming only the name
278
+ * cannot be traced back to a player.
279
+ */
280
+ label() {
281
+ return `${(0, utils_1.forLog)(this.options.displayName)} [${(0, utils_1.forLog)(this.options.deviceId)}]`;
282
+ }
283
+ /**
284
+ * Sleep, but return early if a write, refresh or shutdown arrives.
285
+ *
286
+ * The timer is cleared rather than left to expire, so stopping the plugin does
287
+ * not have to wait out a backoff delay that no longer matters.
288
+ */
289
+ async sleepInterruptibly(ms) {
290
+ const pending = (0, utils_1.interruptibleSleep)(ms);
291
+ this.wake = () => pending.interrupt();
292
+ try {
293
+ await pending.promise;
294
+ }
295
+ finally {
296
+ this.wake = undefined;
297
+ }
298
+ }
299
+ }
300
+ exports.DevicePoller = DevicePoller;
@@ -0,0 +1,184 @@
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 Plugin-wide constants and the plugin version reader.
8
+ *
9
+ * Numbers that came from the BluOS Custom Integration API v1.7 specification or
10
+ * from measurements against real players cite their source, so a future reader
11
+ * can tell a vendor requirement apart from a judgement call.
12
+ */
13
+ import type { PluginLogger } from './types';
14
+ /** npm package name. Must match `package.json` `name` for Homebridge to load us. */
15
+ export declare const PLUGIN_NAME = "homebridge-bluos";
16
+ /** Platform alias used in `config.json` and `config.schema.json`. */
17
+ export declare const PLATFORM_NAME = "BluOS";
18
+ /**
19
+ * Namespace for generated accessory UUIDs.
20
+ *
21
+ * Deliberately does not include a host or port: seeding accessory identity with
22
+ * an address means a DHCP lease change re-creates every accessory and destroys
23
+ * the rooms, scenes and automations the user built on top of them.
24
+ */
25
+ export declare const UUID_PREFIX = "homebridge-bluos:";
26
+ /** Reported when `package.json` cannot be read. */
27
+ export declare const UNKNOWN_PLUGIN_VERSION = "0.0.0";
28
+ /** Manufacturer shown when `/SyncStatus` does not report a brand. */
29
+ export declare const DEFAULT_BRAND = "BluOS";
30
+ /** Model shown when `/SyncStatus` does not report one. */
31
+ export declare const DEFAULT_MODEL = "BluOS Player";
32
+ /** Control port for a primary player (API v1.7 section 1). */
33
+ export declare const DEFAULT_BLUOS_PORT = 11000;
34
+ /**
35
+ * Ports the specification documents for multi-zone chassis.
36
+ *
37
+ * API v1.7 section 1: the CI 580 exposes four streamer nodes on one IP, using
38
+ * 11000, 11010, 11020 and 11030. The CI-S2 uses 11000 and 11010. Ports outside
39
+ * this set are accepted but warned about, because mDNS SRV records are the
40
+ * authority on which port a zone actually listens to.
41
+ */
42
+ export declare const DOCUMENTED_BLUOS_PORTS: readonly number[];
43
+ /** Lowest port number accepted anywhere in configuration. */
44
+ export declare const MIN_PORT = 1;
45
+ /** Highest port number accepted anywhere in configuration. */
46
+ export declare const MAX_PORT = 65535;
47
+ /**
48
+ * Duration passed as `/SyncStatus?timeout=`.
49
+ *
50
+ * The specification recommends 180 s for `/SyncStatus`, and forbids anything
51
+ * faster than 10 s. 100 s is inside that envelope and halves the worst-case
52
+ * delay before an unplugged player's socket read times out, which is how a
53
+ * silent disappearance (as opposed to a connection reset) gets noticed.
54
+ */
55
+ export declare const LONG_POLL_SEC = 100;
56
+ /** Read headroom beyond the player's own long-poll timeout. */
57
+ export declare const LONG_POLL_READ_SLACK_MS = 5000;
58
+ /**
59
+ * Minimum gap between two consecutive requests for the same resource.
60
+ *
61
+ * API v1.7 section 2 makes this a requirement, not a suggestion: "a client must
62
+ * not make two consecutive requests for the same resource less than one second
63
+ * apart, even if the first request returns in less than one second".
64
+ */
65
+ export declare const SAME_RESOURCE_MIN_GAP_MS = 1000;
66
+ /** Connect timeout. Short so a powered-off player cannot stall startup. */
67
+ export declare const CONNECT_TIMEOUT_MS = 2500;
68
+ /** Total timeout for a control call (`/Volume?level=`, `/Volume?mute=`). */
69
+ export declare const CONTROL_TIMEOUT_MS = 5000;
70
+ /** Total timeout for a plain, non-long-poll status read. */
71
+ export declare const STATUS_TIMEOUT_MS = 6000;
72
+ /** Minimum spacing between control calls to one endpoint. */
73
+ export declare const CONTROL_RATE_LIMIT_MS = 100;
74
+ /** First reconnect delay after a failed poll. Doubles up to the ceiling. */
75
+ export declare const POLL_BACKOFF_BASE_MS = 2000;
76
+ /** Ceiling for poll backoff. */
77
+ export declare const POLL_BACKOFF_MAX_MS = 60000;
78
+ /** Consecutive poll failures before an accessory reports No Response. */
79
+ export declare const POLL_FAILURES_BEFORE_UNKNOWN = 3;
80
+ /** How long before a still-failing player is warned about again. */
81
+ export declare const POLL_FAILURE_REWARN_MS = 3600000;
82
+ /**
83
+ * How long a HomeKit write may block before HAP gives up on it.
84
+ *
85
+ * HAP-NodeJS warns at `Accessory.TIMEOUT_WARNING` (3 s) and abandons the write
86
+ * at 9 s total, returning `OPERATION_TIMED_OUT` and discarding whatever the
87
+ * handler eventually returns. A set therefore has to answer well inside that
88
+ * window and finish any slower work in the background.
89
+ */
90
+ export declare const HOMEKIT_WRITE_BUDGET_MS = 2500;
91
+ /** Level restored when the slider is switched on and no previous level is known. */
92
+ export declare const DEFAULT_RESTORE_VOLUME = 20;
93
+ /**
94
+ * Window for coalescing the pair of writes HomeKit sends when a slider moves
95
+ * from off.
96
+ *
97
+ * Dragging a Fanv2 up from zero produces an `Active` write immediately followed
98
+ * by a `RotationSpeed` write. Acting on both would set the restore level and then
99
+ * the requested level, which the user hears as a jump. Waiting briefly lets the
100
+ * second write supersede the first, so one value reaches the player. Short enough
101
+ * to be imperceptible next to the LAN round trip.
102
+ */
103
+ export declare const SLIDER_COALESCE_MS = 150;
104
+ /** Lowest BluOS volume level. */
105
+ export declare const VOLUME_MIN = 0;
106
+ /** Highest BluOS volume level. */
107
+ export declare const VOLUME_MAX = 100;
108
+ /**
109
+ * `volume="-1"` means the player's output level is fixed.
110
+ *
111
+ * API v1.7 section 2.2: "-1 means fixed volume". Such a player must not be
112
+ * given a volume slider; writing a level to it is meaningless.
113
+ */
114
+ export declare const FIXED_VOLUME_SENTINEL = -1;
115
+ /**
116
+ * `db="-100"` means silence, and is not a real output level.
117
+ *
118
+ * Measured on firmware 4.16.6: muting reports `volume="0" db="-100"` alongside
119
+ * `muteVolume` and `muteDb` carrying the pre-mute values. Note that writing
120
+ * `level=0` reports the same `db="-100"` with no `muteVolume`, so this value
121
+ * cannot be used to detect mute — see `readMuted` in `api/sync-status.ts`.
122
+ */
123
+ export declare const MUTED_DB_SENTINEL = -100;
124
+ /**
125
+ * Largest `/SyncStatus` body accepted.
126
+ *
127
+ * Real responses measure a few hundred bytes; a fully grouped CI 580 is still
128
+ * far under a kilobyte. 128 KiB is generous while keeping a hostile or
129
+ * malfunctioning endpoint from growing the heap.
130
+ */
131
+ export declare const MAX_XML_BYTES = 131072;
132
+ /** Deepest element nesting accepted. */
133
+ export declare const MAX_XML_DEPTH = 16;
134
+ /** Most elements accepted in one document. */
135
+ export declare const MAX_XML_ELEMENTS = 2000;
136
+ /** Most attributes accepted on one element. */
137
+ export declare const MAX_XML_ATTRIBUTES = 64;
138
+ /** mDNS service type advertised by primary players. */
139
+ export declare const MDNS_SERVICE_PRIMARY = "_musc._tcp.local";
140
+ /**
141
+ * mDNS service type advertised by secondary zones of a multi-zone chassis.
142
+ *
143
+ * API v1.7 appendix 13.1 maps this to LSDP class 0x0003, "BluOS Player
144
+ * (secondary in multi-zone players such as the CI580)".
145
+ */
146
+ export declare const MDNS_SERVICE_SECONDARY = "_musp._tcp.local";
147
+ /** Default discovery window, in seconds. */
148
+ export declare const DEFAULT_DISCOVERY_TIMEOUT_SEC = 5;
149
+ /** Shortest configurable discovery window. */
150
+ export declare const MIN_DISCOVERY_TIMEOUT_SEC = 1;
151
+ /** Longest configurable discovery window. */
152
+ export declare const MAX_DISCOVERY_TIMEOUT_SEC = 30;
153
+ /** Consecutive poll failures before the platform tries to re-resolve an address. */
154
+ export declare const FAILURES_BEFORE_REDISCOVERY = 3;
155
+ /** Minimum gap between address re-resolution attempts. */
156
+ export declare const REDISCOVERY_MIN_INTERVAL_MS = 60000;
157
+ /**
158
+ * Most records of one kind kept from a browse window.
159
+ *
160
+ * Anything on the segment can answer a multicast query, and a browse window can
161
+ * be as long as 30 s, so the maps that accumulate advertisements need a ceiling
162
+ * they cannot be talked past. A household fleet uses a handful of entries.
163
+ */
164
+ export declare const MAX_DISCOVERY_RECORDS = 256;
165
+ /**
166
+ * Most candidate endpoints verified from one browse.
167
+ *
168
+ * Verification opens a real connection per candidate, so the count of
169
+ * advertisements must not decide how many sockets this plugin opens.
170
+ */
171
+ export declare const MAX_DISCOVERY_CANDIDATES = 64;
172
+ /** Candidate endpoints verified at once. Bounds concurrent sockets during a sweep. */
173
+ export declare const DISCOVERY_VERIFY_CONCURRENCY = 6;
174
+ /** Longest untrusted string interpolated into a log line. */
175
+ export declare const MAX_LOG_FIELD_LENGTH = 100;
176
+ /** Longest accepted HomeKit display name. */
177
+ export declare const MAX_NAME_LENGTH = 64;
178
+ /**
179
+ * Read this plugin's version from `package.json`.
180
+ *
181
+ * Reported to HomeKit as FirmwareRevision, which makes the version visible in
182
+ * the Home app and therefore in bug reports.
183
+ */
184
+ export declare function readPluginVersion(log?: PluginLogger): string;