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 +14 -0
- package/README.md +1 -1
- package/SECURITY.md +2 -2
- package/dist/api/discovery.d.ts +6 -4
- package/dist/api/discovery.js +14 -4
- package/dist/devices/host.d.ts +9 -0
- package/dist/devices/reboot-accessory.d.ts +25 -8
- package/dist/devices/reboot-accessory.js +64 -28
- package/dist/devices/reboot-all-accessory.d.ts +34 -1
- package/dist/devices/reboot-all-accessory.js +97 -22
- package/dist/platform.d.ts +7 -0
- package/dist/platform.js +9 -0
- package/dist/settings.d.ts +6 -5
- package/dist/settings.js +6 -5
- package/dist/utils/validators.d.ts +11 -0
- package/dist/utils/validators.js +24 -0
- package/docs/FEATURES.md +3 -2
- package/docs/PROTOCOL.md +1 -1
- package/docs/README-DETAILED.md +18 -5
- package/package.json +1 -1
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
|
package/dist/api/discovery.d.ts
CHANGED
|
@@ -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
|
|
116
|
-
* are both known
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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. */
|
package/dist/api/discovery.js
CHANGED
|
@@ -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
|
|
206
|
-
* are both known
|
|
207
|
-
*
|
|
208
|
-
*
|
|
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;
|
package/dist/devices/host.d.ts
CHANGED
|
@@ -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.
|
|
12
|
-
*
|
|
13
|
-
* no such thing as an un-reboot, so an off has nothing to mean. This is
|
|
14
|
-
* makes the switch safe to leave in a house full of scenes: "turn
|
|
15
|
-
* and a scene that sets switches off both write false, and false
|
|
16
|
-
* here. A stateful reboot switch would restart the stereo every time
|
|
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
|
-
/**
|
|
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.
|
|
13
|
-
*
|
|
14
|
-
* no such thing as an un-reboot, so an off has nothing to mean. This is
|
|
15
|
-
* makes the switch safe to leave in a house full of scenes: "turn
|
|
16
|
-
* and a scene that sets switches off both write false, and false
|
|
17
|
-
* here. A stateful reboot switch would restart the stereo every time
|
|
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(() =>
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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(() =>
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
136
|
+
if (!this.isExcusedFailure(target, outcome.reason)) {
|
|
137
|
+
failed += 1;
|
|
138
|
+
}
|
|
104
139
|
});
|
|
105
|
-
const rebooted =
|
|
106
|
-
this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: ${rebooted} of ${
|
|
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
|
-
/**
|
|
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?.();
|
package/dist/platform.d.ts
CHANGED
|
@@ -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
|
package/dist/settings.d.ts
CHANGED
|
@@ -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
|
|
144
|
+
* How long a momentary switch stays on after its work has finished.
|
|
145
145
|
*
|
|
146
|
-
* A
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
|
150
|
+
* How long a momentary switch stays on after its work has finished.
|
|
151
151
|
*
|
|
152
|
-
* A
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
/**
|
package/dist/utils/validators.js
CHANGED
|
@@ -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
|
-
- ✅
|
|
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
|
|
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
|
|
package/docs/README-DETAILED.md
CHANGED
|
@@ -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
|
|
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
|
|
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. **
|
|
262
|
-
13.
|
|
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.
|
|
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",
|