homebridge-bluos 1.1.0 → 1.1.2

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.2](https://github.com/tbaur/homebridge-bluos/compare/v1.1.1...v1.1.2) (2026-08-30)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * do not end the reboot quiet window on a last-gasp poll ([#26](https://github.com/tbaur/homebridge-bluos/issues/26)) ([91651a4](https://github.com/tbaur/homebridge-bluos/commit/91651a4c4b114c8a8e7bfba0cc7c4d6148375b7d))
9
+
10
+ ## [1.1.1](https://github.com/tbaur/homebridge-bluos/compare/v1.1.0...v1.1.1) (2026-08-30)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * keep accessories quiet while a rebooted player comes back ([#24](https://github.com/tbaur/homebridge-bluos/issues/24)) ([dc4f792](https://github.com/tbaur/homebridge-bluos/commit/dc4f7921cb584697e5e49f92ec0207642d88a541))
16
+
3
17
  ## [1.1.0](https://github.com/tbaur/homebridge-bluos/compare/v1.0.3...v1.1.0) (2026-08-30)
4
18
 
5
19
 
package/README.md CHANGED
@@ -14,13 +14,13 @@ Browsing, search, queues and artwork stay in the BluOS app, which HomeKit cannot
14
14
  - **Mute switch:** unmuting restores the level the player remembered, not a guess
15
15
  - **Volume presets:** one switch per exact level, addressable by name with Siri
16
16
  - **Battery sensor:** level, charging state and low-battery warning, for players with a battery pack
17
- - **Reboot switch:** momentary, so a scene cannot restart your stereo, and still pressable when the player has stopped answering
17
+ - **Reboot switch:** momentary, so a scene cannot reboot your stereo, and still pressable when the player has stopped answering
18
18
  - **Grouping-aware:** a zone leading a BluOS group moves the whole group, exactly as it does in the BluOS app
19
19
  - **Multi-zone chassis:** each zone of a NAD CI S2 or CI 580 is its own player, on its own port
20
20
 
21
21
  ### For the whole install
22
22
 
23
- - **Reboot all:** off by default. One switch that restarts every BluOS player it can find on the network, naming each one in the log first
23
+ - **Reboot all:** off by default. One switch that reboots every BluOS player it can find on the network. The info log is a count (`found 2 device(s), 3 player(s)`); the debug log names each box
24
24
 
25
25
  ### Reliability
26
26
 
@@ -111,7 +111,7 @@ Only one `BluOS` platform block is supported. It can hold as many players as you
111
111
  | `devices` | ✓ | List of players |
112
112
  | `options.sliderService` | | `fan` (default) or `lightbulb`, for every slider |
113
113
  | `options.discoveryTimeoutSec` | | mDNS listening window, 1–30 seconds (default 5) |
114
- | `options.rebootAll` | | Expose one switch that restarts **every BluOS player on the network** (default false) |
114
+ | `options.rebootAll` | | Expose one switch that reboots **every BluOS player on the network** (default false) |
115
115
  | `options.rebootAllName` | | What that switch is called in the Home app |
116
116
 
117
117
  Each entry in `devices[]` takes `id`, `name` and `host`, plus an optional `port`, `volumeSlider`, `sliderService`, `mute`, `battery`, `reboot` and `volumePresets[]`. The [detailed documentation](docs/README-DETAILED.md#devices-entries) describes each one, and explains which changes are safe to make to a working install.
@@ -121,7 +121,7 @@ Each entry in `devices[]` takes `id`, `name` and `host`, plus an optional `port`
121
121
  1. **Nothing found by Discover Players.** mDNS is often filtered across VLANs and by some access points. Use manual address entry, or add the player by hand
122
122
  2. **Everything shows No Response and stays that way.** Check the log for `BluOS is disabled until its configuration is fixed`
123
123
  3. **A zone on a multi-zone chassis is missing.** Check the port. Zone two is 11010, not 11000
124
- 4. **A reboot switch restarted the room next door.** Expected on a multi-zone chassis. BluOS serves reboot per box, not per zone
124
+ 4. **A reboot switch rebooted the room next door.** Expected on a multi-zone chassis. BluOS serves reboot per box, not per zone
125
125
  5. **The reboot switch turns itself off.** By design. It is a button, not a state
126
126
  6. Restart Homebridge after editing `config.json` by hand
127
127
 
@@ -42,7 +42,7 @@
42
42
  "title": "Reboot All Switch",
43
43
  "type": "boolean",
44
44
  "default": false,
45
- "description": "One switch that restarts every BluOS player it can find. Its reach is the whole network, not just the players listed below: pressing it also restarts players you have deliberately left out of this configuration. Every player is named in the Homebridge log before anything is sent."
45
+ "description": "One switch that reboots every BluOS player it can find. Its reach is the whole network, not just the players listed below: pressing it also reboots players you have deliberately left out of this configuration. The info log is a count of devices and players; the debug log names each box before anything is sent."
46
46
  },
47
47
  "rebootAllName": {
48
48
  "title": "Reboot All Name",
@@ -125,7 +125,7 @@
125
125
  "title": "Reboot Switch",
126
126
  "type": "boolean",
127
127
  "default": false,
128
- "description": "A switch that restarts this player. It springs back to off on its own and does nothing when switched off, so a scene or \"turn everything off\" cannot trigger it. Restarting interrupts playback. On a multi-zone chassis such as a CI S2 it restarts every zone on that box: BluOS reboots the box, not the zone, and there is no way to restart just this room."
128
+ "description": "A switch that reboots this player. It springs back to off on its own and does nothing when switched off, so a scene or \"turn everything off\" cannot trigger it. Rebooting interrupts playback. On a multi-zone chassis such as a CI S2 it reboots every zone on that box: BluOS reboots the box, not the zone, and there is no way to reboot just this room."
129
129
  },
130
130
  "volumePresets": {
131
131
  "title": "Volume Presets",
@@ -74,4 +74,12 @@ export interface AccessoryHost {
74
74
  * loud rather than letting someone discover it.
75
75
  */
76
76
  playersSharingAddress(deviceId: string): readonly string[];
77
+ /**
78
+ * A reboot request has reached this address, so silence from it is expected.
79
+ *
80
+ * The poller will fail while the box is down. Calling this is what stops every
81
+ * accessory on that address from warning, and what makes the next successful
82
+ * poll the source of truth for HomeKit instead of the pre-reboot reading.
83
+ */
84
+ expectReboot(host: string): void;
77
85
  }
@@ -51,7 +51,7 @@ class RebootAccessory extends base_accessory_1.BaseAccessory {
51
51
  .onSet(async (value) => this.writeOn(value));
52
52
  const shared = this.host.playersSharingAddress(this.deviceId);
53
53
  if (shared.length > 0) {
54
- this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)} will also restart ${shared.map(utils_1.forLog).join(', ')}: `
54
+ this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: will also reboot ${shared.map(utils_1.forLog).join(', ')}: `
55
55
  + 'they are zones of one chassis, and BluOS reboots the whole box');
56
56
  }
57
57
  }
@@ -67,6 +67,9 @@ class RebootAccessory extends base_accessory_1.BaseAccessory {
67
67
  }
68
68
  // The host only: reboot lives on port 80, not on the zone's control port.
69
69
  const result = await this.host.client.reboot(endpoint.host);
70
+ // The box is going down. Tell the platform so the other accessories
71
+ // stay quiet, and so the next poll is what HomeKit shows.
72
+ this.host.expectReboot(endpoint.host);
70
73
  // Never a group operation. Grouping decides where a *volume* change
71
74
  // reaches; a reboot restarts a box and has no notion of followers.
72
75
  this.logAction(result.acknowledged ? 'REBOOT' : 'REBOOT (sent; the player stopped answering, as expected)', { tellSlaves: false });
@@ -13,9 +13,10 @@
13
13
  *
14
14
  * Its reach is wider than the plugin's configuration: it restarts every player
15
15
  * mDNS answers for, including ones deliberately left out of `devices[]`. That is
16
- * what it is for, and it is why the option is off by default and why every target
17
- * is named in the log before a single request goes out. The BluOS API has no
18
- * authentication, so anything on the segment will comply.
16
+ * what it is for, and it is why the option is off by default. The info log is a
17
+ * count of devices and players; the debug log names every box before a single
18
+ * request goes out. The BluOS API has no authentication, so anything on the
19
+ * segment will comply.
19
20
  *
20
21
  * It works in addresses rather than players, because reboot is served on port 80
21
22
  * and port 80 is one server per chassis. A CI S2 carrying two zones is one
@@ -34,7 +35,7 @@ export declare class RebootAllAccessory extends BaseAccessory {
34
35
  private resetTimer;
35
36
  constructor(init: AccessoryInit);
36
37
  private writeOn;
37
- /** Name everything that is about to go down, before any of it does. */
38
+ /** Count at info, name every box at debug, before any request goes out. */
38
39
  private announce;
39
40
  /**
40
41
  * Restart every target, letting each succeed or fail on its own.
@@ -14,9 +14,10 @@
14
14
  *
15
15
  * Its reach is wider than the plugin's configuration: it restarts every player
16
16
  * mDNS answers for, including ones deliberately left out of `devices[]`. That is
17
- * what it is for, and it is why the option is off by default and why every target
18
- * is named in the log before a single request goes out. The BluOS API has no
19
- * authentication, so anything on the segment will comply.
17
+ * what it is for, and it is why the option is off by default. The info log is a
18
+ * count of devices and players; the debug log names every box before a single
19
+ * request goes out. The BluOS API has no authentication, so anything on the
20
+ * segment will comply.
20
21
  *
21
22
  * It works in addresses rather than players, because reboot is served on port 80
22
23
  * and port 80 is one server per chassis. A CI S2 carrying two zones is one
@@ -54,7 +55,7 @@ class RebootAllAccessory extends base_accessory_1.BaseAccessory {
54
55
  await this.completeWithinBudget('reboot all', async () => {
55
56
  const targets = await this.host.rebootTargets();
56
57
  if (targets.length === 0) {
57
- this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)} found nothing to restart. `
58
+ this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: found nothing to reboot. `
58
59
  + 'Multicast may be filtered on this network and no players are configured');
59
60
  return;
60
61
  }
@@ -66,13 +67,14 @@ class RebootAllAccessory extends base_accessory_1.BaseAccessory {
66
67
  this.scheduleReset();
67
68
  }
68
69
  }
69
- /** Name everything that is about to go down, before any of it does. */
70
+ /** Count at info, name every box at debug, before any request goes out. */
70
71
  announce(targets) {
72
+ const players = playerCount(targets);
73
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: found ${targets.length} device(s), ${players} player(s)`);
71
74
  const listed = targets
72
75
  .map((target) => `${target.host} (${target.names.map(utils_1.forLog).join(', ')})`)
73
76
  .join('; ');
74
- const players = targets.reduce((total, target) => total + target.names.length, 0);
75
- this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: rebooting ${targets.length} box(es) carrying `
77
+ this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: rebooting ${targets.length} box(es) carrying `
76
78
  + `${players} player(s): ${listed}`);
77
79
  }
78
80
  /**
@@ -89,15 +91,19 @@ class RebootAllAccessory extends base_accessory_1.BaseAccessory {
89
91
  let failed = 0;
90
92
  outcomes.forEach((outcome, index) => {
91
93
  const target = targets[index];
92
- if (outcome.status === 'fulfilled' || target === undefined) {
94
+ if (target === undefined) {
95
+ return;
96
+ }
97
+ if (outcome.status === 'fulfilled') {
98
+ this.host.expectReboot(target.host);
93
99
  return;
94
100
  }
95
101
  failed += 1;
96
- this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)} could not reboot ${target.host} `
102
+ this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: could not reboot ${target.host} `
97
103
  + `(${target.names.map(utils_1.forLog).join(', ')}): ${(0, utils_1.describeError)(outcome.reason)}`);
98
104
  });
99
- const restarted = targets.length - failed;
100
- this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: ${restarted} of ${targets.length} box(es) took the reboot`);
105
+ const rebooted = targets.length - failed;
106
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: ${rebooted} of ${targets.length} device(s) rebooted`);
101
107
  }
102
108
  /** Spring the tile back to off, the way a real button returns. */
103
109
  scheduleReset() {
@@ -131,3 +137,7 @@ class RebootAllAccessory extends base_accessory_1.BaseAccessory {
131
137
  }
132
138
  }
133
139
  exports.RebootAllAccessory = RebootAllAccessory;
140
+ /** How many player names sit behind the given boxes. */
141
+ function playerCount(targets) {
142
+ return targets.reduce((total, target) => total + target.names.length, 0);
143
+ }
@@ -51,6 +51,8 @@ export declare class BluOSPlatform implements DynamicPlatformPlugin, AccessoryHo
51
51
  private readonly staggerTimers;
52
52
  /** The launch address sweep, tracked so a shutdown can wait for it to end. */
53
53
  private launchSweep;
54
+ /** Addresses we have just asked to reboot, while silence from them is expected. */
55
+ private readonly rebootGrace;
54
56
  constructor(log: Logging, config: PlatformConfig, api: API);
55
57
  get hap(): API['hap'];
56
58
  /** Homebridge hands back every accessory it restored from disk. */
@@ -98,6 +100,16 @@ export declare class BluOSPlatform implements DynamicPlatformPlugin, AccessoryHo
98
100
  * not asked to manage.
99
101
  */
100
102
  playersSharingAddress(deviceId: string): readonly string[];
103
+ /**
104
+ * A reboot request has reached this address.
105
+ *
106
+ * In-flight long-polls are dropped so the next reading is of the player after
107
+ * it comes back, not of the request that died with the box. Failures during
108
+ * the grace window do not mark accessories unreachable.
109
+ */
110
+ expectReboot(host: string): void;
111
+ /** Current address of a player, preferring the poller when it has one. */
112
+ private hostOf;
101
113
  private start;
102
114
  private stop;
103
115
  /**
package/dist/platform.js CHANGED
@@ -58,6 +58,8 @@ class BluOSPlatform {
58
58
  staggerTimers = new Set();
59
59
  /** The launch address sweep, tracked so a shutdown can wait for it to end. */
60
60
  launchSweep;
61
+ /** Addresses we have just asked to reboot, while silence from them is expected. */
62
+ rebootGrace = new utils_1.RebootGrace(settings_1.REBOOT_GRACE_MS);
61
63
  constructor(log, config, api) {
62
64
  this.log = log;
63
65
  this.config = config;
@@ -198,6 +200,26 @@ class BluOSPlatform {
198
200
  && (this.endpointFor(device.id)?.host ?? device.host) === host)
199
201
  .map((device) => device.name);
200
202
  }
203
+ /**
204
+ * A reboot request has reached this address.
205
+ *
206
+ * In-flight long-polls are dropped so the next reading is of the player after
207
+ * it comes back, not of the request that died with the box. Failures during
208
+ * the grace window do not mark accessories unreachable.
209
+ */
210
+ expectReboot(host) {
211
+ this.rebootGrace.expect(host);
212
+ for (const [deviceId, poller] of this.pollers) {
213
+ if (this.hostOf(deviceId) === host) {
214
+ poller.refreshNow();
215
+ }
216
+ }
217
+ }
218
+ /** Current address of a player, preferring the poller when it has one. */
219
+ hostOf(deviceId) {
220
+ return this.endpointFor(deviceId)?.host
221
+ ?? this.devices.find((device) => device.id === deviceId)?.host;
222
+ }
201
223
  // --- Lifecycle ------------------------------------------------------------
202
224
  start() {
203
225
  const result = (0, utils_1.validateConfig)(this.config);
@@ -507,6 +529,10 @@ class BluOSPlatform {
507
529
  }
508
530
  }
509
531
  publish(deviceId, observation, reason) {
532
+ // Ends the window only after the box has already gone quiet. A last-gasp
533
+ // reading while the ports are still up must not cancel it, or the real
534
+ // outage that follows is logged as a surprise.
535
+ this.rebootGrace.clearIfRecovered(this.hostOf(deviceId));
510
536
  for (const handler of this.handlers.get(deviceId) ?? []) {
511
537
  try {
512
538
  handler.applyObservation(observation, reason);
@@ -517,6 +543,11 @@ class BluOSPlatform {
517
543
  }
518
544
  }
519
545
  reportUnavailable(deviceId, error) {
546
+ const host = this.hostOf(deviceId);
547
+ if (this.rebootGrace.isExpected(host)) {
548
+ this.rebootGrace.noteSilence(host);
549
+ return;
550
+ }
520
551
  for (const handler of this.handlers.get(deviceId) ?? []) {
521
552
  // Guarded exactly like publish. This runs from inside the poll loop's catch
522
553
  // block, so a throw here — a characteristic missing from a hand-edited
package/dist/poller.d.ts CHANGED
@@ -79,6 +79,14 @@ export declare class DevicePoller {
79
79
  * update available.
80
80
  */
81
81
  adoptWriteResult(result: VolumeResult): void;
82
+ /**
83
+ * Drop the request in flight and start a plain read.
84
+ *
85
+ * Used after a reboot: the long-poll is aimed at a box that is already going
86
+ * down, and waiting it out only delays the first reading of whatever state the
87
+ * player comes back in.
88
+ */
89
+ refreshNow(): void;
82
90
  /** Cancel the request in flight and wake any backoff sleep. */
83
91
  private interrupt;
84
92
  private run;
package/dist/poller.js CHANGED
@@ -145,6 +145,17 @@ class DevicePoller {
145
145
  this.options.onObservation(merged, 'post-set');
146
146
  this.interrupt();
147
147
  }
148
+ /**
149
+ * Drop the request in flight and start a plain read.
150
+ *
151
+ * Used after a reboot: the long-poll is aimed at a box that is already going
152
+ * down, and waiting it out only delays the first reading of whatever state the
153
+ * player comes back in.
154
+ */
155
+ refreshNow() {
156
+ this.etag = undefined;
157
+ this.interrupt();
158
+ }
148
159
  /** Cancel the request in flight and wake any backoff sleep. */
149
160
  interrupt() {
150
161
  this.abort?.abort();
@@ -99,6 +99,15 @@ export declare const REBOOT_TIMEOUT_MS = 3000;
99
99
  export declare const REBOOT_RESOURCE = "reboot";
100
100
  /** @see REBOOT_RESOURCE */
101
101
  export declare const REBOOT_FORM: Readonly<Record<string, string>>;
102
+ /**
103
+ * How long after a reboot request we treat silence as the player coming back.
104
+ *
105
+ * A reboot takes the control ports down with the box. Polls fail, and without
106
+ * this window every accessory on that box would warn "is not responding" and
107
+ * then info "is responding again" — which is exactly what a reboot looks like.
108
+ * After this window a still-silent player is logged the usual way.
109
+ */
110
+ export declare const REBOOT_GRACE_MS = 90000;
102
111
  /** Minimum spacing between control calls to one endpoint. */
103
112
  export declare const CONTROL_RATE_LIMIT_MS = 100;
104
113
  /** First reconnect delay after a failed poll. Doubles up to the ceiling. */
package/dist/settings.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * can tell a vendor requirement apart from a judgement call.
13
13
  */
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.MAX_NAME_LENGTH = exports.MAX_LOG_FIELD_LENGTH = exports.DISCOVERY_VERIFY_CONCURRENCY = exports.MAX_DISCOVERY_CANDIDATES = exports.MAX_DISCOVERY_RECORDS = exports.REDISCOVERY_MIN_INTERVAL_MS = exports.FAILURES_BEFORE_REDISCOVERY = exports.MAX_DISCOVERY_TIMEOUT_SEC = exports.MIN_DISCOVERY_TIMEOUT_SEC = exports.DEFAULT_DISCOVERY_TIMEOUT_SEC = exports.MDNS_SERVICE_SECONDARY = exports.MDNS_SERVICE_PRIMARY = exports.MAX_XML_ATTRIBUTES = exports.MAX_XML_ELEMENTS = exports.MAX_XML_DEPTH = exports.MAX_XML_BYTES = exports.MUTED_DB_SENTINEL = exports.FIXED_VOLUME_SENTINEL = exports.VOLUME_MAX = exports.VOLUME_MIN = exports.MOMENTARY_RESET_MS = exports.SLIDER_COALESCE_MS = exports.DEFAULT_RESTORE_VOLUME = exports.HOMEKIT_WRITE_BUDGET_MS = exports.POLL_FAILURE_REWARN_MS = exports.POLL_FAILURES_BEFORE_UNKNOWN = exports.POLL_BACKOFF_MAX_MS = exports.POLL_BACKOFF_BASE_MS = exports.CONTROL_RATE_LIMIT_MS = exports.REBOOT_FORM = exports.REBOOT_RESOURCE = exports.REBOOT_TIMEOUT_MS = exports.STATUS_TIMEOUT_MS = exports.CONTROL_TIMEOUT_MS = exports.CONNECT_TIMEOUT_MS = exports.SAME_RESOURCE_MIN_GAP_MS = exports.LONG_POLL_READ_SLACK_MS = exports.LONG_POLL_SEC = exports.MAX_PORT = exports.MIN_PORT = exports.DOCUMENTED_BLUOS_PORTS = exports.DEFAULT_BLUOS_PORT = exports.DEFAULT_MODEL = exports.DEFAULT_BRAND = exports.UNKNOWN_PLUGIN_VERSION = exports.PLATFORM_DEVICE_ID = exports.UUID_PREFIX = exports.PLATFORM_NAME = exports.PLUGIN_NAME = void 0;
15
+ exports.MAX_NAME_LENGTH = exports.MAX_LOG_FIELD_LENGTH = exports.DISCOVERY_VERIFY_CONCURRENCY = exports.MAX_DISCOVERY_CANDIDATES = exports.MAX_DISCOVERY_RECORDS = exports.REDISCOVERY_MIN_INTERVAL_MS = exports.FAILURES_BEFORE_REDISCOVERY = exports.MAX_DISCOVERY_TIMEOUT_SEC = exports.MIN_DISCOVERY_TIMEOUT_SEC = exports.DEFAULT_DISCOVERY_TIMEOUT_SEC = exports.MDNS_SERVICE_SECONDARY = exports.MDNS_SERVICE_PRIMARY = exports.MAX_XML_ATTRIBUTES = exports.MAX_XML_ELEMENTS = exports.MAX_XML_DEPTH = exports.MAX_XML_BYTES = exports.MUTED_DB_SENTINEL = exports.FIXED_VOLUME_SENTINEL = exports.VOLUME_MAX = exports.VOLUME_MIN = exports.MOMENTARY_RESET_MS = exports.SLIDER_COALESCE_MS = exports.DEFAULT_RESTORE_VOLUME = exports.HOMEKIT_WRITE_BUDGET_MS = exports.POLL_FAILURE_REWARN_MS = exports.POLL_FAILURES_BEFORE_UNKNOWN = exports.POLL_BACKOFF_MAX_MS = exports.POLL_BACKOFF_BASE_MS = exports.CONTROL_RATE_LIMIT_MS = exports.REBOOT_GRACE_MS = exports.REBOOT_FORM = exports.REBOOT_RESOURCE = exports.REBOOT_TIMEOUT_MS = exports.STATUS_TIMEOUT_MS = exports.CONTROL_TIMEOUT_MS = exports.CONNECT_TIMEOUT_MS = exports.SAME_RESOURCE_MIN_GAP_MS = exports.LONG_POLL_READ_SLACK_MS = exports.LONG_POLL_SEC = exports.MAX_PORT = exports.MIN_PORT = exports.DOCUMENTED_BLUOS_PORTS = exports.DEFAULT_BLUOS_PORT = exports.DEFAULT_MODEL = exports.DEFAULT_BRAND = exports.UNKNOWN_PLUGIN_VERSION = exports.PLATFORM_DEVICE_ID = exports.UUID_PREFIX = exports.PLATFORM_NAME = exports.PLUGIN_NAME = void 0;
16
16
  exports.readPluginVersion = readPluginVersion;
17
17
  /** npm package name. Must match `package.json` `name` for Homebridge to load us. */
18
18
  exports.PLUGIN_NAME = 'homebridge-bluos';
@@ -104,6 +104,15 @@ exports.REBOOT_TIMEOUT_MS = 3_000;
104
104
  exports.REBOOT_RESOURCE = 'reboot';
105
105
  /** @see REBOOT_RESOURCE */
106
106
  exports.REBOOT_FORM = { noheader: '0', yes: '1' };
107
+ /**
108
+ * How long after a reboot request we treat silence as the player coming back.
109
+ *
110
+ * A reboot takes the control ports down with the box. Polls fail, and without
111
+ * this window every accessory on that box would warn "is not responding" and
112
+ * then info "is responding again" — which is exactly what a reboot looks like.
113
+ * After this window a still-silent player is logged the usual way.
114
+ */
115
+ exports.REBOOT_GRACE_MS = 90_000;
107
116
  /** Minimum spacing between control calls to one endpoint. */
108
117
  exports.CONTROL_RATE_LIMIT_MS = 100;
109
118
  /** First reconnect delay after a failed poll. Doubles up to the ceiling. */
@@ -8,6 +8,7 @@
8
8
  */
9
9
  export * from './context';
10
10
  export * from './errors';
11
+ export * from './reboot-grace';
11
12
  export * from './serial';
12
13
  export * from './timing';
13
14
  export * from './validators';
@@ -24,6 +24,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
24
24
  Object.defineProperty(exports, "__esModule", { value: true });
25
25
  __exportStar(require("./context"), exports);
26
26
  __exportStar(require("./errors"), exports);
27
+ __exportStar(require("./reboot-grace"), exports);
27
28
  __exportStar(require("./serial"), exports);
28
29
  __exportStar(require("./timing"), exports);
29
30
  __exportStar(require("./validators"), exports);
@@ -0,0 +1,51 @@
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 A short window after a reboot request in which silence is expected.
8
+ *
9
+ * Reboot takes the box down. The poller then fails, and every accessory on that
10
+ * address would otherwise warn and later claim to have recovered. That is noise:
11
+ * we asked it to go down. This clock is what lets the platform stay quiet until
12
+ * the player answers again, or until the window ends and a real outage remains.
13
+ *
14
+ * A reading that arrives before the box actually drops is not recovery. The
15
+ * control ports often answer one more time after POST /reboot. That last gasp
16
+ * must not end the window, or the real outage that follows is logged as usual.
17
+ * Recovery is a successful poll after we have already seen the expected silence.
18
+ */
19
+ /** Hosts we have just asked to reboot, and when that expectation expires. */
20
+ export declare class RebootGrace {
21
+ private readonly graceMs;
22
+ private readonly now;
23
+ private readonly expiresAt;
24
+ /** Addresses that have gone quiet at least once inside their window. */
25
+ private readonly sawSilence;
26
+ constructor(graceMs: number, now?: () => number);
27
+ /** Start (or refresh) the quiet window for this address. */
28
+ expect(host: string): void;
29
+ /**
30
+ * Record that this address failed while a reboot was still expected.
31
+ *
32
+ * The next successful poll is then treated as the player coming back, not as
33
+ * a last gasp from a box that has not gone down yet.
34
+ */
35
+ noteSilence(host: string | undefined): void;
36
+ /**
37
+ * End the window if this address already went quiet and has now answered.
38
+ *
39
+ * A success with no prior silence is ignored: the box has not dropped yet.
40
+ */
41
+ clearIfRecovered(host: string | undefined): void;
42
+ /**
43
+ * End the window now.
44
+ *
45
+ * Used when the clock runs out, and by tests. Recovery goes through
46
+ * {@link clearIfRecovered} so a last-gasp reading cannot cancel the window.
47
+ */
48
+ clear(host: string | undefined): void;
49
+ /** True while a reboot of this address is still expected to look like an outage. */
50
+ isExpected(host: string | undefined): boolean;
51
+ }
@@ -0,0 +1,87 @@
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 A short window after a reboot request in which silence is expected.
9
+ *
10
+ * Reboot takes the box down. The poller then fails, and every accessory on that
11
+ * address would otherwise warn and later claim to have recovered. That is noise:
12
+ * we asked it to go down. This clock is what lets the platform stay quiet until
13
+ * the player answers again, or until the window ends and a real outage remains.
14
+ *
15
+ * A reading that arrives before the box actually drops is not recovery. The
16
+ * control ports often answer one more time after POST /reboot. That last gasp
17
+ * must not end the window, or the real outage that follows is logged as usual.
18
+ * Recovery is a successful poll after we have already seen the expected silence.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.RebootGrace = void 0;
22
+ /** Hosts we have just asked to reboot, and when that expectation expires. */
23
+ class RebootGrace {
24
+ graceMs;
25
+ now;
26
+ expiresAt = new Map();
27
+ /** Addresses that have gone quiet at least once inside their window. */
28
+ sawSilence = new Set();
29
+ constructor(graceMs, now = Date.now) {
30
+ this.graceMs = graceMs;
31
+ this.now = now;
32
+ }
33
+ /** Start (or refresh) the quiet window for this address. */
34
+ expect(host) {
35
+ this.expiresAt.set(host, this.now() + this.graceMs);
36
+ this.sawSilence.delete(host);
37
+ }
38
+ /**
39
+ * Record that this address failed while a reboot was still expected.
40
+ *
41
+ * The next successful poll is then treated as the player coming back, not as
42
+ * a last gasp from a box that has not gone down yet.
43
+ */
44
+ noteSilence(host) {
45
+ if (host !== undefined && this.isExpected(host)) {
46
+ this.sawSilence.add(host);
47
+ }
48
+ }
49
+ /**
50
+ * End the window if this address already went quiet and has now answered.
51
+ *
52
+ * A success with no prior silence is ignored: the box has not dropped yet.
53
+ */
54
+ clearIfRecovered(host) {
55
+ if (host !== undefined && this.sawSilence.has(host)) {
56
+ this.clear(host);
57
+ }
58
+ }
59
+ /**
60
+ * End the window now.
61
+ *
62
+ * Used when the clock runs out, and by tests. Recovery goes through
63
+ * {@link clearIfRecovered} so a last-gasp reading cannot cancel the window.
64
+ */
65
+ clear(host) {
66
+ if (host !== undefined) {
67
+ this.expiresAt.delete(host);
68
+ this.sawSilence.delete(host);
69
+ }
70
+ }
71
+ /** True while a reboot of this address is still expected to look like an outage. */
72
+ isExpected(host) {
73
+ if (host === undefined) {
74
+ return false;
75
+ }
76
+ const expires = this.expiresAt.get(host);
77
+ if (expires === undefined) {
78
+ return false;
79
+ }
80
+ if (this.now() >= expires) {
81
+ this.clear(host);
82
+ return false;
83
+ }
84
+ return true;
85
+ }
86
+ }
87
+ exports.RebootGrace = RebootGrace;
package/docs/FEATURES.md CHANGED
@@ -12,8 +12,8 @@ A checklist of what is built. The plugin aims to cover everything about a BluOS
12
12
  - ✅ Mute switch, with unmute restoring the level the player remembered instead of a guess
13
13
  - ✅ Volume preset switches: one exact level per switch, addressable by name with Siri; set On sets the level, set Off is a no-op
14
14
  - ✅ Battery sensor (level, charging state, low-battery) for players with a battery pack fitted
15
- - ✅ Reboot switch per player, momentary so a scene or "turn everything off" cannot restart the stereo, and still pressable when the player has stopped answering. Restarts the whole box on a multi-zone chassis, which BluOS gives no way to avoid, and warns at startup when that means other configured rooms
16
- - ✅ Optional "reboot all" switch that restarts every BluOS player on the network, naming each one in the log before anything is sent, and sending once per box instead of once per zone. You choose its name with `options.rebootAllName`, so it can sit in whichever Home app room suits you
15
+ - ✅ Reboot switch per player, momentary so a scene or "turn everything off" cannot reboot the stereo, and still pressable when the player has stopped answering. Reboots the whole box on a multi-zone chassis, which BluOS gives no way to avoid, and warns at startup when that means other configured rooms
16
+ - ✅ Optional "reboot all" switch that reboots every BluOS player on the network, sending once per box instead of once per zone. The info log is a count (`found … device(s), … player(s)`), then `N of N device(s) rebooted`. The debug log names each box before anything is sent. Accessories on a box that was just rebooted stay quiet until it answers again, then take its live state. You choose its name with `options.rebootAllName`, so it can sit in whichever Home app room suits you
17
17
  - ✅ Fixed-output players detected from `volume="-1"` and given no slider, with one explanatory log line
18
18
  - ✅ Multi-zone chassis support (NAD CI S2, CI 580): each zone is a separate player on its own port
19
19
  - ✅ mDNS discovery in the settings page, with manual address entry for networks that filter multicast
@@ -89,7 +89,7 @@ HomeKit has no way to render a library or a queue, and no vocabulary for "play t
89
89
 
90
90
  | Configuration | HomeKit service |
91
91
  | --- | --- |
92
- | `options.rebootAll` | Switch, momentary. One for the whole install, restarting every box it can find. Named by `options.rebootAllName` |
92
+ | `options.rebootAll` | Switch, momentary. One for the whole install, rebooting every box it can find. Named by `options.rebootAllName` |
93
93
 
94
94
  ## Protocol surface
95
95
 
@@ -38,9 +38,9 @@ Where HomeKit is genuinely better is the tile, the scene, the automation and the
38
38
 
39
39
  **Battery sensor.** Charge level, charging state and low-battery warning, for players with a battery pack fitted (PULSE FLEX with BP100, PULSE M).
40
40
 
41
- **Reboot switch.** Restarts the player. It springs back to off and ignores being switched off, so a scene or "turn everything off" cannot restart your stereo. It stays pressable while a player is showing No Response, which is exactly when you want it.
41
+ **Reboot switch.** Reboots the player. It springs back to off and ignores being switched off, so a scene or "turn everything off" cannot reboot your stereo. It stays pressable while a player is showing No Response, which is exactly when you want it.
42
42
 
43
- On a multi-zone chassis it restarts both zones. BluOS serves reboot on the box's own web server, not on a zone's control port, so "Study Reboot" on a CI S2 also takes down the other room. There is no way to avoid this. The plugin names the other affected rooms in the log at startup.
43
+ On a multi-zone chassis it reboots both zones. BluOS serves reboot on the box's own web server, not on a zone's control port, so "Study Reboot" on a CI S2 also takes down the other room. There is no way to avoid this. At startup the plugin warns, naming the other affected rooms.
44
44
 
45
45
  **Grouping awareness.** A zone that is leading a BluOS group moves the whole group, exactly as its slider does in the BluOS app. Every other zone moves alone. The scope is stated explicitly on every write, never left to the firmware default.
46
46
 
@@ -48,9 +48,9 @@ On a multi-zone chassis it restarts both zones. BluOS serves reboot on the box's
48
48
 
49
49
  ### For the whole install
50
50
 
51
- **Reboot all.** Off by default. One switch that restarts **every BluOS player it can find on the network**, not only the ones listed in your configuration. It sweeps with mDNS and adds your configured players, so it still works where multicast is filtered.
51
+ **Reboot all.** Off by default. One switch that reboots **every BluOS player it can find on the network**, not only the ones listed in your configuration. It sweeps with mDNS and adds your configured players, so it still works where multicast is filtered.
52
52
 
53
- Every player is named in the Homebridge log before a single request goes out. One press sends one restart per box, so a multi-zone chassis restarts once and takes all of its zones with it. A box that cannot be reached is reported, and the rest still go.
53
+ The info log is a count (`found 2 device(s), 3 player(s)`), then `2 of 2 device(s) rebooted`. The debug log names every box and the players on it before a request goes out. One press sends one reboot per box, so a multi-zone chassis reboots once and takes all of its zones with it. A box that cannot be reached is a warning (`could not reboot`), and the rest still go.
54
54
 
55
55
  Name it with `options.rebootAllName`. It is the one accessory with no room of its own, so you choose where it lives in the Home app.
56
56
 
@@ -88,7 +88,7 @@ Only one `BluOS` platform block is supported (`singular` in the schema). It can
88
88
  | `devices` | ✓ | List of players. An empty list is allowed and warns. A missing or non-list value is an error |
89
89
  | `options.sliderService` | | `fan` (default) or `lightbulb`, for every slider |
90
90
  | `options.discoveryTimeoutSec` | | mDNS listening window, 1–30 seconds (default 5) |
91
- | `options.rebootAll` | | Expose one switch that restarts **every BluOS player on the network** (default false). Its reach is not limited to `devices[]` |
91
+ | `options.rebootAll` | | Expose one switch that reboots **every BluOS player on the network** (default false). Its reach is not limited to `devices[]` |
92
92
  | `options.rebootAllName` | | What that switch is called in the Home app, so you can keep it in the room you want. Defaults to the plugin name followed by `Reboot All` |
93
93
 
94
94
  ### `devices[]` entries
@@ -103,7 +103,7 @@ Only one `BluOS` platform block is supported (`singular` in the schema). It can
103
103
  | `sliderService` | | Override the platform slider style for this player. Empty means "use the platform setting". Changing it removes the old control from the accessory instead of leaving both |
104
104
  | `mute` | | Expose a mute switch (default false) |
105
105
  | `battery` | | Expose a battery sensor (default false, and only meaningful with a battery pack) |
106
- | `reboot` | | Expose a switch that restarts this player (default false). Restarting interrupts playback, and on a multi-zone chassis it restarts every zone on that box |
106
+ | `reboot` | | Expose a switch that reboots this player (default false). Rebooting interrupts playback, and on a multi-zone chassis it reboots every zone on that box |
107
107
  | `volumePresets[]` | | `{ "name": "...", "volume": 0-100 }`. Duplicate levels on one player are skipped with a warning |
108
108
 
109
109
  ### A complete example
@@ -188,6 +188,29 @@ A HomeKit write that reached the player:
188
188
 
189
189
  Mute `ON` means muted. `(group)` means that zone is leading a BluOS group, so the write carried the followers.
190
190
 
191
+ A Reboot All press. Info is the count and the result. Debug names every box.
192
+
193
+ ```text
194
+ [BluOS] Downstairs Reboot: found 2 device(s), 3 player(s)
195
+ [BluOS] Downstairs Reboot: rebooting 2 box(es) carrying 3 player(s): 192.168.4.11 (Zone One, Zone Two); 192.168.4.12 (Kitchen)
196
+ [BluOS] Downstairs Reboot: 2 of 2 device(s) rebooted
197
+ ```
198
+
199
+ The middle line is debug. A box that did not take the request is a warning:
200
+
201
+ ```text
202
+ [BluOS] Downstairs Reboot: could not reboot 192.168.4.11 (Zone One, Zone Two): connect EHOSTUNREACH
203
+ [BluOS] Downstairs Reboot: 1 of 2 device(s) rebooted
204
+ ```
205
+
206
+ A per-player reboot switch on a shared chassis, at startup:
207
+
208
+ ```text
209
+ [BluOS] Zone One Reboot: will also reboot Zone Two: they are zones of one chassis, and BluOS reboots the whole box
210
+ ```
211
+
212
+ After a reboot the other accessories stay quiet. The player is expected to stop answering, so they do not log `is not responding` or `is responding again`. A reading that arrives before the box actually drops is ignored for that purpose: the control ports often answer once more after the reboot is sent. The first successful poll after the silence writes whatever the player is actually doing into HomeKit. A player that is still silent after that window is logged as not responding, the usual way.
213
+
191
214
  A configuration the plugin will not act on:
192
215
 
193
216
  ```text
@@ -232,9 +255,9 @@ Ordered by how well HomeKit expresses the thing, not by how easy it is to build.
232
255
  6. **A zone on a multi-zone chassis is missing.** Check the port. Zone two is 11010, not 11000
233
256
  7. **The volume moved but the slider did not, for a second.** A change made on the player takes one long-poll round trip to arrive. A change made from HomeKit is immediate
234
257
  8. **One zone's slider moved several rooms.** That zone is currently leading a BluOS group, so it carries its followers, the same as its slider in the BluOS app. Ungroup in the BluOS app and it goes back to moving alone
235
- 9. **The reboot switch turns itself off.** This is by design. It is a button, not a state: it fires when switched on, then springs back. Switching it off does nothing, which is what stops a scene or "turn everything off" from restarting your stereo
236
- 10. **Reboot All restarted a player you did not configure.** Also by design, and the reason it is off by default. It sweeps the network instead of reading `devices[]`. The log lists every player it is about to restart, by name and address
237
- 11. **A reboot switch restarted the room next door.** Expect this on a multi-zone chassis such as a CI S2. BluOS serves `/reboot` on the box's own web server, not on a zone's control port, so there is no way to restart one zone of a shared box. The plugin logs a warning naming the other rooms when it starts
258
+ 9. **The reboot switch turns itself off.** This is by design. It is a button, not a state: it fires when switched on, then springs back. Switching it off does nothing, which is what stops a scene or "turn everything off" from rebooting your stereo
259
+ 10. **Reboot All rebooted a player you did not configure.** Also by design, and the reason it is off by default. It sweeps the network instead of reading `devices[]`. The info log is a count (`found … device(s), … player(s)`); the debug log lists every player by name and address
260
+ 11. **A reboot switch rebooted the room next door.** Expect this on a multi-zone chassis such as a CI S2. BluOS serves `/reboot` on the box's own web server, not on a zone's control port, so there is no way to reboot one zone of a shared box. At startup the plugin warns, naming the other rooms
238
261
  12. **100 is louder than you ever want.** Set the limit on the player, in the BluOS app's settings for it. The wording varies by model: a volume limit on Bluesound players, a maximum volume on NAD amplifiers. The plugin deliberately has no ceiling of its own, so a limit set on the player is enforced by the hardware for every controller. No HomeKit automation or misheard Siri phrase can exceed it, and a second limit here could only disagree with the first
239
262
  13. Restart Homebridge after editing `config.json` by hand
240
263
 
@@ -378,7 +378,7 @@
378
378
  (value) => { player.battery = value },
379
379
  ))
380
380
  options.append(checkbox(
381
- 'Reboot switch (restarts this player, and any zone sharing its box)',
381
+ 'Reboot switch (reboots this player, and any zone sharing its box)',
382
382
  player.reboot,
383
383
  false,
384
384
  (value) => { player.reboot = value },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-bluos",
3
3
  "displayName": "Homebridge BluOS",
4
- "version": "1.1.0",
4
+ "version": "1.1.2",
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",