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,489 @@
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 The platform: configuration, accessory lifecycle, orchestration.
9
+ *
10
+ * Two policies here matter more than the mechanics.
11
+ *
12
+ * Accessories are adopted, never replaced. Identity is derived from the player's
13
+ * MAC and zone port and never from its address, so a DHCP lease change leaves
14
+ * every UUID untouched. When a cached accessory carries the right identity in its
15
+ * context but a different UUID — the situation an identity-scheme change would
16
+ * create — it is adopted rather than orphaned, because losing an accessory takes
17
+ * the user's rooms, scenes and automations with it.
18
+ *
19
+ * A broken configuration disables the platform instead of deleting anything. The
20
+ * accessories stay registered and report No Response, which is recoverable; a
21
+ * plugin that unregisters accessories when it cannot parse its own settings
22
+ * destroys work the user cannot get back.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.BluOSPlatform = void 0;
26
+ const client_1 = require("./api/client");
27
+ const discovery_1 = require("./api/discovery");
28
+ const identity_1 = require("./api/identity");
29
+ const devices_1 = require("./devices");
30
+ const poller_1 = require("./poller");
31
+ const settings_1 = require("./settings");
32
+ const utils_1 = require("./utils");
33
+ /** Delay between starting successive pollers, so a fleet does not start as a burst. */
34
+ const POLLER_STAGGER_MS = 120;
35
+ /** The BluOS dynamic platform. */
36
+ class BluOSPlatform {
37
+ log;
38
+ client;
39
+ pluginVersion;
40
+ api;
41
+ config;
42
+ discovery;
43
+ /** Accessories restored from disk, by UUID. */
44
+ restored = new Map();
45
+ /** Live accessories, by UUID. */
46
+ active = new Map();
47
+ /** Accessory handlers grouped by the player they belong to. */
48
+ handlers = new Map();
49
+ pollers = new Map();
50
+ devices = [];
51
+ discoveryTimeoutSec;
52
+ /** True when configuration could not be used; nothing is polled. */
53
+ disabled = false;
54
+ shuttingDown = false;
55
+ /** Warnings raised while resolving options in the constructor, logged at start. */
56
+ discoveryWarnings = [];
57
+ /** Pending poller starts, tracked so a shutdown can clear them. */
58
+ staggerTimers = new Set();
59
+ /** The launch address sweep, tracked so a shutdown can wait for it to end. */
60
+ launchSweep;
61
+ constructor(log, config, api) {
62
+ this.log = log;
63
+ this.config = config;
64
+ this.api = api;
65
+ this.pluginVersion = (0, settings_1.readPluginVersion)(log);
66
+ this.client = new client_1.BluOSClient({ log });
67
+ this.discovery = new discovery_1.BluOSDiscovery({ log, client: this.client });
68
+ // Warnings are collected rather than logged here: the constructor runs before
69
+ // Homebridge has finished wiring logging for the platform, and a clamped or
70
+ // unreadable value the user never hears about is a support call.
71
+ this.discoveryTimeoutSec = (0, utils_1.resolveDiscoveryTimeoutSec)(config.options?.discoveryTimeoutSec, this.discoveryWarnings);
72
+ this.api.on('didFinishLaunching', () => {
73
+ // Startup is synchronous up to the point where polling begins, so a
74
+ // configuration problem is reported before Homebridge finishes launching.
75
+ // Anything slower — address correction, the poll loops themselves — is
76
+ // started in the background from within.
77
+ try {
78
+ this.start();
79
+ }
80
+ catch (error) {
81
+ this.log.error(`BluOS failed to start: ${(0, utils_1.describeError)(error)}`);
82
+ // The stack only at debug level: a startup fault this unexpected is a bug
83
+ // in the plugin, and the report is useless without one.
84
+ this.log.debug((0, utils_1.describeErrorStack)(error));
85
+ }
86
+ });
87
+ this.api.on('shutdown', () => {
88
+ void this.stop();
89
+ });
90
+ }
91
+ get hap() {
92
+ return this.api.hap;
93
+ }
94
+ /** Homebridge hands back every accessory it restored from disk. */
95
+ configureAccessory(accessory) {
96
+ this.log.debug(`restoring cached accessory ${(0, utils_1.forLog)(accessory.displayName)}`);
97
+ this.restored.set(accessory.UUID, accessory);
98
+ }
99
+ // --- AccessoryHost --------------------------------------------------------
100
+ endpointFor(deviceId) {
101
+ return this.pollers.get(deviceId)?.endpoint;
102
+ }
103
+ observationFor(deviceId) {
104
+ return this.pollers.get(deviceId)?.lastObservation;
105
+ }
106
+ adoptWriteResult(deviceId, result) {
107
+ this.pollers.get(deviceId)?.adoptWriteResult(result);
108
+ }
109
+ /**
110
+ * Write accessory context back to the Homebridge cache.
111
+ *
112
+ * One accessory by default, because `updatePlatformAccessories` makes
113
+ * Homebridge serialise and rewrite the whole cache file: a front-panel volume
114
+ * knob produces a stream of observations, and writing every accessory's context
115
+ * for each of them is sustained disk churn on the SD card of a typical host.
116
+ */
117
+ persistContext(accessory) {
118
+ if (accessory !== undefined) {
119
+ this.api.updatePlatformAccessories([accessory]);
120
+ return;
121
+ }
122
+ if (this.active.size === 0) {
123
+ return;
124
+ }
125
+ this.api.updatePlatformAccessories([...this.active.values()]);
126
+ }
127
+ // --- Lifecycle ------------------------------------------------------------
128
+ start() {
129
+ const result = (0, utils_1.validateConfig)(this.config);
130
+ for (const warning of result.warnings) {
131
+ this.log.warn(warning);
132
+ }
133
+ for (const warning of this.discoveryWarnings) {
134
+ this.log.warn(warning);
135
+ }
136
+ if (result.errors.length > 0) {
137
+ this.disabled = true;
138
+ for (const error of result.errors) {
139
+ this.log.error(error);
140
+ }
141
+ this.log.error('BluOS is disabled until its configuration is fixed. Cached accessories are kept '
142
+ + 'and will show as No Response, so rooms and automations are not lost.');
143
+ this.reportEverythingUnavailable();
144
+ return;
145
+ }
146
+ this.devices = result.devices;
147
+ const accessoryWarnings = [];
148
+ const wanted = (0, utils_1.resolveAccessories)(this.devices, accessoryWarnings);
149
+ for (const warning of accessoryWarnings) {
150
+ this.log.warn(warning);
151
+ }
152
+ this.syncAccessories(wanted);
153
+ this.startPollers();
154
+ // Address correction runs alongside polling rather than before it: a player
155
+ // that has not moved should not have startup delayed by a multicast sweep.
156
+ // The promise is kept so a shutdown can wait for it instead of leaving a
157
+ // bound multicast socket behind.
158
+ this.launchSweep = this.correctAddresses();
159
+ }
160
+ async stop() {
161
+ this.shuttingDown = true;
162
+ for (const timer of this.staggerTimers) {
163
+ clearTimeout(timer);
164
+ }
165
+ this.staggerTimers.clear();
166
+ // Cancelled before the pollers are awaited: a poller inside an address
167
+ // re-resolution is waiting on a browse window that nothing else can end, so
168
+ // without this the process cannot exit until the window elapses — up to 30 s
169
+ // for every player that happened to be unreachable.
170
+ this.discovery.cancelAll();
171
+ const pollers = [...this.pollers.values()];
172
+ this.pollers.clear();
173
+ const sweep = this.launchSweep;
174
+ this.launchSweep = undefined;
175
+ await Promise.all([
176
+ ...pollers.map(async (poller) => poller.stop()),
177
+ sweep ?? Promise.resolve(),
178
+ ]);
179
+ this.log.debug('BluOS polling stopped');
180
+ }
181
+ /**
182
+ * Bring the registered accessory set in line with configuration.
183
+ *
184
+ * Creates what is missing, adopts what matches by identity, and unregisters
185
+ * only what configuration no longer asks for.
186
+ */
187
+ syncAccessories(wanted) {
188
+ const claimed = new Set();
189
+ const byId = new Map(this.devices.map((device) => [device.id, device]));
190
+ for (const accessory of wanted) {
191
+ const uuid = this.uuidFor(accessory);
192
+ // Every wanted accessory was expanded from one of these devices, so a miss
193
+ // is impossible rather than merely unlikely; the map exists to keep startup
194
+ // linear in accessory count.
195
+ const device = byId.get(accessory.deviceId);
196
+ if (device === undefined) {
197
+ continue;
198
+ }
199
+ const existing = this.restored.get(uuid) ?? this.findByIdentity(accessory, claimed);
200
+ if (existing === undefined) {
201
+ this.createAccessory(uuid, accessory, device);
202
+ continue;
203
+ }
204
+ claimed.add(existing.UUID);
205
+ this.adoptAccessory(existing, uuid, accessory, device);
206
+ }
207
+ for (const [uuid, accessory] of this.restored) {
208
+ if (claimed.has(uuid) || this.active.has(uuid)) {
209
+ continue;
210
+ }
211
+ this.log.info(`removing ${(0, utils_1.forLog)(accessory.displayName)}, no longer in the configuration`);
212
+ this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
213
+ }
214
+ }
215
+ uuidFor(accessory) {
216
+ return this.api.hap.uuid.generate(`${settings_1.UUID_PREFIX}${(0, identity_1.accessoryIdentityKey)(accessory)}`);
217
+ }
218
+ /**
219
+ * Find a cached accessory that describes this one but under a different UUID.
220
+ *
221
+ * The safety net for an identity-scheme change: matching on the persisted
222
+ * context lets the accessory be adopted instead of being replaced by a fresh
223
+ * one, which would silently drop it out of every scene it belongs to.
224
+ */
225
+ findByIdentity(accessory, claimed) {
226
+ for (const [uuid, candidate] of this.restored) {
227
+ if (claimed.has(uuid)) {
228
+ continue;
229
+ }
230
+ const context = candidate.context;
231
+ if ((0, identity_1.hasAccessoryIdentity)(context, accessory)) {
232
+ return candidate;
233
+ }
234
+ }
235
+ return undefined;
236
+ }
237
+ createAccessory(uuid, accessory, device) {
238
+ this.log.info(`adding ${(0, utils_1.forLog)(accessory.name)}`);
239
+ const platformAccessory = new this.api.platformAccessory(accessory.name, uuid);
240
+ platformAccessory.context = this.buildContext({
241
+ accessory,
242
+ device,
243
+ serialNumber: (0, utils_1.newAccessorySerialNumber)(),
244
+ adoptedLegacyUuid: false,
245
+ });
246
+ this.attachHandler(platformAccessory);
247
+ this.active.set(uuid, platformAccessory);
248
+ this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [platformAccessory]);
249
+ }
250
+ adoptAccessory(existing, expectedUuid, accessory, device) {
251
+ const adopted = existing.UUID !== expectedUuid;
252
+ const alreadyAdopted = existing.context.adoptedLegacyUuid === true;
253
+ // A HAP UUID is immutable, so an adopted accessory keeps its old one and takes
254
+ // this path on every launch. The flag is what makes the notice a migration
255
+ // notice rather than a line the user reads forever.
256
+ if (adopted && !alreadyAdopted) {
257
+ this.log.info(`adopting cached accessory ${(0, utils_1.forLog)(existing.displayName)} by identity; `
258
+ + 'its rooms and automations are preserved');
259
+ }
260
+ // The serial number is carried over rather than regenerated: HomeKit treats a
261
+ // changed serial as a different piece of hardware.
262
+ const serialNumber = (0, utils_1.ensureAccessorySerialNumber)(existing);
263
+ existing.context = this.buildContext({
264
+ accessory,
265
+ device,
266
+ serialNumber,
267
+ adoptedLegacyUuid: adopted || alreadyAdopted,
268
+ });
269
+ if (existing.displayName !== accessory.name) {
270
+ this.log.info(`${(0, utils_1.forLog)(existing.displayName)} is now named ${(0, utils_1.forLog)(accessory.name)}`);
271
+ existing.displayName = accessory.name;
272
+ }
273
+ this.attachHandler(existing);
274
+ this.active.set(existing.UUID, existing);
275
+ this.api.updatePlatformAccessories([existing]);
276
+ }
277
+ buildContext(input) {
278
+ const { accessory, device, serialNumber, adoptedLegacyUuid } = input;
279
+ const previous = this.restored.get(this.uuidFor(accessory))?.context;
280
+ const context = {
281
+ kind: accessory.kind,
282
+ deviceId: accessory.deviceId,
283
+ host: device.host,
284
+ port: device.port,
285
+ brand: device.brand ?? 'BluOS',
286
+ model: device.model ?? 'BluOS Player',
287
+ serialNumber,
288
+ adoptedLegacyUuid,
289
+ sliderService: accessory.sliderService,
290
+ };
291
+ if (accessory.volume !== undefined) {
292
+ context.volume = accessory.volume;
293
+ }
294
+ if (Number.isInteger(previous?.lastNonZeroVolume)) {
295
+ context.lastNonZeroVolume = previous?.lastNonZeroVolume;
296
+ }
297
+ return context;
298
+ }
299
+ /**
300
+ * Build the handler for an accessory from its persisted context.
301
+ *
302
+ * Driven by context rather than configuration so that the same path works when
303
+ * the platform is disabled and there is no valid configuration to consult.
304
+ */
305
+ attachHandler(accessory) {
306
+ let context;
307
+ try {
308
+ context = (0, utils_1.parseAccessoryContext)(accessory);
309
+ }
310
+ catch (error) {
311
+ this.log.warn(`${(0, utils_1.forLog)(accessory.displayName)} cannot be driven: ${(0, utils_1.describeError)(error)}. `
312
+ + 'It is left registered and shown as No Response rather than deleted, so its rooms '
313
+ + 'and automations survive. Remove it in the Homebridge UI if it is no longer wanted');
314
+ // Without this the tile keeps whatever HomeKit last cached and reports it
315
+ // forever, since no handler exists to correct it. No Response is the honest
316
+ // state for an accessory nothing is driving.
317
+ this.markAccessoryUnavailable(accessory);
318
+ return;
319
+ }
320
+ const init = { host: this, accessory, context };
321
+ let handler;
322
+ switch (context.kind) {
323
+ case 'volume':
324
+ handler = new devices_1.VolumeAccessory(init);
325
+ break;
326
+ case 'mute':
327
+ handler = new devices_1.MuteAccessory(init);
328
+ break;
329
+ case 'volumePreset':
330
+ handler = new devices_1.VolumePresetAccessory(init);
331
+ break;
332
+ case 'battery':
333
+ handler = new devices_1.BatteryAccessory(init);
334
+ break;
335
+ }
336
+ const group = this.handlers.get(context.deviceId) ?? [];
337
+ group.push(handler);
338
+ this.handlers.set(context.deviceId, group);
339
+ }
340
+ startPollers() {
341
+ let index = 0;
342
+ for (const device of this.devices) {
343
+ if ((this.handlers.get(device.id) ?? []).length === 0) {
344
+ this.log.debug(`${(0, utils_1.forLog)(device.name)} has no accessories; not polling it`);
345
+ continue;
346
+ }
347
+ const poller = new poller_1.DevicePoller({
348
+ log: this.log,
349
+ client: this.client,
350
+ deviceId: device.id,
351
+ displayName: device.name,
352
+ endpoint: { host: device.host, port: device.port },
353
+ onObservation: (observation, reason) => {
354
+ this.publish(device.id, observation, reason);
355
+ },
356
+ onUnreachable: (error) => {
357
+ this.reportUnavailable(device.id, error);
358
+ },
359
+ resolveEndpoint: async (deviceId) => this.discovery.resolveEndpoint(deviceId, this.discoveryTimeoutSec),
360
+ onEndpointChanged: (endpoint) => {
361
+ this.rememberEndpoint(device.id, endpoint);
362
+ },
363
+ });
364
+ this.pollers.set(device.id, poller);
365
+ const delay = index * POLLER_STAGGER_MS;
366
+ index += 1;
367
+ const timer = setTimeout(() => {
368
+ this.staggerTimers.delete(timer);
369
+ if (!this.shuttingDown) {
370
+ poller.start();
371
+ }
372
+ }, delay);
373
+ timer.unref?.();
374
+ this.staggerTimers.add(timer);
375
+ }
376
+ this.log.info(`BluOS is watching ${this.pollers.size} zone(s) with ${this.active.size} accessory(s)`);
377
+ }
378
+ /** Correct addresses once at launch, so a DHCP change needs no user action. */
379
+ async correctAddresses() {
380
+ if (this.pollers.size === 0) {
381
+ return;
382
+ }
383
+ try {
384
+ const players = await this.discovery.discover(this.discoveryTimeoutSec);
385
+ if (this.shuttingDown) {
386
+ return;
387
+ }
388
+ for (const player of players) {
389
+ const poller = this.pollers.get(player.id);
390
+ poller?.setEndpoint({ host: player.host, port: player.port });
391
+ }
392
+ }
393
+ catch (error) {
394
+ this.log.debug(`launch discovery failed: ${(0, utils_1.describeError)(error)}`);
395
+ }
396
+ }
397
+ /** Record a new address in accessory context so it survives a restart. */
398
+ rememberEndpoint(deviceId, endpoint) {
399
+ let changed = false;
400
+ for (const accessory of this.active.values()) {
401
+ const context = accessory.context;
402
+ if (context.deviceId !== deviceId) {
403
+ continue;
404
+ }
405
+ if (context.host !== endpoint.host || context.port !== endpoint.port) {
406
+ context.host = endpoint.host;
407
+ context.port = endpoint.port;
408
+ changed = true;
409
+ }
410
+ }
411
+ if (changed) {
412
+ this.persistContext();
413
+ }
414
+ }
415
+ publish(deviceId, observation, reason) {
416
+ for (const handler of this.handlers.get(deviceId) ?? []) {
417
+ try {
418
+ handler.applyObservation(observation, reason);
419
+ }
420
+ catch (error) {
421
+ this.log.debug(`${(0, utils_1.forLog)(handler.displayName)} could not apply an observation: ${(0, utils_1.describeError)(error)}`);
422
+ }
423
+ }
424
+ }
425
+ reportUnavailable(deviceId, error) {
426
+ for (const handler of this.handlers.get(deviceId) ?? []) {
427
+ // Guarded exactly like publish. This runs from inside the poll loop's catch
428
+ // block, so a throw here — a characteristic missing from a hand-edited
429
+ // cached accessory, a service another plugin removed — would escape as an
430
+ // unhandled rejection and end the Homebridge process.
431
+ try {
432
+ handler.noteUnreachable(error);
433
+ }
434
+ catch (failure) {
435
+ this.log.debug(`${(0, utils_1.forLog)(handler.displayName)} could not be marked unavailable: `
436
+ + (0, utils_1.describeError)(failure));
437
+ }
438
+ }
439
+ }
440
+ /**
441
+ * Put every cached accessory into No Response.
442
+ *
443
+ * Used when configuration is unusable. Handlers are attached first so that the
444
+ * characteristics exist to be marked, which also means HomeKit sees a
445
+ * well-formed accessory that happens to be unreachable rather than a
446
+ * half-registered one.
447
+ */
448
+ reportEverythingUnavailable() {
449
+ for (const accessory of this.restored.values()) {
450
+ this.attachHandler(accessory);
451
+ this.active.set(accessory.UUID, accessory);
452
+ }
453
+ const reason = new Error('the plugin configuration is not usable');
454
+ for (const handlers of this.handlers.values()) {
455
+ for (const handler of handlers) {
456
+ handler.noteUnreachable(reason);
457
+ }
458
+ }
459
+ }
460
+ /**
461
+ * Push No Response onto an accessory that has no handler.
462
+ *
463
+ * Generic rather than per accessory kind, because the reason it is needed is
464
+ * that the context which would have told us the kind could not be read.
465
+ * Accessory Information is left alone so the tile keeps its name and model.
466
+ */
467
+ markAccessoryUnavailable(accessory) {
468
+ const failure = new this.hap.HapStatusError(-70402 /* this.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
469
+ for (const service of accessory.services) {
470
+ if (service.UUID === this.hap.Service.AccessoryInformation.UUID) {
471
+ continue;
472
+ }
473
+ for (const characteristic of service.characteristics) {
474
+ try {
475
+ characteristic.updateValue(failure);
476
+ }
477
+ catch (error) {
478
+ this.log.debug(`${(0, utils_1.forLog)(accessory.displayName)} could not be marked unavailable: `
479
+ + (0, utils_1.describeError)(error));
480
+ }
481
+ }
482
+ }
483
+ }
484
+ /** True when the platform gave up on its configuration. Exposed for tests. */
485
+ get isDisabled() {
486
+ return this.disabled;
487
+ }
488
+ }
489
+ exports.BluOSPlatform = BluOSPlatform;
@@ -0,0 +1,109 @@
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 One long-poll loop per player zone.
8
+ *
9
+ * The loop holds a `/SyncStatus?timeout=100&etag=…` request open and is woken by
10
+ * the player the moment that zone's state changes. Verified against firmware
11
+ * 4.16.6: with a current etag the request holds for exactly the requested window,
12
+ * with a stale etag it answers in 44 ms, and the etag is per-zone — a sibling zone
13
+ * on the same chassis changing volume does not wake this poll. That last point is
14
+ * why one loop per zone is correct rather than wasteful.
15
+ *
16
+ * The set/poll race is handled with a generation counter. A response that was
17
+ * computed before a local write cannot be distinguished from a fresh one by its
18
+ * contents, so any response that arrives across a write is discarded and replaced
19
+ * by an immediate re-read. That costs one cheap request — a stale etag answers
20
+ * immediately — and removes the class of bug where a HomeKit slider springs back
21
+ * to its old position a moment after being moved.
22
+ */
23
+ import type { Endpoint } from './api/client';
24
+ import { BluOSClient } from './api/client';
25
+ import type { VolumeResult } from './api/sync-status';
26
+ import type { PlayerObservation, PluginLogger, RefreshReason } from './types';
27
+ /** Collaborators and callbacks for one poller. */
28
+ export interface DevicePollerOptions {
29
+ log: PluginLogger;
30
+ client: BluOSClient;
31
+ deviceId: string;
32
+ displayName: string;
33
+ endpoint: Endpoint;
34
+ /** Called with every accepted observation. */
35
+ onObservation: (observation: PlayerObservation, reason: RefreshReason) => void;
36
+ /** Called when the player could not be reached often enough to matter. */
37
+ onUnreachable: (error: unknown) => void;
38
+ /**
39
+ * Ask the platform to find this player's current address.
40
+ *
41
+ * Invoked after repeated failures, which is the signature of a DHCP lease
42
+ * change, and rate-limited so a genuinely absent player does not cause
43
+ * continuous multicast traffic.
44
+ */
45
+ resolveEndpoint: (deviceId: string) => Promise<Endpoint | undefined>;
46
+ /** Called when an address changes, so it can be persisted. */
47
+ onEndpointChanged?: (endpoint: Endpoint) => void;
48
+ }
49
+ /** Drives one player zone. */
50
+ export declare class DevicePoller {
51
+ private readonly options;
52
+ private currentEndpoint;
53
+ private observation;
54
+ /** Opaque long-poll token. Cleared to force a plain read next time round. */
55
+ private etag;
56
+ /** Incremented by every local write, to invalidate responses that predate it. */
57
+ private generation;
58
+ private consecutiveFailures;
59
+ private lastRediscoveryAt;
60
+ private stopped;
61
+ private loop;
62
+ private abort;
63
+ /** Resolves the current backoff sleep early when a refresh is requested. */
64
+ private wake;
65
+ constructor(options: DevicePollerOptions);
66
+ get endpoint(): Endpoint;
67
+ get lastObservation(): PlayerObservation | undefined;
68
+ /** Point this poller at a new address, dropping any request in flight. */
69
+ setEndpoint(endpoint: Endpoint): void;
70
+ /** Begin polling. Safe to call more than once. */
71
+ start(): void;
72
+ /** Stop polling and drop any request in flight. */
73
+ stop(): Promise<void>;
74
+ /**
75
+ * Adopt the state a write reported, and invalidate anything in flight.
76
+ *
77
+ * The player's answer to a write is authoritative — it clamps the level into
78
+ * its own configured range — so this is both the fastest and the most accurate
79
+ * update available.
80
+ */
81
+ adoptWriteResult(result: VolumeResult): void;
82
+ /** Cancel the request in flight and wake any backoff sleep. */
83
+ private interrupt;
84
+ private run;
85
+ private readOnce;
86
+ private handleFailure;
87
+ /**
88
+ * Look for a new address for this player. True when the address changed.
89
+ *
90
+ * Rate-limited: a player that is switched off would otherwise trigger a
91
+ * multicast sweep on every backoff cycle.
92
+ */
93
+ private tryRediscovery;
94
+ /**
95
+ * Name plus stable id, for a log line someone has to act on.
96
+ *
97
+ * Two players can share a display name — the configuration validator warns
98
+ * about it rather than refusing it — so a failure line naming only the name
99
+ * cannot be traced back to a player.
100
+ */
101
+ private label;
102
+ /**
103
+ * Sleep, but return early if a write, refresh or shutdown arrives.
104
+ *
105
+ * The timer is cleared rather than left to expire, so stopping the plugin does
106
+ * not have to wait out a backoff delay that no longer matters.
107
+ */
108
+ private sleepInterruptibly;
109
+ }