homebridge-bluos 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- 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/docs/FEATURES.md +2 -1
- package/docs/README-DETAILED.md +17 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.0.2](https://github.com/tbaur/homebridge-bluos/compare/v2.0.1...v2.0.2) (2026-09-10)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* 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))
|
|
9
|
+
|
|
3
10
|
## [2.0.1](https://github.com/tbaur/homebridge-bluos/compare/v2.0.0...v2.0.1) (2026-09-05)
|
|
4
11
|
|
|
5
12
|
|
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 ----------------------------------------------------------------
|
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
|
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,6 +54,8 @@ 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
|
|
@@ -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.2",
|
|
5
5
|
"description": "Homebridge plugin for BluOS players — per-zone volume, mute, volume-preset and battery accessories over the LAN Custom Integration API. Verified on NAD and Bluesound hardware",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|