homebridge-bluos 1.1.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.0.0](https://github.com/tbaur/homebridge-bluos/compare/v1.1.6...v2.0.0) (2026-09-05)
4
+
5
+
6
+ ### ⚠ BREAKING CHANGES
7
+
8
+ * requires Homebridge 2.0 or newer and Node 22, 24 or 26. Homebridge 1.x and Node 20 are no longer supported.
9
+
10
+ ### Features
11
+
12
+ * require Homebridge 2 and Node 22+, and fix cached-context persistence ([#39](https://github.com/tbaur/homebridge-bluos/issues/39)) ([72fdbf8](https://github.com/tbaur/homebridge-bluos/commit/72fdbf80de22183ca79b5da4c3cdda575e4ef8d1))
13
+
14
+ ## [1.1.6](https://github.com/tbaur/homebridge-bluos/compare/v1.1.5...v1.1.6) (2026-08-31)
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * announce discovery with a toast while the spinner runs ([#35](https://github.com/tbaur/homebridge-bluos/issues/35)) ([99582c9](https://github.com/tbaur/homebridge-bluos/commit/99582c92d710f7e5c7aade520faf76a431a0a477))
20
+
3
21
  ## [1.1.5](https://github.com/tbaur/homebridge-bluos/compare/v1.1.4...v1.1.5) (2026-08-31)
4
22
 
5
23
 
package/DEVELOPMENT.md CHANGED
@@ -35,7 +35,7 @@ A capability ships if HomeKit can express it as a tile, a scene, an automation o
35
35
 
36
36
  ## Commands
37
37
 
38
- Node 20+, matching `engines`. CI runs 20 / 22 / 24, a Homebridge 1.6 floor job, and a runtime `npm audit`. `@types/node` stays on the 20 line for the same reason: newer types let the compiler accept API that the oldest supported runtime does not have. Dependabot is told not to raise it, because the floor and the types are meant to move together.
38
+ Node 22, 24 or 26, matching `engines`. CI runs 22 / 24 / 26 and a runtime `npm audit`. `@types/node` tracks the top of that range rather than its floor, so the Node 22 job in the matrix is what catches an API the oldest supported runtime does not have. Dependabot is told not to raise it on its own, because the range and the types are meant to move together.
39
39
 
40
40
  ```bash
41
41
  npm install
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # homebridge-bluos
2
2
 
3
- [![Tests](https://github.com/tbaur/homebridge-bluos/actions/workflows/test.yml/badge.svg)](https://github.com/tbaur/homebridge-bluos/actions/workflows/test.yml) [![npm version](https://img.shields.io/npm/v/homebridge-bluos?style=flat-square)](https://www.npmjs.com/package/homebridge-bluos) [![npm downloads](https://img.shields.io/npm/dt/homebridge-bluos?label=downloads&style=flat-square)](https://www.npmjs.com/package/homebridge-bluos) [![Node.js](https://img.shields.io/badge/node-%3E%3D20-green)](https://nodejs.org) [![verified-by-homebridge](https://img.shields.io/badge/homebridge-verified-blueviolet?color=%23491F59&style=flat)](https://github.com/homebridge/homebridge/wiki/Verified-Plugins) [![Homebridge](https://img.shields.io/badge/homebridge-%3E%3D1.6.0%20%7C%7C%202.x-purple)](https://homebridge.io) [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
3
+ [![Tests](https://github.com/tbaur/homebridge-bluos/actions/workflows/test.yml/badge.svg)](https://github.com/tbaur/homebridge-bluos/actions/workflows/test.yml) [![npm version](https://img.shields.io/npm/v/homebridge-bluos?style=flat-square)](https://www.npmjs.com/package/homebridge-bluos) [![npm downloads](https://img.shields.io/npm/dt/homebridge-bluos?label=downloads&style=flat-square)](https://www.npmjs.com/package/homebridge-bluos) [![Node.js](https://img.shields.io/badge/node-22%20%7C%7C%2024%20%7C%7C%2026-green)](https://nodejs.org) [![verified-by-homebridge](https://img.shields.io/badge/homebridge-verified-blueviolet?color=%23491F59&style=flat)](https://github.com/homebridge/homebridge/wiki/Verified-Plugins) [![Homebridge](https://img.shields.io/badge/homebridge-2.x-purple)](https://homebridge.io) [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
4
4
 
5
5
  **BluOS players in Apple HomeKit, over your LAN.** No cloud, no accounts, no polling loops hammering your speakers. Verified against NAD and Bluesound hardware. Other BluOS brands (DALI, Monitor Audio, Roksan) run the same firmware and are expected to work, but are untested here.
6
6
 
@@ -133,8 +133,8 @@ The plugin talks only to the addresses in your configuration, on your LAN. There
133
133
 
134
134
  ## Requirements
135
135
 
136
- - Homebridge 1.6.0+ or 2.0+
137
- - Node.js 20+ (Homebridge 2.x itself requires Node 22+, so the Node 20 floor applies to Homebridge 1.x hosts)
136
+ - Homebridge 2.x
137
+ - Node.js 22, 24 or 26, matching what Homebridge 2.x itself supports
138
138
  - One or more BluOS players reachable on the same network
139
139
 
140
140
  ## More Info
@@ -83,8 +83,15 @@ export declare class BluOSClient {
83
83
  /** Tail of the write queue for each chassis host. */
84
84
  private readonly chassisWriteQueue;
85
85
  constructor(options: BluOSClientOptions);
86
- /** Read `/SyncStatus` once, without long-polling. */
87
- readSyncStatus(endpoint: Endpoint): Promise<PlayerObservation>;
86
+ /**
87
+ * Read `/SyncStatus` once, without long-polling.
88
+ *
89
+ * Takes a signal for the same reason the long poll does. This read is bounded
90
+ * by {@link STATUS_TIMEOUT_MS} rather than by a poll window, but a shutdown
91
+ * still has to be able to drop it: an unreachable player holds it for the full
92
+ * timeout, and a shutdown waits for every poller.
93
+ */
94
+ readSyncStatus(endpoint: Endpoint, signal?: AbortSignal): Promise<PlayerObservation>;
88
95
  /**
89
96
  * Long-poll `/SyncStatus`, returning when the player's state changes or the
90
97
  * poll window elapses.
@@ -58,13 +58,21 @@ class BluOSClient {
58
58
  this.now = options.now ?? Date.now;
59
59
  this.sleep = options.sleep ?? timing_1.sleep;
60
60
  }
61
- /** Read `/SyncStatus` once, without long-polling. */
62
- async readSyncStatus(endpoint) {
61
+ /**
62
+ * Read `/SyncStatus` once, without long-polling.
63
+ *
64
+ * Takes a signal for the same reason the long poll does. This read is bounded
65
+ * by {@link STATUS_TIMEOUT_MS} rather than by a poll window, but a shutdown
66
+ * still has to be able to drop it: an unreachable player holds it for the full
67
+ * timeout, and a shutdown waits for every poller.
68
+ */
69
+ async readSyncStatus(endpoint, signal) {
63
70
  const body = await this.get({
64
71
  endpoint,
65
72
  resource: 'SyncStatus',
66
73
  query: {},
67
74
  totalTimeoutMs: settings_1.STATUS_TIMEOUT_MS,
75
+ signal,
68
76
  });
69
77
  return (0, sync_status_1.parseSyncStatus)(body, (0, identity_1.formatEndpoint)(endpoint.host, endpoint.port));
70
78
  }
@@ -136,6 +136,10 @@ export declare class BluOSPlatform implements DynamicPlatformPlugin, AccessoryHo
136
136
  *
137
137
  * Driven by context rather than configuration so that the same path works when
138
138
  * the platform is disabled and there is no valid configuration to consult.
139
+ *
140
+ * The validated context becomes the accessory's own, so the handler and the
141
+ * Homebridge cache hold one object. Anything a handler remembers is then in
142
+ * the object `persistContext` serialises. See {@link bindAccessoryContext}.
139
143
  */
140
144
  private attachHandler;
141
145
  private startPollers;
package/dist/platform.js CHANGED
@@ -349,6 +349,9 @@ class BluOSPlatform {
349
349
  device,
350
350
  serialNumber: (0, utils_1.newAccessorySerialNumber)(),
351
351
  adoptedLegacyUuid: false,
352
+ // Nothing to carry over: this path is taken only when no cached accessory
353
+ // answered to this identity.
354
+ previous: undefined,
352
355
  });
353
356
  this.attachHandler(platformAccessory);
354
357
  this.active.set(uuid, platformAccessory);
@@ -372,6 +375,11 @@ class BluOSPlatform {
372
375
  device,
373
376
  serialNumber,
374
377
  adoptedLegacyUuid: adopted || alreadyAdopted,
378
+ // Read off the accessory being adopted rather than looked up by the UUID
379
+ // this identity produces now. An adopted accessory is cached under its old
380
+ // UUID, so that lookup misses on exactly the path this branch exists for,
381
+ // and the level the user last set would be dropped by the migration.
382
+ previous: existing.context,
375
383
  });
376
384
  if (existing.displayName !== accessory.name) {
377
385
  this.log.info(`${(0, utils_1.forLog)(existing.displayName)} is now named ${(0, utils_1.forLog)(accessory.name)}`);
@@ -382,8 +390,7 @@ class BluOSPlatform {
382
390
  this.api.updatePlatformAccessories([existing]);
383
391
  }
384
392
  buildContext(input) {
385
- const { accessory, device, serialNumber, adoptedLegacyUuid } = input;
386
- const previous = this.restored.get(this.uuidFor(accessory))?.context;
393
+ const { accessory, device, serialNumber, adoptedLegacyUuid, previous } = input;
387
394
  // The platform's own switch has no device, and so no address: its host and
388
395
  // port are placeholders that nothing reads, because it resolves its targets
389
396
  // when it is pressed rather than holding one endpoint.
@@ -414,11 +421,15 @@ class BluOSPlatform {
414
421
  *
415
422
  * Driven by context rather than configuration so that the same path works when
416
423
  * the platform is disabled and there is no valid configuration to consult.
424
+ *
425
+ * The validated context becomes the accessory's own, so the handler and the
426
+ * Homebridge cache hold one object. Anything a handler remembers is then in
427
+ * the object `persistContext` serialises. See {@link bindAccessoryContext}.
417
428
  */
418
429
  attachHandler(accessory) {
419
430
  let context;
420
431
  try {
421
- context = (0, utils_1.parseAccessoryContext)(accessory);
432
+ context = (0, utils_1.bindAccessoryContext)(accessory);
422
433
  }
423
434
  catch (error) {
424
435
  this.log.warn(`${(0, utils_1.forLog)(accessory.displayName)} cannot be driven: ${(0, utils_1.describeError)(error)}. `
package/dist/poller.d.ts CHANGED
@@ -90,6 +90,13 @@ export declare class DevicePoller {
90
90
  /** Cancel the request in flight and wake any backoff sleep. */
91
91
  private interrupt;
92
92
  private run;
93
+ /**
94
+ * Read this zone once, long-polling when there is a token to poll with.
95
+ *
96
+ * Both reads are abortable, not just the long poll. A plain read is the path an
97
+ * unreachable player takes — a failure clears the etag — and it can sit for the
98
+ * whole status timeout, which a shutdown would otherwise wait out per player.
99
+ */
93
100
  private readOnce;
94
101
  private handleFailure;
95
102
  /**
package/dist/poller.js CHANGED
@@ -199,15 +199,21 @@ class DevicePoller {
199
199
  }
200
200
  }
201
201
  }
202
+ /**
203
+ * Read this zone once, long-polling when there is a token to poll with.
204
+ *
205
+ * Both reads are abortable, not just the long poll. A plain read is the path an
206
+ * unreachable player takes — a failure clears the etag — and it can sit for the
207
+ * whole status timeout, which a shutdown would otherwise wait out per player.
208
+ */
202
209
  async readOnce() {
203
210
  const etag = this.etag;
204
- if (etag === undefined) {
205
- return this.options.client.readSyncStatus(this.currentEndpoint);
206
- }
207
211
  const abort = new AbortController();
208
212
  this.abort = abort;
209
213
  try {
210
- return await this.options.client.pollSyncStatus(this.currentEndpoint, etag, abort.signal);
214
+ return etag === undefined
215
+ ? await this.options.client.readSyncStatus(this.currentEndpoint, abort.signal)
216
+ : await this.options.client.pollSyncStatus(this.currentEndpoint, etag, abort.signal);
211
217
  }
212
218
  catch (error) {
213
219
  throw abort.signal.aborted ? new PollInterrupted() : error;
@@ -16,3 +16,18 @@ import type { PlatformAccessory } from 'homebridge';
16
16
  import { type AccessoryContext } from '../types';
17
17
  /** Read and validate a restored accessory's context. */
18
18
  export declare function parseAccessoryContext(accessory: PlatformAccessory): AccessoryContext;
19
+ /**
20
+ * Validate a restored accessory's context and make it the accessory's own.
21
+ *
22
+ * What a handler is given has to be the object Homebridge serialises, because
23
+ * handlers remember things in it: a slider's last non-zero level, a brand the
24
+ * player reported that configuration got wrong. {@link parseAccessoryContext}
25
+ * returns a new object by design, so a handler driven by that alone would write
26
+ * those values to a detached copy — thrown away at the next restart, after
27
+ * paying a full accessory-cache rewrite per volume step to save nothing.
28
+ *
29
+ * Kept separate from {@link parseAccessoryContext} so validating a context stays
30
+ * free of side effects for a caller that only wants to know whether it can be
31
+ * read.
32
+ */
33
+ export declare function bindAccessoryContext(accessory: PlatformAccessory): AccessoryContext;
@@ -15,6 +15,7 @@
15
15
  */
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.parseAccessoryContext = parseAccessoryContext;
18
+ exports.bindAccessoryContext = bindAccessoryContext;
18
19
  const settings_1 = require("../settings");
19
20
  const types_1 = require("../types");
20
21
  const errors_1 = require("./errors");
@@ -57,3 +58,22 @@ function parseAccessoryContext(accessory) {
57
58
  }
58
59
  return context;
59
60
  }
61
+ /**
62
+ * Validate a restored accessory's context and make it the accessory's own.
63
+ *
64
+ * What a handler is given has to be the object Homebridge serialises, because
65
+ * handlers remember things in it: a slider's last non-zero level, a brand the
66
+ * player reported that configuration got wrong. {@link parseAccessoryContext}
67
+ * returns a new object by design, so a handler driven by that alone would write
68
+ * those values to a detached copy — thrown away at the next restart, after
69
+ * paying a full accessory-cache rewrite per volume step to save nothing.
70
+ *
71
+ * Kept separate from {@link parseAccessoryContext} so validating a context stays
72
+ * free of side effects for a caller that only wants to know whether it can be
73
+ * read.
74
+ */
75
+ function bindAccessoryContext(accessory) {
76
+ const context = parseAccessoryContext(accessory);
77
+ accessory.context = context;
78
+ return context;
79
+ }
package/docs/FEATURES.md CHANGED
@@ -45,8 +45,8 @@ A checklist of what is built. The plugin aims to cover everything about a BluOS
45
45
  - ✅ Clean shutdown: poll loops, backoff delays and mDNS browses are cancelled, not left to run out
46
46
  - ✅ A cached accessory that the plugin cannot drive reports No Response and says what to do about it. It never shows a stale value forever
47
47
  - ✅ Custom Homebridge UI settings page, plus a plain `config.schema.json` form
48
- - ✅ Homebridge v1.6.0+ and v2.0+ support
49
- - ✅ Node.js 20+ support
48
+ - ✅ Homebridge v2.0+ support
49
+ - ✅ Node.js 22, 24 and 26 support
50
50
 
51
51
  ## Not built yet
52
52
 
@@ -275,7 +275,7 @@ Responses are parsed by a size-, depth- and element-capped XML reader instead of
275
275
 
276
276
  - **Strict TypeScript:** `strict`, plus `noUncheckedIndexedAccess` and type-aware lint
277
277
  - **Tested:** a behavioural Jest suite over 95% of statements, including XML fixtures recorded from real hardware (NAD C658, CI S2, Bluesound P430, portable player) and the settings page that writes your configuration
278
- - **CI:** build, lint (warnings are failures), type-check and test on Node 20/22/24, a job against the oldest supported Homebridge, a committed-`dist` drift check, a dependency audit and OSV scanning
278
+ - **CI:** build, lint (warnings are failures), type-check and test on Node 22/24/26, a job against the oldest supported Homebridge, a committed-`dist` drift check, a dependency audit and OSV scanning
279
279
  - **No analytics:** no tracking, no cloud, no accounts
280
280
 
281
281
  ## More
@@ -10,39 +10,7 @@
10
10
  -->
11
11
  <style>
12
12
  .bluos-intro { margin-bottom: 1rem; }
13
- .bluos-toolbar {
14
- display: flex;
15
- gap: .5rem;
16
- align-items: flex-end;
17
- flex-wrap: wrap;
18
- position: sticky;
19
- top: 0;
20
- z-index: 5;
21
- margin: 0 0 1rem;
22
- padding: .55rem 0;
23
- /* Frosted so cards do not show through while this bar stays on screen. */
24
- background: rgba(128, 128, 128, .12);
25
- backdrop-filter: blur(10px);
26
- }
27
- .bluos-busy {
28
- display: inline-flex;
29
- align-items: center;
30
- align-self: center;
31
- gap: .4rem;
32
- font-size: .85rem;
33
- white-space: nowrap;
34
- }
35
- .bluos-busy[hidden] { display: none !important; }
36
- .bluos-busy::before {
37
- content: '';
38
- width: .85rem;
39
- height: .85rem;
40
- border: 2px solid currentColor;
41
- border-right-color: transparent;
42
- border-radius: 50%;
43
- animation: bluos-spin .7s linear infinite;
44
- }
45
- @keyframes bluos-spin { to { transform: rotate(360deg); } }
13
+ .bluos-toolbar { display: flex; gap: .5rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: 1rem; }
46
14
  .bluos-toolbar .form-group { margin-bottom: 0; }
47
15
  .bluos-card { border: 1px solid rgba(128, 128, 128, .3); border-radius: .5rem; padding: .85rem 1rem; margin-bottom: .75rem; }
48
16
  .bluos-card.is-selected { border-color: #007bff; box-shadow: 0 0 0 1px rgba(0, 123, 255, .35); }
@@ -78,7 +46,6 @@
78
46
 
79
47
  <div class="bluos-toolbar">
80
48
  <button class="btn btn-primary" id="discover">Discover Players</button>
81
- <span id="discover-status" class="bluos-busy text-muted" hidden></span>
82
49
  <div class="form-group">
83
50
  <label for="timeout" class="mb-0" style="font-size: .8rem;">Listen for</label>
84
51
  <select class="form-control form-control-sm" id="timeout">
@@ -457,30 +457,12 @@
457
457
 
458
458
  // --- Actions ------------------------------------------------------------
459
459
 
460
- let requestInFlight = false
461
-
462
- /**
463
- * Show progress next to Discover, which is where the user just pressed.
464
- *
465
- * Homebridge's spinner is centred on the whole settings page. With a long
466
- * list of players that centre sits below the fold, so a user who pressed
467
- * Discover would see nothing happen for several seconds.
468
- */
469
- function setBusy(isBusy, message) {
470
- requestInFlight = isBusy
471
- byId('discover').disabled = isBusy
472
- byId('manual-probe').disabled = isBusy
473
- const status = byId('discover-status')
474
- status.hidden = !isBusy
475
- status.textContent = isBusy ? message : ''
476
- }
477
-
478
460
  async function discover() {
479
- if (requestInFlight) {
480
- return
481
- }
482
461
  const timeoutSec = Number(byId('timeout').value) || DEFAULT_TIMEOUT_SEC
483
- setBusy(true, 'Listening for players…')
462
+ homebridge.showSpinner()
463
+ // The overlay can sit in the middle of a long device list. The toast is
464
+ // drawn on the Config UI chrome, so it stays visible.
465
+ homebridge.toast.info('Listening for players…', 'Discovering')
484
466
  try {
485
467
  const response = await homebridge.request('/discover', { timeoutSec })
486
468
  const found = Array.isArray(response && response.players) ? response.players : []
@@ -505,14 +487,11 @@
505
487
  } catch (error) {
506
488
  homebridge.toast.error(describeError(error), 'Discovery failed')
507
489
  } finally {
508
- setBusy(false)
490
+ homebridge.hideSpinner()
509
491
  }
510
492
  }
511
493
 
512
494
  async function probe() {
513
- if (requestInFlight) {
514
- return
515
- }
516
495
  const host = byId('manual-host').value.trim()
517
496
  const portValue = byId('manual-port').value.trim()
518
497
  if (host.length === 0) {
@@ -523,7 +502,8 @@
523
502
  if (portValue.length > 0) {
524
503
  payload.port = Number(portValue)
525
504
  }
526
- setBusy(true, 'Probing…')
505
+ homebridge.showSpinner()
506
+ homebridge.toast.info(`Probing ${host}…`, 'Probing')
527
507
  try {
528
508
  const response = await homebridge.request('/probe', payload)
529
509
  const found = Array.isArray(response && response.players) ? response.players : []
@@ -539,7 +519,7 @@
539
519
  } catch (error) {
540
520
  homebridge.toast.error(describeError(error), 'Probe failed')
541
521
  } finally {
542
- setBusy(false)
522
+ homebridge.hideSpinner()
543
523
  }
544
524
  }
545
525
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-bluos",
3
3
  "displayName": "Homebridge BluOS",
4
- "version": "1.1.5",
4
+ "version": "2.0.0",
5
5
  "description": "Homebridge plugin for BluOS players — per-zone volume, mute, volume-preset and battery accessories over the LAN Custom Integration API. Verified on NAD and Bluesound hardware",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "homepage": "https://github.com/tbaur/homebridge-bluos#readme",
42
42
  "overrides": {
43
- "js-yaml": "^4.2.0",
43
+ "js-yaml": "^4.3.1",
44
44
  "brace-expansion": "^5.0.9"
45
45
  },
46
46
  "dependencies": {
@@ -51,7 +51,7 @@
51
51
  "@types/dns-packet": "^5.6.5",
52
52
  "@types/jest": "^30.0.0",
53
53
  "@types/multicast-dns": "^7.2.4",
54
- "@types/node": "^20.19.43",
54
+ "@types/node": "^26",
55
55
  "@typescript-eslint/eslint-plugin": "^8.51.0",
56
56
  "@typescript-eslint/parser": "^8.51.0",
57
57
  "eslint": "^10.6.0",
@@ -62,8 +62,8 @@
62
62
  "typescript": "^6.0.3"
63
63
  },
64
64
  "engines": {
65
- "homebridge": "^1.6.0 || ^2.0.0",
66
- "node": ">=20.0.0"
65
+ "homebridge": "^2.0.0",
66
+ "node": "^22 || ^24 || ^26"
67
67
  },
68
68
  "scripts": {
69
69
  "build": "tsc",