homebridge-bluos 2.0.1 → 2.0.3

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
+ ## [2.0.3](https://github.com/tbaur/homebridge-bluos/compare/v2.0.2...v2.0.3) (2026-09-13)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * skip public and loopback addresses in mDNS discovery ([#46](https://github.com/tbaur/homebridge-bluos/issues/46)) ([9010b86](https://github.com/tbaur/homebridge-bluos/commit/9010b861a95f8d11e450c7c3d877f1f243c2f01d))
9
+
10
+ ## [2.0.2](https://github.com/tbaur/homebridge-bluos/compare/v2.0.1...v2.0.2) (2026-09-10)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * stop one reboot press from becoming several reboot waves ([#44](https://github.com/tbaur/homebridge-bluos/issues/44)) ([13661ec](https://github.com/tbaur/homebridge-bluos/commit/13661ecbc837366baebfd1bfee6489609dab986c))
16
+
3
17
  ## [2.0.1](https://github.com/tbaur/homebridge-bluos/compare/v2.0.0...v2.0.1) (2026-09-05)
4
18
 
5
19
 
package/README.md CHANGED
@@ -56,7 +56,7 @@ Nothing needs enabling on the player, because the BluOS LAN API is always on. A
56
56
 
57
57
  ### 3. Configure
58
58
 
59
- **Homebridge UI** (recommended): open the plugin settings and press **Discover Players**. Every zone that answers is listed. Tick the players and accessories you want, then press the Homebridge Save button.
59
+ **Homebridge UI** (recommended): open the plugin settings and press **Discover Players**. Every zone that answers on a local IPv4 address is listed. Public and loopback advertisements are skipped. Tick the players and accessories you want, then press the Homebridge Save button.
60
60
 
61
61
  Or in `config.json`:
62
62
 
package/SECURITY.md CHANGED
@@ -16,8 +16,8 @@ Do not open a public issue. Use GitHub's [private vulnerability reporting](https
16
16
  - **Input validation:** Config is checked at startup. A missing or non-list `devices` value disables the platform without unregistering accessories. A bad player or preset is skipped. Hosts, ports and timeouts are rejected or clamped.
17
17
  - **Log safety:** Values written to logs have control characters stripped and are length-limited.
18
18
  - **Bounded I/O:** Connect and total timeouts on every request, responses capped at 128 KiB, and XML parsed with size, depth and element caps.
19
- - **Discovery:** mDNS errors are caught so they cannot take Homebridge down. Browse results are capped.
20
- - **Settings probe:** Accepts only a private or local address and a documented BluOS port.
19
+ - **Discovery:** mDNS errors are caught so they cannot take Homebridge down. Browse results are capped. Advertised addresses are verified only when they are local (RFC 1918, CGNAT/Tailscale, link-local). Public IPv4 and loopback are skipped, so an advertisement cannot make Homebridge open `/SyncStatus` on itself or on the public internet. Players already in configuration are unchanged.
20
+ - **Settings probe:** Accepts only a private or local address and a documented BluOS port. Loopback is allowed here because an administrator asked for that probe.
21
21
  - **Dependencies:** CI runs `npm audit` on the runtime tree and OSV-Scanner on the full tree.
22
22
 
23
23
  ## Best Practices for Users
@@ -112,10 +112,12 @@ export declare class BluOSDiscovery {
112
112
  /**
113
113
  * Turn service instances into addressable endpoints.
114
114
  *
115
- * A zone is only usable once its SRV record (for the port) and an IPv4 address
116
- * are both known. The address comes from an A record when one was offered, and
117
- * otherwise from the responder's own source address, which for a player
118
- * advertising its own service is the same machine.
115
+ * A zone is only usable once its SRV record (for the port) and a local IPv4
116
+ * address are both known (RFC 1918, CGNAT / Tailscale, link-local). Public
117
+ * IPv4 and loopback are ignored, so an advertisement cannot point verify at
118
+ * Homebridge or at an off-network host. The address comes from an A record
119
+ * when one was offered, and otherwise from the responder's own source
120
+ * address, which for a player advertising its own service is the same machine.
119
121
  */
120
122
  private toEndpoints;
121
123
  /** Collect mDNS records for the configured window. */
@@ -35,6 +35,7 @@ const node_os_1 = __importDefault(require("node:os"));
35
35
  const settings_1 = require("../settings");
36
36
  const errors_1 = require("../utils/errors");
37
37
  const timing_1 = require("../utils/timing");
38
+ const validators_1 = require("../utils/validators");
38
39
  const identity_1 = require("./identity");
39
40
  /** Re-query schedule, in milliseconds, to survive dropped multicast packets. */
40
41
  const QUERY_SCHEDULE_MS = [0, 400, 1_200];
@@ -202,10 +203,12 @@ class BluOSDiscovery {
202
203
  /**
203
204
  * Turn service instances into addressable endpoints.
204
205
  *
205
- * A zone is only usable once its SRV record (for the port) and an IPv4 address
206
- * are both known. The address comes from an A record when one was offered, and
207
- * otherwise from the responder's own source address, which for a player
208
- * advertising its own service is the same machine.
206
+ * A zone is only usable once its SRV record (for the port) and a local IPv4
207
+ * address are both known (RFC 1918, CGNAT / Tailscale, link-local). Public
208
+ * IPv4 and loopback are ignored, so an advertisement cannot point verify at
209
+ * Homebridge or at an off-network host. The address comes from an A record
210
+ * when one was offered, and otherwise from the responder's own source
211
+ * address, which for a player advertising its own service is the same machine.
209
212
  */
210
213
  toEndpoints(candidates) {
211
214
  const seen = new Set();
@@ -217,6 +220,13 @@ class BluOSDiscovery {
217
220
  this.log.debug(`discovery: no IPv4 address for ${candidate.instance} (target ${candidate.target})`);
218
221
  continue;
219
222
  }
223
+ if (!(0, validators_1.isDiscoveryHost)(host)) {
224
+ const reason = (0, validators_1.isLoopbackIpv4)(host)
225
+ ? 'loopback is not accepted from mDNS'
226
+ : 'not a local address';
227
+ this.log.debug(`discovery: skipping ${host} (${reason})`);
228
+ continue;
229
+ }
220
230
  const key = (0, identity_1.formatEndpoint)(host, candidate.port);
221
231
  if (seen.has(key)) {
222
232
  continue;
@@ -82,4 +82,13 @@ export interface AccessoryHost {
82
82
  * poll the source of truth for HomeKit instead of the pre-reboot reading.
83
83
  */
84
84
  expectReboot(host: string): void;
85
+ /**
86
+ * True while this address is still expected to be down from a reboot we sent.
87
+ *
88
+ * Asked before a reboot goes out, not only when one fails. A box part-way
89
+ * through a restart has no web server on port 80, so a second request cannot
90
+ * reach it and its failure says nothing about whether the first one worked.
91
+ * Sending anyway is what turns one press into a page of `could not reboot`.
92
+ */
93
+ isRebooting(host: string): boolean;
85
94
  }
@@ -8,13 +8,20 @@
8
8
  *
9
9
  * Two departures from how every other accessory here behaves, both deliberate.
10
10
  *
11
- * It is momentary, never stateful. `On` always reads false, turning it on fires
12
- * the reboot and the tile springs back, and turning it off does nothing. There is
13
- * no such thing as an un-reboot, so an off has nothing to mean. This is also what
14
- * makes the switch safe to leave in a house full of scenes: "turn everything off"
15
- * and a scene that sets switches off both write false, and false does nothing
16
- * here. A stateful reboot switch would restart the stereo every time someone said
17
- * goodnight.
11
+ * It is momentary, never stateful. Turning it on fires the reboot, the tile
12
+ * springs back once the request has been sent, and turning it off does nothing.
13
+ * There is no such thing as an un-reboot, so an off has nothing to mean. This is
14
+ * also what makes the switch safe to leave in a house full of scenes: "turn
15
+ * everything off" and a scene that sets switches off both write false, and false
16
+ * does nothing here. A stateful reboot switch would restart the stereo every time
17
+ * someone said goodnight.
18
+ *
19
+ * `On` reads true only while a press this switch started is still being sent.
20
+ * That is a fact about this switch rather than a reading off the player, so it
21
+ * does not break the rule against inventing state. It matters because a HomeKit
22
+ * write must answer inside the write budget while the reboot itself can take
23
+ * longer: a tile that springs back before anything is logged looks like a press
24
+ * that did nothing, and gets pressed again.
18
25
  *
19
26
  * It stays pressable when the player is unreachable, which breaks the plugin's
20
27
  * "unknown is No Response" rule. That rule exists so automations cannot fire
@@ -36,9 +43,19 @@ import { BaseAccessory, type AccessoryInit } from './base-accessory';
36
43
  export declare class RebootAccessory extends BaseAccessory {
37
44
  private readonly service;
38
45
  private resetTimer;
46
+ /** True from a press until the tile springs back. @see writeOn */
47
+ private rebooting;
39
48
  constructor(init: AccessoryInit);
40
49
  private writeOn;
41
- /** Spring the tile back to off, the way a real button returns. */
50
+ /** Send the reboot, unless this box is already on its way down. */
51
+ private sendReboot;
52
+ /**
53
+ * Spring the tile back to off, the way a real button returns.
54
+ *
55
+ * Clearing {@link rebooting} here rather than when the work finishes keeps the
56
+ * reported value and the pushed value in step: HomeKit is told off at the same
57
+ * moment a read would start answering off.
58
+ */
42
59
  private scheduleReset;
43
60
  /**
44
61
  * Nothing to apply.
@@ -9,13 +9,20 @@
9
9
  *
10
10
  * Two departures from how every other accessory here behaves, both deliberate.
11
11
  *
12
- * It is momentary, never stateful. `On` always reads false, turning it on fires
13
- * the reboot and the tile springs back, and turning it off does nothing. There is
14
- * no such thing as an un-reboot, so an off has nothing to mean. This is also what
15
- * makes the switch safe to leave in a house full of scenes: "turn everything off"
16
- * and a scene that sets switches off both write false, and false does nothing
17
- * here. A stateful reboot switch would restart the stereo every time someone said
18
- * goodnight.
12
+ * It is momentary, never stateful. Turning it on fires the reboot, the tile
13
+ * springs back once the request has been sent, and turning it off does nothing.
14
+ * There is no such thing as an un-reboot, so an off has nothing to mean. This is
15
+ * also what makes the switch safe to leave in a house full of scenes: "turn
16
+ * everything off" and a scene that sets switches off both write false, and false
17
+ * does nothing here. A stateful reboot switch would restart the stereo every time
18
+ * someone said goodnight.
19
+ *
20
+ * `On` reads true only while a press this switch started is still being sent.
21
+ * That is a fact about this switch rather than a reading off the player, so it
22
+ * does not break the rule against inventing state. It matters because a HomeKit
23
+ * write must answer inside the write budget while the reboot itself can take
24
+ * longer: a tile that springs back before anything is logged looks like a press
25
+ * that did nothing, and gets pressed again.
19
26
  *
20
27
  * It stays pressable when the player is unreachable, which breaks the plugin's
21
28
  * "unknown is No Response" rule. That rule exists so automations cannot fire
@@ -40,6 +47,8 @@ const base_accessory_1 = require("./base-accessory");
40
47
  class RebootAccessory extends base_accessory_1.BaseAccessory {
41
48
  service;
42
49
  resetTimer;
50
+ /** True from a press until the tile springs back. @see writeOn */
51
+ rebooting = false;
43
52
  constructor(init) {
44
53
  super(init);
45
54
  const { Characteristic: Char, Service: HapService } = this.host.hap;
@@ -47,7 +56,7 @@ class RebootAccessory extends base_accessory_1.BaseAccessory {
47
56
  this.service.setCharacteristic(Char.Name, this.displayName);
48
57
  this.service
49
58
  .getCharacteristic(Char.On)
50
- .onGet(() => false)
59
+ .onGet(() => this.rebooting)
51
60
  .onSet(async (value) => this.writeOn(value));
52
61
  const shared = this.host.playersSharingAddress(this.deviceId);
53
62
  if (shared.length > 0) {
@@ -59,35 +68,62 @@ class RebootAccessory extends base_accessory_1.BaseAccessory {
59
68
  if (value !== true) {
60
69
  return;
61
70
  }
62
- try {
63
- await this.completeWithinBudget('reboot', async () => {
64
- const endpoint = this.host.endpointFor(this.deviceId);
65
- if (endpoint === undefined) {
66
- throw new Error('player is no longer configured');
67
- }
68
- // The host only: reboot lives on port 80, not on the zone's control port.
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);
73
- // Never a group operation. Grouping decides where a *volume* change
74
- // reaches; a reboot restarts a box and has no notion of followers.
75
- this.logAction(result.acknowledged ? 'REBOOT' : 'REBOOT (sent; the player stopped answering, as expected)', { tellSlaves: false });
76
- });
71
+ if (this.rebooting) {
72
+ // A press that arrives while one is still being sent is a duplicate, not a
73
+ // second instruction: the box can only be restarted once.
74
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: a restart is already under way; ignoring this press`);
75
+ return;
76
+ }
77
+ this.rebooting = true;
78
+ await this.completeWithinBudget('reboot', async () => {
79
+ try {
80
+ await this.sendReboot();
81
+ }
82
+ finally {
83
+ // Inside the work rather than around the budget, so the tile springs back
84
+ // when the reboot is actually done instead of when HomeKit stopped
85
+ // waiting for it. A failed reboot resets too: a tile left on would
86
+ // suggest something is still happening.
87
+ this.scheduleReset();
88
+ }
89
+ });
90
+ }
91
+ /** Send the reboot, unless this box is already on its way down. */
92
+ async sendReboot() {
93
+ const endpoint = this.host.endpointFor(this.deviceId);
94
+ if (endpoint === undefined) {
95
+ throw new Error('player is no longer configured');
77
96
  }
78
- finally {
79
- // In `finally` because a failed reboot must not leave the tile stuck on.
80
- // The write already surfaced its own error to HomeKit and to the log.
81
- this.scheduleReset();
97
+ if (this.host.isRebooting(endpoint.host)) {
98
+ // Not an error, and deliberately not a second request. Port 80 is down
99
+ // while the box boots, so this would fail and be reported as a reboot that
100
+ // did not work, when in fact one is in progress.
101
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: ${endpoint.host} is already restarting; nothing sent`);
102
+ return;
82
103
  }
104
+ // The host only: reboot lives on port 80, not on the zone's control port.
105
+ const result = await this.host.client.reboot(endpoint.host);
106
+ // The box is going down. Tell the platform so the other accessories
107
+ // stay quiet, and so the next poll is what HomeKit shows.
108
+ this.host.expectReboot(endpoint.host);
109
+ // Never a group operation. Grouping decides where a *volume* change
110
+ // reaches; a reboot restarts a box and has no notion of followers.
111
+ this.logAction(result.acknowledged ? 'REBOOT' : 'REBOOT (sent; the player stopped answering, as expected)', { tellSlaves: false });
83
112
  }
84
- /** Spring the tile back to off, the way a real button returns. */
113
+ /**
114
+ * Spring the tile back to off, the way a real button returns.
115
+ *
116
+ * Clearing {@link rebooting} here rather than when the work finishes keeps the
117
+ * reported value and the pushed value in step: HomeKit is told off at the same
118
+ * moment a read would start answering off.
119
+ */
85
120
  scheduleReset() {
86
121
  if (this.resetTimer !== undefined) {
87
122
  clearTimeout(this.resetTimer);
88
123
  }
89
124
  this.resetTimer = setTimeout(() => {
90
125
  this.resetTimer = undefined;
126
+ this.rebooting = false;
91
127
  this.service.updateCharacteristic(this.host.hap.Characteristic.On, false);
92
128
  }, settings_1.MOMENTARY_RESET_MS);
93
129
  // Nothing is waiting on this, so it must not hold Homebridge open at shutdown.
@@ -26,6 +26,14 @@
26
26
  * See RebootAccessory for why this is momentary and why it stays pressable when
27
27
  * players are unreachable; the same reasoning applies, more so here, since a
28
28
  * fleet-wide restart is most useful when several players have stopped answering.
29
+ *
30
+ * One press is one wave, and a wave outlasts the HomeKit write budget by a good
31
+ * margin: the sweep alone runs for the discovery window before a single request
32
+ * goes out. So the tile is held on for as long as the wave runs, and a press that
33
+ * arrives while one is running is ignored rather than queued. Without both, a
34
+ * press looks like it did nothing, gets repeated, and each repeat sweeps a fleet
35
+ * that is now half way through restarting — which finds fewer boxes every time
36
+ * and reports the ones it does find as failures.
29
37
  */
30
38
  import type { PlayerObservation, RefreshReason } from '../types';
31
39
  import { BaseAccessory, type AccessoryInit } from './base-accessory';
@@ -33,8 +41,12 @@ import { BaseAccessory, type AccessoryInit } from './base-accessory';
33
41
  export declare class RebootAllAccessory extends BaseAccessory {
34
42
  private readonly service;
35
43
  private resetTimer;
44
+ /** True from a press until the tile springs back. @see writeOn */
45
+ private rebooting;
36
46
  constructor(init: AccessoryInit);
37
47
  private writeOn;
48
+ /** Sweep for targets, then restart every one that is not already going down. */
49
+ private runWave;
38
50
  /** Count at info, name every box at debug, before any request goes out. */
39
51
  private announce;
40
52
  /**
@@ -45,9 +57,30 @@ export declare class RebootAllAccessory extends BaseAccessory {
45
57
  * single dead address delay every box behind it by a full timeout.
46
58
  * `allSettled` because one failure must not abandon the rest — a fleet-wide
47
59
  * restart that stopped at the first missing player would be worse than useless.
60
+ *
61
+ * Addresses already inside their reboot grace window are left alone. Nothing
62
+ * serves port 80 while a box boots, so a request there could only fail, and
63
+ * reporting that as `could not reboot` states the opposite of what is true.
48
64
  */
49
65
  private rebootAll;
50
- /** Spring the tile back to off, the way a real button returns. */
66
+ /** Split targets into those still to restart and those already restarting. */
67
+ private partition;
68
+ /**
69
+ * Log one failed reboot, and say whether it counts against the total.
70
+ *
71
+ * A box that entered its grace window after the check above — because the
72
+ * per-player switch was pressed, or an earlier wave reached it — is going down
73
+ * already. A refused or unanswered port 80 is what that looks like, so it is a
74
+ * debug line rather than a warning about a reboot that did not work.
75
+ */
76
+ private isExcusedFailure;
77
+ /**
78
+ * Spring the tile back to off, the way a real button returns.
79
+ *
80
+ * Clearing {@link rebooting} here rather than when the wave finishes keeps the
81
+ * reported value and the pushed value in step: HomeKit is told off at the same
82
+ * moment a read would start answering off.
83
+ */
51
84
  private scheduleReset;
52
85
  /**
53
86
  * Never marked unreachable.
@@ -27,6 +27,14 @@
27
27
  * See RebootAccessory for why this is momentary and why it stays pressable when
28
28
  * players are unreachable; the same reasoning applies, more so here, since a
29
29
  * fleet-wide restart is most useful when several players have stopped answering.
30
+ *
31
+ * One press is one wave, and a wave outlasts the HomeKit write budget by a good
32
+ * margin: the sweep alone runs for the discovery window before a single request
33
+ * goes out. So the tile is held on for as long as the wave runs, and a press that
34
+ * arrives while one is running is ignored rather than queued. Without both, a
35
+ * press looks like it did nothing, gets repeated, and each repeat sweeps a fleet
36
+ * that is now half way through restarting — which finds fewer boxes every time
37
+ * and reports the ones it does find as failures.
30
38
  */
31
39
  Object.defineProperty(exports, "__esModule", { value: true });
32
40
  exports.RebootAllAccessory = void 0;
@@ -37,6 +45,8 @@ const base_accessory_1 = require("./base-accessory");
37
45
  class RebootAllAccessory extends base_accessory_1.BaseAccessory {
38
46
  service;
39
47
  resetTimer;
48
+ /** True from a press until the tile springs back. @see writeOn */
49
+ rebooting = false;
40
50
  constructor(init) {
41
51
  super(init);
42
52
  const { Characteristic: Char, Service: HapService } = this.host.hap;
@@ -44,28 +54,40 @@ class RebootAllAccessory extends base_accessory_1.BaseAccessory {
44
54
  this.service.setCharacteristic(Char.Name, this.displayName);
45
55
  this.service
46
56
  .getCharacteristic(Char.On)
47
- .onGet(() => false)
57
+ .onGet(() => this.rebooting)
48
58
  .onSet(async (value) => this.writeOn(value));
49
59
  }
50
60
  async writeOn(value) {
51
61
  if (value !== true) {
52
62
  return;
53
63
  }
54
- try {
55
- await this.completeWithinBudget('reboot all', async () => {
56
- const targets = await this.host.rebootTargets();
57
- if (targets.length === 0) {
58
- this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: found nothing to reboot. `
59
- + 'Multicast may be filtered on this network and no players are configured');
60
- return;
61
- }
62
- this.announce(targets);
63
- await this.rebootAll(targets);
64
- });
64
+ if (this.rebooting) {
65
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: a restart is already under way; ignoring this press`);
66
+ return;
65
67
  }
66
- finally {
67
- this.scheduleReset();
68
+ this.rebooting = true;
69
+ await this.completeWithinBudget('reboot all', async () => {
70
+ try {
71
+ await this.runWave();
72
+ }
73
+ finally {
74
+ // Inside the work rather than around the budget. The budget expires long
75
+ // before the sweep finishes, so resetting there springs the tile back
76
+ // before the first line of the log is written.
77
+ this.scheduleReset();
78
+ }
79
+ });
80
+ }
81
+ /** Sweep for targets, then restart every one that is not already going down. */
82
+ async runWave() {
83
+ const targets = await this.host.rebootTargets();
84
+ if (targets.length === 0) {
85
+ this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: found nothing to reboot. `
86
+ + 'Multicast may be filtered on this network and no players are configured');
87
+ return;
68
88
  }
89
+ this.announce(targets);
90
+ await this.rebootAll(targets);
69
91
  }
70
92
  /** Count at info, name every box at debug, before any request goes out. */
71
93
  announce(targets) {
@@ -85,12 +107,25 @@ class RebootAllAccessory extends base_accessory_1.BaseAccessory {
85
107
  * single dead address delay every box behind it by a full timeout.
86
108
  * `allSettled` because one failure must not abandon the rest — a fleet-wide
87
109
  * restart that stopped at the first missing player would be worse than useless.
110
+ *
111
+ * Addresses already inside their reboot grace window are left alone. Nothing
112
+ * serves port 80 while a box boots, so a request there could only fail, and
113
+ * reporting that as `could not reboot` states the opposite of what is true.
88
114
  */
89
115
  async rebootAll(targets) {
90
- const outcomes = await Promise.allSettled(targets.map(async (target) => this.host.client.reboot(target.host)));
116
+ const { pending, restarting } = this.partition(targets);
117
+ if (restarting.length > 0) {
118
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: skipping ${restarting.length} device(s) already `
119
+ + `restarting: ${restarting.map((target) => target.host).join(', ')}`);
120
+ }
121
+ if (pending.length === 0) {
122
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: every device found is already restarting; nothing sent`);
123
+ return;
124
+ }
125
+ const outcomes = await Promise.allSettled(pending.map(async (target) => this.host.client.reboot(target.host)));
91
126
  let failed = 0;
92
127
  outcomes.forEach((outcome, index) => {
93
- const target = targets[index];
128
+ const target = pending[index];
94
129
  if (target === undefined) {
95
130
  return;
96
131
  }
@@ -98,20 +133,60 @@ class RebootAllAccessory extends base_accessory_1.BaseAccessory {
98
133
  this.host.expectReboot(target.host);
99
134
  return;
100
135
  }
101
- failed += 1;
102
- this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: could not reboot ${target.host} `
103
- + `(${target.names.map(utils_1.forLog).join(', ')}): ${(0, utils_1.describeError)(outcome.reason)}`);
136
+ if (!this.isExcusedFailure(target, outcome.reason)) {
137
+ failed += 1;
138
+ }
104
139
  });
105
- const rebooted = targets.length - failed;
106
- this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: ${rebooted} of ${targets.length} device(s) rebooted`);
140
+ const rebooted = pending.length - failed;
141
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: ${rebooted} of ${pending.length} device(s) rebooted`);
107
142
  }
108
- /** Spring the tile back to off, the way a real button returns. */
143
+ /** Split targets into those still to restart and those already restarting. */
144
+ partition(targets) {
145
+ const pending = [];
146
+ const restarting = [];
147
+ for (const target of targets) {
148
+ if (this.host.isRebooting(target.host)) {
149
+ restarting.push(target);
150
+ }
151
+ else {
152
+ pending.push(target);
153
+ }
154
+ }
155
+ return { pending, restarting };
156
+ }
157
+ /**
158
+ * Log one failed reboot, and say whether it counts against the total.
159
+ *
160
+ * A box that entered its grace window after the check above — because the
161
+ * per-player switch was pressed, or an earlier wave reached it — is going down
162
+ * already. A refused or unanswered port 80 is what that looks like, so it is a
163
+ * debug line rather than a warning about a reboot that did not work.
164
+ */
165
+ isExcusedFailure(target, reason) {
166
+ const named = `${target.host} (${target.names.map(utils_1.forLog).join(', ')})`;
167
+ const detail = (0, utils_1.describeError)(reason);
168
+ if (this.host.isRebooting(target.host)) {
169
+ this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)}: ${named} is already restarting, so it did not `
170
+ + `answer: ${detail}`);
171
+ return true;
172
+ }
173
+ this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)}: could not reboot ${named}: ${detail}`);
174
+ return false;
175
+ }
176
+ /**
177
+ * Spring the tile back to off, the way a real button returns.
178
+ *
179
+ * Clearing {@link rebooting} here rather than when the wave finishes keeps the
180
+ * reported value and the pushed value in step: HomeKit is told off at the same
181
+ * moment a read would start answering off.
182
+ */
109
183
  scheduleReset() {
110
184
  if (this.resetTimer !== undefined) {
111
185
  clearTimeout(this.resetTimer);
112
186
  }
113
187
  this.resetTimer = setTimeout(() => {
114
188
  this.resetTimer = undefined;
189
+ this.rebooting = false;
115
190
  this.service.updateCharacteristic(this.host.hap.Characteristic.On, false);
116
191
  }, settings_1.MOMENTARY_RESET_MS);
117
192
  this.resetTimer.unref?.();
@@ -108,6 +108,13 @@ export declare class BluOSPlatform implements DynamicPlatformPlugin, AccessoryHo
108
108
  * the grace window do not mark accessories unreachable.
109
109
  */
110
110
  expectReboot(host: string): void;
111
+ /**
112
+ * True while a reboot of this address is still expected to be in progress.
113
+ *
114
+ * The same window the pollers use to keep quiet, read by the reboot switches so
115
+ * they decline to send a second request to a box already on its way down.
116
+ */
117
+ isRebooting(host: string): boolean;
111
118
  /** Current address of a player, preferring the poller when it has one. */
112
119
  private hostOf;
113
120
  private start;
package/dist/platform.js CHANGED
@@ -215,6 +215,15 @@ class BluOSPlatform {
215
215
  }
216
216
  }
217
217
  }
218
+ /**
219
+ * True while a reboot of this address is still expected to be in progress.
220
+ *
221
+ * The same window the pollers use to keep quiet, read by the reboot switches so
222
+ * they decline to send a second request to a box already on its way down.
223
+ */
224
+ isRebooting(host) {
225
+ return this.rebootGrace.isExpected(host);
226
+ }
218
227
  /** Current address of a player, preferring the poller when it has one. */
219
228
  hostOf(deviceId) {
220
229
  return this.endpointFor(deviceId)?.host
@@ -141,12 +141,13 @@ export declare const DEFAULT_RESTORE_VOLUME = 20;
141
141
  */
142
142
  export declare const SLIDER_COALESCE_MS = 150;
143
143
  /**
144
- * How long a momentary switch stays on before it springs back.
144
+ * How long a momentary switch stays on after its work has finished.
145
145
  *
146
- * A reboot switch has no state to report: the player is either restarting or it
147
- * is not, and neither is "on". Long enough that the Home app renders the press
148
- * so the user sees the tap registered, short enough that the tile is not left
149
- * looking like a thing that is still happening.
146
+ * A floor on how long a press is visible, not the whole duration: the tile is
147
+ * held on while the reboot is actually being sent, which outlasts
148
+ * {@link HOMEKIT_WRITE_BUDGET_MS} and can outlast this. It matters for a press
149
+ * that finishes almost at once, such as a sweep that found nothing, where
150
+ * springing back immediately would look like a tap that never registered.
150
151
  */
151
152
  export declare const MOMENTARY_RESET_MS = 1000;
152
153
  /** Lowest BluOS volume level. */
package/dist/settings.js CHANGED
@@ -147,12 +147,13 @@ exports.DEFAULT_RESTORE_VOLUME = 20;
147
147
  */
148
148
  exports.SLIDER_COALESCE_MS = 150;
149
149
  /**
150
- * How long a momentary switch stays on before it springs back.
150
+ * How long a momentary switch stays on after its work has finished.
151
151
  *
152
- * A reboot switch has no state to report: the player is either restarting or it
153
- * is not, and neither is "on". Long enough that the Home app renders the press
154
- * so the user sees the tap registered, short enough that the tile is not left
155
- * looking like a thing that is still happening.
152
+ * A floor on how long a press is visible, not the whole duration: the tile is
153
+ * held on while the reboot is actually being sent, which outlasts
154
+ * {@link HOMEKIT_WRITE_BUDGET_MS} and can outlast this. It matters for a press
155
+ * that finishes almost at once, such as a sweep that found nothing, where
156
+ * springing back immediately would look like a tap that never registered.
156
157
  */
157
158
  exports.MOMENTARY_RESET_MS = 1_000;
158
159
  // --- Volume ----------------------------------------------------------------
@@ -85,6 +85,17 @@ export declare function isNonPrivateIpv4(value: string): boolean;
85
85
  * hostnames are accepted.
86
86
  */
87
87
  export declare function isProbeableHost(value: unknown): value is string;
88
+ /**
89
+ * True for an address discovery may dial after an unsolicited mDNS advertisement.
90
+ *
91
+ * Narrower than {@link isProbeableHost}. The settings-page probe is an
92
+ * administrator asking this host to open a connection, so loopback is allowed
93
+ * there. An advertisement on the LAN is not a request, and a packet that names
94
+ * 127.0.0.1 would make Homebridge talk to itself.
95
+ */
96
+ export declare function isDiscoveryHost(value: unknown): value is string;
97
+ /** True for IPv4 loopback (first octet 127). `isProbeableHost` treats this as local; discovery does not. */
98
+ export declare function isLoopbackIpv4(value: string): boolean;
88
99
  /** Clamp the discovery window into the supported range. */
89
100
  export declare function resolveDiscoveryTimeoutSec(value: unknown, warnings?: string[]): number;
90
101
  /**
@@ -25,6 +25,8 @@ exports.isIpv4 = isIpv4;
25
25
  exports.isValidHost = isValidHost;
26
26
  exports.isNonPrivateIpv4 = isNonPrivateIpv4;
27
27
  exports.isProbeableHost = isProbeableHost;
28
+ exports.isDiscoveryHost = isDiscoveryHost;
29
+ exports.isLoopbackIpv4 = isLoopbackIpv4;
28
30
  exports.resolveDiscoveryTimeoutSec = resolveDiscoveryTimeoutSec;
29
31
  exports.resolveSliderService = resolveSliderService;
30
32
  exports.validateConfig = validateConfig;
@@ -169,6 +171,28 @@ function isProbeableHost(value) {
169
171
  }
170
172
  return PROBE_LOCAL_SUFFIXES.some((suffix) => lower.endsWith(suffix) && lower.length > suffix.length);
171
173
  }
174
+ /**
175
+ * True for an address discovery may dial after an unsolicited mDNS advertisement.
176
+ *
177
+ * Narrower than {@link isProbeableHost}. The settings-page probe is an
178
+ * administrator asking this host to open a connection, so loopback is allowed
179
+ * there. An advertisement on the LAN is not a request, and a packet that names
180
+ * 127.0.0.1 would make Homebridge talk to itself.
181
+ */
182
+ function isDiscoveryHost(value) {
183
+ if (!isProbeableHost(value)) {
184
+ return false;
185
+ }
186
+ return !isLoopbackIpv4(value.trim());
187
+ }
188
+ /** True for IPv4 loopback (first octet 127). `isProbeableHost` treats this as local; discovery does not. */
189
+ function isLoopbackIpv4(value) {
190
+ if (!isIpv4(value)) {
191
+ return false;
192
+ }
193
+ const [firstOctet = 0] = value.split('.').map(Number);
194
+ return firstOctet === 127;
195
+ }
172
196
  /** Clamp the discovery window into the supported range. */
173
197
  function resolveDiscoveryTimeoutSec(value, warnings) {
174
198
  if (value === undefined) {
package/docs/FEATURES.md CHANGED
@@ -13,7 +13,8 @@ A checklist of what is built. The plugin aims to cover everything about a BluOS
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 (level, charging state, low-battery) on the volume tile, or on mute if there is no slider, for players with a pack fitted. A battery-only player still gets a standalone tile; the Home app will not render that one
15
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
16
+ - ✅ Reboot presses are never doubled up: the tile stays on until the request has been sent rather than springing back while HomeKit is still waiting, a press that lands mid-restart is ignored, and a box already restarting is skipped rather than sent a request that could only fail
17
+ - ✅ 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`, with `skipping N device(s) already restarting` when part of the fleet is already down. 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
18
  - ✅ Fixed-output players detected from `volume="-1"` and given no slider, with one explanatory log line
18
19
  - ✅ Multi-zone chassis support (NAD CI S2, CI 580): each zone is a separate player on its own port
19
20
  - ✅ mDNS discovery in the settings page, with manual address entry for networks that filter multicast
@@ -41,7 +42,7 @@ A checklist of what is built. The plugin aims to cover everything about a BluOS
41
42
  - ✅ An unusable configuration disables the platform and keeps every accessory registered. Nothing is deleted
42
43
  - ✅ Per-device validation: one bad entry is skipped with a warning instead of stopping the rest
43
44
  - ✅ Size-, depth-, element- and attribute-capped XML parsing, sanitised log output, and a length cap on any identity a player reports for itself
44
- - ✅ Bounded discovery: the records kept from a browse, the candidates verified from it and the verifications in flight are all capped
45
+ - ✅ Bounded discovery: the records kept from a browse, the candidates verified from it and the verifications in flight are all capped. Public IPv4 and loopback advertisements are not verified
45
46
  - ✅ Clean shutdown: poll loops, backoff delays and mDNS browses are cancelled, not left to run out
46
47
  - ✅ 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
48
  - ✅ Custom Homebridge UI settings page, plus a plain `config.schema.json` form
package/docs/PROTOCOL.md CHANGED
@@ -259,7 +259,7 @@ mDNS, browsing two service types:
259
259
  | `_musc._tcp.local` | Primary players |
260
260
  | `_musp._tcp.local` | Secondary zones of a multi-zone chassis (spec appendix §13.1, LSDP class `0x0003`) |
261
261
 
262
- A zone is usable once its `SRV` record (for the port) and an IPv4 address are both known; the address comes from an `A` record when one is offered and otherwise from the responder's own source address. `TXT` records carry `model`, `version`, `mac` and `zs`, but secondary zones **omit `mac`**. That is why identity is always confirmed by reading `/SyncStatus`, and never trusted from the advertisement alone.
262
+ A zone is usable once its `SRV` record (for the port) and a **local** IPv4 address are both known; the address comes from an `A` record when one is offered and otherwise from the responder's own source address. Public IPv4 and loopback advertisements are ignored, so a packet on the LAN cannot steer verify at Homebridge itself or at an off-network host. `TXT` records carry `model`, `version`, `mac` and `zs`, but secondary zones **omit `mac`**. That is why identity is always confirmed by reading `/SyncStatus`, and never trusted from the advertisement alone.
263
263
 
264
264
  **LSDP** (UDP 11430) is documented as an alternative and was tried first: it failed repeatedly against this fleet, while mDNS answered reliably. mDNS is therefore the only discovery path, with manual address entry as the fallback for networks that filter multicast.
265
265
 
@@ -38,7 +38,9 @@ Where HomeKit is genuinely better is the tile, the scene, the automation and the
38
38
 
39
39
  **Battery.** Charge level, charging state and low-battery warning, for players with a battery pack fitted (PULSE FLEX with BP100, PULSE M). The Home app will not render a Battery accessory on its own, so the service sits on the volume tile when that exists, otherwise on mute. A player that exposes battery and nothing else still gets a standalone tile, and Home will keep saying Not Supported for that one.
40
40
 
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.
41
+ **Reboot switch.** Reboots the player. It 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
+
43
+ The tile stays on while the request is being sent, then springs back. A press that arrives while a restart is still under way is ignored, and a player already restarting is not sent a second request: nothing serves the reboot page while a box boots, so a repeat could only fail and would be reported as a reboot that did not work.
42
44
 
43
45
  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
46
 
@@ -52,11 +54,13 @@ On a multi-zone chassis it reboots both zones. BluOS serves reboot on the box's
52
54
 
53
55
  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
56
 
57
+ One press is one wave. The sweep takes longer than HomeKit will wait for a write, so the tile stays on until the wave finishes rather than springing back while it runs, and a press that lands mid-wave is ignored. Boxes already restarting are skipped with a count (`skipping 1 device(s) already restarting`), and the total counts only what was actually sent.
58
+
55
59
  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
60
 
57
61
  ## Reliability in detail
58
62
 
59
- **Discovery in the settings page.** Finds zones over mDNS and writes the configuration for you, including the stable identity the platform will look for. Manual address entry covers networks where multicast is filtered.
63
+ **Discovery in the settings page.** Finds zones over mDNS and writes the configuration for you, including the stable identity the platform will look for. Only local IPv4 advertisements are verified (private, CGNAT/Tailscale, link-local). Public and loopback addresses are skipped. Manual address entry covers networks where multicast is filtered; the settings probe still allows loopback because you typed it.
60
64
 
61
65
  **Long-polling, not polling.** `/SyncStatus?timeout=100` with an `etag`, so a change made on the front panel, the remote or the BluOS app reaches HomeKit in about a second, without hammering the player. One poll loop per zone, because the etag is per zone and not per chassis.
62
66
 
@@ -203,6 +207,14 @@ The middle line is debug. A box that did not take the request is a warning:
203
207
  [BluOS] Downstairs Reboot: 1 of 2 device(s) rebooted
204
208
  ```
205
209
 
210
+ A box that is already restarting is skipped rather than warned about, and the total counts only what was sent:
211
+
212
+ ```text
213
+ [BluOS] Downstairs Reboot: found 2 device(s), 3 player(s)
214
+ [BluOS] Downstairs Reboot: skipping 1 device(s) already restarting: 192.168.4.11
215
+ [BluOS] Downstairs Reboot: 1 of 1 device(s) rebooted
216
+ ```
217
+
206
218
  A per-player reboot switch on a shared chassis, at startup:
207
219
 
208
220
  ```text
@@ -255,11 +267,12 @@ Ordered by how well HomeKit expresses the thing, not by how easy it is to build.
255
267
  6. **A zone on a multi-zone chassis is missing.** Check the port. Zone two is 11010, not 11000
256
268
  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
257
269
  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
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
270
+ 9. **The reboot switch turns itself off.** This is by design. It is a button, not a state: it stays on while the request is being sent, then springs back. Switching it off does nothing, which is what stops a scene or "turn everything off" from rebooting your stereo
259
271
  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
272
  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
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
262
- 13. Restart Homebridge after editing `config.json` by hand
273
+ 12. **A reboot press seems to be ignored.** Check for `a restart is already under way` or `is already restarting`. A restart already in progress is not repeated: nothing serves `/reboot` while a box boots, so a second request could only fail and would be logged as a reboot that did not work. The window is 90 seconds, after which the switch sends again
274
+ 13. **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
275
+ 14. Restart Homebridge after editing `config.json` by hand
263
276
 
264
277
  ### Why is my volume a fan?
265
278
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-bluos",
3
3
  "displayName": "Homebridge BluOS",
4
- "version": "2.0.1",
4
+ "version": "2.0.3",
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",