homebridge-bluos 1.1.2 → 1.1.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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.3](https://github.com/tbaur/homebridge-bluos/compare/v1.1.2...v1.1.3) (2026-08-30)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * host battery on the volume or mute tile ([#29](https://github.com/tbaur/homebridge-bluos/issues/29)) ([beee853](https://github.com/tbaur/homebridge-bluos/commit/beee8532ca39af8c2b97a7c7412b4c5b82c66621))
9
+
3
10
  ## [1.1.2](https://github.com/tbaur/homebridge-bluos/compare/v1.1.1...v1.1.2) (2026-08-30)
4
11
 
5
12
 
package/README.md CHANGED
@@ -13,7 +13,7 @@ Browsing, search, queues and artwork stay in the BluOS app, which HomeKit cannot
13
13
  - **Volume slider:** 0–100, the same scale the BluOS app uses. A fan by default, a lightbulb if you prefer
14
14
  - **Mute switch:** unmuting restores the level the player remembered, not a guess
15
15
  - **Volume presets:** one switch per exact level, addressable by name with Siri
16
- - **Battery sensor:** level, charging state and low-battery warning, for players with a battery pack
16
+ - **Battery:** level, charging and low-battery, on the volume tile (or mute if there is no slider). A battery-only player still gets its own tile; the Home app will not render that one
17
17
  - **Reboot switch:** momentary, so a scene cannot reboot your stereo, and still pressable when the player has stopped answering
18
18
  - **Grouping-aware:** a zone leading a BluOS group moves the whole group, exactly as it does in the BluOS app
19
19
  - **Multi-zone chassis:** each zone of a NAD CI S2 or CI 580 is its own player, on its own port
@@ -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 with a suggested set of accessories. Tick what you want and save.
59
+ **Homebridge UI** (recommended): open the plugin settings and press **Discover Players**. Every zone that answers is listed with a suggested set of accessories. Tick what you want, then press the Homebridge Save button.
60
60
 
61
61
  Or in `config.json`:
62
62
 
@@ -119,7 +119,7 @@
119
119
  "title": "Battery Sensor",
120
120
  "type": "boolean",
121
121
  "default": false,
122
- "description": "State of charge. Only for players with a battery pack fitted, such as a PULSE FLEX with the BP100."
122
+ "description": "State of charge for a player with a battery pack (PULSE FLEX with the BP100, PULSE M). Appears on the volume tile, or on mute if there is no slider. A battery-only player gets its own tile, which the Home app cannot render."
123
123
  },
124
124
  "reboot": {
125
125
  "title": "Reboot Switch",
@@ -4,25 +4,19 @@
4
4
  * Licensed under the Apache License, Version 2.0
5
5
  * See LICENSE file for full license text
6
6
  *
7
- * @fileoverview State of charge for a portable player.
7
+ * @fileoverview Standalone battery accessory, used only when no other tile exists.
8
8
  *
9
- * `/SyncStatus` already carries `<battery level charging/>` on players with a
10
- * battery pack fitted, so this costs one service and no extra traffic. A player
11
- * without a pack never reports the element, and the accessory then reports No
12
- * Response rather than inventing a charge level.
9
+ * HomeKit will not render this in the Home app. Prefer hosting the Battery
10
+ * service on the volume or mute accessory, which resolveAccessories does
11
+ * whenever one of those is enabled. This class remains for a player that
12
+ * exposes battery and nothing else.
13
13
  */
14
14
  import type { PlayerObservation, RefreshReason } from '../types';
15
15
  import { BaseAccessory, type AccessoryInit } from './base-accessory';
16
- /** A battery sensor for one player. */
16
+ /** A battery sensor for one player, with no other HomeKit service beside it. */
17
17
  export declare class BatteryAccessory extends BaseAccessory {
18
- private readonly service;
19
- private level;
20
- private charging;
18
+ private readonly battery;
21
19
  constructor(init: AccessoryInit);
22
- private readLevel;
23
- private readLowBattery;
24
- private readChargingState;
25
- private requireBattery;
26
20
  protected updateFromObservation(observation: PlayerObservation, _reason: RefreshReason): void;
27
21
  protected markUnavailable(): void;
28
22
  }
@@ -5,81 +5,36 @@
5
5
  * Licensed under the Apache License, Version 2.0
6
6
  * See LICENSE file for full license text
7
7
  *
8
- * @fileoverview State of charge for a portable player.
8
+ * @fileoverview Standalone battery accessory, used only when no other tile exists.
9
9
  *
10
- * `/SyncStatus` already carries `<battery level charging/>` on players with a
11
- * battery pack fitted, so this costs one service and no extra traffic. A player
12
- * without a pack never reports the element, and the accessory then reports No
13
- * Response rather than inventing a charge level.
10
+ * HomeKit will not render this in the Home app. Prefer hosting the Battery
11
+ * service on the volume or mute accessory, which resolveAccessories does
12
+ * whenever one of those is enabled. This class remains for a player that
13
+ * exposes battery and nothing else.
14
14
  */
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.BatteryAccessory = void 0;
17
- const utils_1 = require("../utils");
18
17
  const base_accessory_1 = require("./base-accessory");
19
- /** Below this percentage HomeKit is told the battery is low. */
20
- const LOW_BATTERY_THRESHOLD = 20;
21
- /** A battery sensor for one player. */
18
+ const player_battery_1 = require("./player-battery");
19
+ /** A battery sensor for one player, with no other HomeKit service beside it. */
22
20
  class BatteryAccessory extends base_accessory_1.BaseAccessory {
23
- service;
24
- level;
25
- charging = false;
21
+ battery;
26
22
  constructor(init) {
27
23
  super(init);
28
- const { Characteristic: Char, Service: HapService } = this.host.hap;
29
- this.service = this.requireService(HapService.Battery);
30
- this.service.setCharacteristic(Char.Name, this.displayName);
31
- this.service.getCharacteristic(Char.BatteryLevel).onGet(() => this.readLevel());
32
- this.service.getCharacteristic(Char.StatusLowBattery).onGet(() => this.readLowBattery());
33
- this.service.getCharacteristic(Char.ChargingState).onGet(() => this.readChargingState());
34
- }
35
- readLevel() {
36
- this.requireBattery();
37
- return this.level ?? 0;
38
- }
39
- readLowBattery() {
40
- this.requireBattery();
41
- const { Characteristic: Char } = this.host.hap;
42
- return (this.level ?? 100) <= LOW_BATTERY_THRESHOLD
43
- ? Char.StatusLowBattery.BATTERY_LEVEL_LOW
44
- : Char.StatusLowBattery.BATTERY_LEVEL_NORMAL;
45
- }
46
- readChargingState() {
47
- this.requireBattery();
48
- const { Characteristic: Char } = this.host.hap;
49
- return this.charging ? Char.ChargingState.CHARGING : Char.ChargingState.NOT_CHARGING;
50
- }
51
- requireBattery() {
52
- this.requireObservedState();
53
- if (this.level === undefined) {
54
- throw this.communicationFailure();
55
- }
24
+ this.battery = new player_battery_1.PlayerBattery({
25
+ hap: this.host.hap,
26
+ accessory: this.accessory,
27
+ displayName: this.displayName,
28
+ warnOnce: (key, message) => this.warnOnce(key, message),
29
+ communicationFailure: () => this.communicationFailure(),
30
+ hasObservedState: () => this.hasObservedState(),
31
+ });
56
32
  }
57
33
  updateFromObservation(observation, _reason) {
58
- const battery = observation.battery;
59
- if (battery === undefined) {
60
- if (this.level !== undefined) {
61
- this.level = undefined;
62
- }
63
- this.warnOnce('no-battery', `${(0, utils_1.forLog)(this.displayName)} reports no battery pack; disable the battery sensor `
64
- + 'for this player in the plugin settings');
65
- this.markUnavailable();
66
- return;
67
- }
68
- this.level = battery.level;
69
- this.charging = battery.charging;
70
- const { Characteristic: Char } = this.host.hap;
71
- this.service.updateCharacteristic(Char.BatteryLevel, battery.level);
72
- this.service.updateCharacteristic(Char.StatusLowBattery, battery.level <= LOW_BATTERY_THRESHOLD
73
- ? Char.StatusLowBattery.BATTERY_LEVEL_LOW
74
- : Char.StatusLowBattery.BATTERY_LEVEL_NORMAL);
75
- this.service.updateCharacteristic(Char.ChargingState, battery.charging ? Char.ChargingState.CHARGING : Char.ChargingState.NOT_CHARGING);
34
+ this.battery.apply(observation);
76
35
  }
77
36
  markUnavailable() {
78
- const { Characteristic: Char } = this.host.hap;
79
- const error = this.communicationFailure();
80
- this.service.updateCharacteristic(Char.BatteryLevel, error);
81
- this.service.updateCharacteristic(Char.StatusLowBattery, error);
82
- this.service.updateCharacteristic(Char.ChargingState, error);
37
+ this.battery.markUnavailable();
83
38
  }
84
39
  }
85
40
  exports.BatteryAccessory = BatteryAccessory;
@@ -27,6 +27,8 @@ export declare class MuteAccessory extends BaseAccessory {
27
27
  private readonly service;
28
28
  /** Last mute state read from the player. */
29
29
  private muted;
30
+ /** Present when this switch also carries the player's battery. */
31
+ private readonly battery;
30
32
  constructor(init: AccessoryInit);
31
33
  private readOn;
32
34
  private writeOn;
@@ -25,11 +25,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
25
25
  exports.MuteAccessory = void 0;
26
26
  const utils_1 = require("../utils");
27
27
  const base_accessory_1 = require("./base-accessory");
28
+ const player_battery_1 = require("./player-battery");
28
29
  /** A mute switch for one player. */
29
30
  class MuteAccessory extends base_accessory_1.BaseAccessory {
30
31
  service;
31
32
  /** Last mute state read from the player. */
32
33
  muted;
34
+ /** Present when this switch also carries the player's battery. */
35
+ battery;
33
36
  constructor(init) {
34
37
  super(init);
35
38
  const { Characteristic: Char, Service: HapService } = this.host.hap;
@@ -39,6 +42,14 @@ class MuteAccessory extends base_accessory_1.BaseAccessory {
39
42
  .getCharacteristic(Char.On)
40
43
  .onGet(() => this.readOn())
41
44
  .onSet(async (value) => this.writeOn(value));
45
+ this.battery = (0, player_battery_1.attachHostedBattery)({
46
+ hap: this.host.hap,
47
+ accessory: this.accessory,
48
+ displayName: this.displayName,
49
+ warnOnce: (key, message) => this.warnOnce(key, message),
50
+ communicationFailure: () => this.communicationFailure(),
51
+ hasObservedState: () => this.hasObservedState(),
52
+ }, this.context.hostsBattery === true);
42
53
  }
43
54
  readOn() {
44
55
  this.requireObservedState();
@@ -63,9 +74,11 @@ class MuteAccessory extends base_accessory_1.BaseAccessory {
63
74
  updateFromObservation(observation, _reason) {
64
75
  this.muted = observation.muted;
65
76
  this.service.updateCharacteristic(this.host.hap.Characteristic.On, observation.muted);
77
+ this.battery?.apply(observation);
66
78
  }
67
79
  markUnavailable() {
68
80
  this.service.updateCharacteristic(this.host.hap.Characteristic.On, this.communicationFailure());
81
+ this.battery?.markUnavailable();
69
82
  }
70
83
  }
71
84
  exports.MuteAccessory = MuteAccessory;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Copyright (c) 2026 tbaur
3
+ *
4
+ * Licensed under the Apache License, Version 2.0
5
+ * See LICENSE file for full license text
6
+ *
7
+ * @fileoverview The HAP Battery service for one player.
8
+ *
9
+ * HomeKit will not render a Battery accessory on its own: the Home app parks
10
+ * that tile under Other and says Not Supported. The service has to sit on a
11
+ * tile Home already understands, which for this plugin is the volume slider or
12
+ * the mute switch. A player that exposes neither still gets a standalone
13
+ * Battery accessory, and Home will keep saying Not Supported for that one.
14
+ *
15
+ * `/SyncStatus` already carries `<battery level charging/>` when a pack is
16
+ * fitted, so this costs no extra traffic. A player without a pack never reports
17
+ * the element, and the service then reports No Response rather than inventing
18
+ * a charge level.
19
+ */
20
+ import type { API, PlatformAccessory } from 'homebridge';
21
+ import type { PlayerObservation } from '../types';
22
+ /** Collaborators for one Battery service. */
23
+ export interface PlayerBatteryInit {
24
+ hap: API['hap'];
25
+ accessory: PlatformAccessory;
26
+ displayName: string;
27
+ warnOnce: (key: string, message: string) => void;
28
+ communicationFailure: () => Error;
29
+ hasObservedState: () => boolean;
30
+ }
31
+ /** Charge level, charging state and low-battery on one HAP Battery service. */
32
+ export declare class PlayerBattery {
33
+ private readonly init;
34
+ private readonly service;
35
+ private level;
36
+ private charging;
37
+ constructor(init: PlayerBatteryInit);
38
+ apply(observation: PlayerObservation): void;
39
+ markUnavailable(): void;
40
+ private readLevel;
41
+ private readLowBattery;
42
+ private readChargingState;
43
+ private requireBattery;
44
+ }
45
+ /** Bind a Battery service, or drop a leftover one when this tile no longer hosts it. */
46
+ export declare function attachHostedBattery(init: PlayerBatteryInit, hosts: boolean): PlayerBattery | undefined;
47
+ /** Drop a leftover Battery service when this accessory no longer hosts one. */
48
+ export declare function dropPlayerBattery(accessory: PlatformAccessory, hap: API['hap']): void;
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 tbaur
4
+ *
5
+ * Licensed under the Apache License, Version 2.0
6
+ * See LICENSE file for full license text
7
+ *
8
+ * @fileoverview The HAP Battery service for one player.
9
+ *
10
+ * HomeKit will not render a Battery accessory on its own: the Home app parks
11
+ * that tile under Other and says Not Supported. The service has to sit on a
12
+ * tile Home already understands, which for this plugin is the volume slider or
13
+ * the mute switch. A player that exposes neither still gets a standalone
14
+ * Battery accessory, and Home will keep saying Not Supported for that one.
15
+ *
16
+ * `/SyncStatus` already carries `<battery level charging/>` when a pack is
17
+ * fitted, so this costs no extra traffic. A player without a pack never reports
18
+ * the element, and the service then reports No Response rather than inventing
19
+ * a charge level.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.PlayerBattery = void 0;
23
+ exports.attachHostedBattery = attachHostedBattery;
24
+ exports.dropPlayerBattery = dropPlayerBattery;
25
+ const utils_1 = require("../utils");
26
+ /** Below this percentage HomeKit is told the battery is low. */
27
+ const LOW_BATTERY_THRESHOLD = 20;
28
+ /** Charge level, charging state and low-battery on one HAP Battery service. */
29
+ class PlayerBattery {
30
+ init;
31
+ service;
32
+ level;
33
+ charging = false;
34
+ constructor(init) {
35
+ this.init = init;
36
+ const { Characteristic: Char, Service: HapService } = init.hap;
37
+ this.service = requireService(init.accessory, HapService.Battery, init.displayName);
38
+ this.service.setCharacteristic(Char.Name, init.displayName);
39
+ this.service.getCharacteristic(Char.BatteryLevel).onGet(() => this.readLevel());
40
+ this.service.getCharacteristic(Char.StatusLowBattery).onGet(() => this.readLowBattery());
41
+ this.service.getCharacteristic(Char.ChargingState).onGet(() => this.readChargingState());
42
+ }
43
+ apply(observation) {
44
+ const battery = observation.battery;
45
+ if (battery === undefined) {
46
+ this.level = undefined;
47
+ this.init.warnOnce('no-battery', `${(0, utils_1.forLog)(this.init.displayName)} reports no battery pack; disable the battery sensor `
48
+ + 'for this player in the plugin settings');
49
+ this.markUnavailable();
50
+ return;
51
+ }
52
+ this.level = battery.level;
53
+ this.charging = battery.charging;
54
+ const { Characteristic: Char } = this.init.hap;
55
+ this.service.updateCharacteristic(Char.BatteryLevel, battery.level);
56
+ this.service.updateCharacteristic(Char.StatusLowBattery, battery.level <= LOW_BATTERY_THRESHOLD
57
+ ? Char.StatusLowBattery.BATTERY_LEVEL_LOW
58
+ : Char.StatusLowBattery.BATTERY_LEVEL_NORMAL);
59
+ this.service.updateCharacteristic(Char.ChargingState, battery.charging ? Char.ChargingState.CHARGING : Char.ChargingState.NOT_CHARGING);
60
+ }
61
+ markUnavailable() {
62
+ const { Characteristic: Char } = this.init.hap;
63
+ const error = this.init.communicationFailure();
64
+ this.service.updateCharacteristic(Char.BatteryLevel, error);
65
+ this.service.updateCharacteristic(Char.StatusLowBattery, error);
66
+ this.service.updateCharacteristic(Char.ChargingState, error);
67
+ }
68
+ readLevel() {
69
+ this.requireBattery();
70
+ return this.level ?? 0;
71
+ }
72
+ readLowBattery() {
73
+ this.requireBattery();
74
+ const { Characteristic: Char } = this.init.hap;
75
+ return (this.level ?? 100) <= LOW_BATTERY_THRESHOLD
76
+ ? Char.StatusLowBattery.BATTERY_LEVEL_LOW
77
+ : Char.StatusLowBattery.BATTERY_LEVEL_NORMAL;
78
+ }
79
+ readChargingState() {
80
+ this.requireBattery();
81
+ const { Characteristic: Char } = this.init.hap;
82
+ return this.charging ? Char.ChargingState.CHARGING : Char.ChargingState.NOT_CHARGING;
83
+ }
84
+ requireBattery() {
85
+ if (!this.init.hasObservedState() || this.level === undefined) {
86
+ throw this.init.communicationFailure();
87
+ }
88
+ }
89
+ }
90
+ exports.PlayerBattery = PlayerBattery;
91
+ /** Bind a Battery service, or drop a leftover one when this tile no longer hosts it. */
92
+ function attachHostedBattery(init, hosts) {
93
+ if (!hosts) {
94
+ dropPlayerBattery(init.accessory, init.hap);
95
+ return undefined;
96
+ }
97
+ return new PlayerBattery(init);
98
+ }
99
+ /** Drop a leftover Battery service when this accessory no longer hosts one. */
100
+ function dropPlayerBattery(accessory, hap) {
101
+ const stale = accessory.services.find((service) => service.UUID === hap.Service.Battery.UUID);
102
+ if (stale !== undefined) {
103
+ accessory.removeService(stale);
104
+ }
105
+ }
106
+ function requireService(accessory, ServiceType, displayName) {
107
+ const existing = accessory.services.find((service) => service.UUID === ServiceType.UUID);
108
+ return existing ?? accessory.addService(new ServiceType(displayName));
109
+ }
@@ -36,6 +36,8 @@ export declare class VolumeAccessory extends BaseAccessory {
36
36
  private fixedVolume;
37
37
  /** Level awaiting a coalesced write. */
38
38
  private pendingLevel;
39
+ /** Present when this slider also carries the player's battery. */
40
+ private readonly battery;
39
41
  /** In-flight coalesced write, shared by every handler that queued into it. */
40
42
  private flush;
41
43
  constructor(init: AccessoryInit);
@@ -32,6 +32,7 @@ const sync_status_1 = require("../api/sync-status");
32
32
  const settings_1 = require("../settings");
33
33
  const utils_1 = require("../utils");
34
34
  const base_accessory_1 = require("./base-accessory");
35
+ const player_battery_1 = require("./player-battery");
35
36
  /** A volume slider for one player. */
36
37
  class VolumeAccessory extends base_accessory_1.BaseAccessory {
37
38
  surface;
@@ -41,12 +42,22 @@ class VolumeAccessory extends base_accessory_1.BaseAccessory {
41
42
  fixedVolume = false;
42
43
  /** Level awaiting a coalesced write. */
43
44
  pendingLevel;
45
+ /** Present when this slider also carries the player's battery. */
46
+ battery;
44
47
  /** In-flight coalesced write, shared by every handler that queued into it. */
45
48
  flush;
46
49
  constructor(init) {
47
50
  super(init);
48
51
  this.surface = this.buildSurface();
49
52
  this.bindHandlers();
53
+ this.battery = (0, player_battery_1.attachHostedBattery)({
54
+ hap: this.host.hap,
55
+ accessory: this.accessory,
56
+ displayName: this.displayName,
57
+ warnOnce: (key, message) => this.warnOnce(key, message),
58
+ communicationFailure: () => this.communicationFailure(),
59
+ hasObservedState: () => this.hasObservedState(),
60
+ }, this.context.hostsBattery === true);
50
61
  }
51
62
  buildSurface() {
52
63
  const { Characteristic: Char, Service: HapService } = this.host.hap;
@@ -190,6 +201,7 @@ class VolumeAccessory extends base_accessory_1.BaseAccessory {
190
201
  + 'cannot do anything; disable the slider for this player in the plugin settings');
191
202
  this.markUnavailable();
192
203
  }
204
+ this.battery?.apply(observation);
193
205
  return;
194
206
  }
195
207
  this.fixedVolume = false;
@@ -207,12 +219,14 @@ class VolumeAccessory extends base_accessory_1.BaseAccessory {
207
219
  const { Characteristic: Char } = this.host.hap;
208
220
  this.surface.service.updateCharacteristic(this.context.sliderService === 'lightbulb' ? Char.On : Char.Active, this.surface.toActiveValue(level > settings_1.VOLUME_MIN));
209
221
  this.surface.service.updateCharacteristic(this.context.sliderService === 'lightbulb' ? Char.Brightness : Char.RotationSpeed, level);
222
+ this.battery?.apply(observation);
210
223
  }
211
224
  markUnavailable() {
212
225
  const { Characteristic: Char } = this.host.hap;
213
226
  const error = this.communicationFailure();
214
227
  this.surface.service.updateCharacteristic(this.context.sliderService === 'lightbulb' ? Char.On : Char.Active, error);
215
228
  this.surface.service.updateCharacteristic(this.context.sliderService === 'lightbulb' ? Char.Brightness : Char.RotationSpeed, error);
229
+ this.battery?.markUnavailable();
216
230
  }
217
231
  }
218
232
  exports.VolumeAccessory = VolumeAccessory;
package/dist/platform.js CHANGED
@@ -404,6 +404,9 @@ class BluOSPlatform {
404
404
  if (Number.isInteger(previous?.lastNonZeroVolume)) {
405
405
  context.lastNonZeroVolume = previous?.lastNonZeroVolume;
406
406
  }
407
+ if (accessory.hostsBattery === true) {
408
+ context.hostsBattery = true;
409
+ }
407
410
  return context;
408
411
  }
409
412
  /**
@@ -18,10 +18,11 @@ export interface PluginLogger {
18
18
  * What an accessory does. Part of its identity, and therefore of its UUID.
19
19
  *
20
20
  * `volume` is the fake slider, `mute` the mute switch, `volumePreset` a
21
- * one-level switch, `battery` the state-of-charge sensor for portables, and
22
- * `reboot` a momentary switch that restarts one player. `rebootAll` is the only
23
- * kind with no player behind it: it belongs to the platform and restarts every
24
- * player it can find.
21
+ * one-level switch, `battery` a standalone charge sensor used only when the
22
+ * player has no volume or mute tile to host it, and `reboot` a momentary
23
+ * switch that restarts one player. `rebootAll` is the only kind with no
24
+ * player behind it: it belongs to the platform and restarts every player it
25
+ * can find.
25
26
  */
26
27
  export declare const ACCESSORY_KINDS: readonly ["volume", "mute", "volumePreset", "battery", "reboot", "rebootAll"];
27
28
  /** @see ACCESSORY_KINDS */
@@ -121,6 +122,14 @@ export interface ResolvedAccessory {
121
122
  sliderService: SliderService;
122
123
  /** Target level, for `volumePreset` only. */
123
124
  volume?: number;
125
+ /**
126
+ * True when this volume or mute accessory also carries the Battery service.
127
+ *
128
+ * HomeKit will not render a Battery accessory on its own. The service sits
129
+ * on the volume tile when that exists, otherwise on mute. A player that
130
+ * exposes neither still gets a standalone `battery` accessory.
131
+ */
132
+ hostsBattery?: boolean;
124
133
  }
125
134
  /**
126
135
  * What gets persisted in `PlatformAccessory.context`.
@@ -150,6 +159,8 @@ export interface AccessoryContext {
150
159
  * authoritative and preferred.
151
160
  */
152
161
  lastNonZeroVolume?: number;
162
+ /** True when this volume or mute accessory also carries the Battery service. */
163
+ hostsBattery?: boolean;
153
164
  }
154
165
  /**
155
166
  * How a player relates to a runtime sync group.
@@ -16,10 +16,11 @@ exports.isSliderService = isSliderService;
16
16
  * What an accessory does. Part of its identity, and therefore of its UUID.
17
17
  *
18
18
  * `volume` is the fake slider, `mute` the mute switch, `volumePreset` a
19
- * one-level switch, `battery` the state-of-charge sensor for portables, and
20
- * `reboot` a momentary switch that restarts one player. `rebootAll` is the only
21
- * kind with no player behind it: it belongs to the platform and restarts every
22
- * player it can find.
19
+ * one-level switch, `battery` a standalone charge sensor used only when the
20
+ * player has no volume or mute tile to host it, and `reboot` a momentary
21
+ * switch that restarts one player. `rebootAll` is the only kind with no
22
+ * player behind it: it belongs to the platform and restarts every player it
23
+ * can find.
23
24
  */
24
25
  exports.ACCESSORY_KINDS = [
25
26
  'volume',
@@ -52,5 +52,8 @@ function parseAccessoryContext(accessory) {
52
52
  if (Number.isInteger(raw.lastNonZeroVolume)) {
53
53
  context.lastNonZeroVolume = raw.lastNonZeroVolume;
54
54
  }
55
+ if (raw.hostsBattery === true) {
56
+ context.hostsBattery = true;
57
+ }
55
58
  return context;
56
59
  }
@@ -432,6 +432,9 @@ function resolveAccessories(devices, warnings, platform) {
432
432
  // ambiguous both on screen and to Siri.
433
433
  name: suffixName(device.name, 'Volume'),
434
434
  sliderService: device.sliderService,
435
+ // Home will not render a Battery accessory on its own. The service sits
436
+ // on this tile when a slider exists, so the charge icon appears there.
437
+ ...(device.battery ? { hostsBattery: true } : {}),
435
438
  });
436
439
  }
437
440
  if (device.mute) {
@@ -440,9 +443,11 @@ function resolveAccessories(devices, warnings, platform) {
440
443
  deviceId: device.id,
441
444
  name: suffixName(device.name, 'Mute'),
442
445
  sliderService: device.sliderService,
446
+ // Mute hosts the battery only when there is no slider to put it on.
447
+ ...(device.battery && !device.volumeSlider ? { hostsBattery: true } : {}),
443
448
  });
444
449
  }
445
- if (device.battery) {
450
+ if (device.battery && !device.volumeSlider && !device.mute) {
446
451
  accessories.push({
447
452
  kind: 'battery',
448
453
  deviceId: device.id,
package/docs/FEATURES.md CHANGED
@@ -11,7 +11,7 @@ A checklist of what is built. The plugin aims to cover everything about a BluOS
11
11
  - ✅ Slider exposed as a fan (default) or a lightbulb. Siri commands aimed at lights do not sweep up the fan
12
12
  - ✅ Mute switch, with unmute restoring the level the player remembered instead of a guess
13
13
  - ✅ Volume preset switches: one exact level per switch, addressable by name with Siri; set On sets the level, set Off is a no-op
14
- - ✅ Battery sensor (level, charging state, low-battery) for players with a battery pack fitted
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
  - ✅ Optional "reboot all" switch that reboots every BluOS player on the network, sending once per box instead of once per zone. The info log is a count (`found … device(s), … player(s)`), then `N of N device(s) rebooted`. The debug log names each box before anything is sent. Accessories on a box that was just rebooted stay quiet until it answers again, then take its live state. You choose its name with `options.rebootAllName`, so it can sit in whichever Home app room suits you
17
17
  - ✅ Fixed-output players detected from `volume="-1"` and given no slider, with one explanatory log line
@@ -82,7 +82,7 @@ HomeKit has no way to render a library or a queue, and no vocabulary for "play t
82
82
  | `volumeSlider` | Fanv2 (default) or Lightbulb, as a 0–100 slider |
83
83
  | `mute` | Switch |
84
84
  | `volumePresets[]` | Switch, one per level |
85
- | `battery` | Battery |
85
+ | `battery` | Battery service on the volume tile, or on mute if there is no slider. Standalone Battery accessory only when both are off |
86
86
  | `reboot` | Switch, momentary |
87
87
 
88
88
  ## Accessories per platform
@@ -36,7 +36,7 @@ Where HomeKit is genuinely better is the tile, the scene, the automation and the
36
36
 
37
37
  **Volume preset switches.** One switch per level, for example "Study Evening" at 15. Addressable by name with Siri, and far safer than a slider inside an automation. Setting one On sets the level. Setting it Off does nothing.
38
38
 
39
- **Battery sensor.** Charge level, charging state and low-battery warning, for players with a battery pack fitted (PULSE FLEX with BP100, PULSE M).
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
41
  **Reboot switch.** Reboots the player. It springs back to off and ignores being switched off, so a scene or "turn everything off" cannot reboot your stereo. It stays pressable while a player is showing No Response, which is exactly when you want it.
42
42
 
@@ -20,6 +20,9 @@
20
20
  .bluos-badge { font-size: .7rem; text-transform: uppercase; letter-spacing: .03em; padding: .12rem .4rem; border-radius: .25rem; background: rgba(128, 128, 128, .2); }
21
21
  .bluos-badge.warn { background: rgba(255, 193, 7, .28); }
22
22
  .bluos-options { display: flex; gap: 1.1rem; flex-wrap: wrap; margin-top: .6rem; }
23
+ .bluos-check { display: inline-flex; align-items: center; gap: .4rem; margin: 0; }
24
+ .bluos-check input { margin: 0; }
25
+ .bluos-check label { margin: 0; }
23
26
  .bluos-presets { margin-top: .6rem; }
24
27
  .bluos-preset-row { display: flex; gap: .4rem; align-items: center; margin-bottom: .35rem; }
25
28
  .bluos-preset-row input[type="text"] { flex: 1 1 8rem; }
@@ -83,7 +86,7 @@
83
86
  <div class="bluos-card-title">Whole install</div>
84
87
  </div>
85
88
  <div class="bluos-options">
86
- <label class="mb-0">
89
+ <label class="bluos-check">
87
90
  <input type="checkbox" id="reboot-all" />
88
91
  Reboot All switch
89
92
  </label>
@@ -96,13 +99,10 @@
96
99
  <label for="reboot-all-name" style="font-size: .8rem;">Name in the Home app</label>
97
100
  <input type="text" class="form-control form-control-sm" id="reboot-all-name"
98
101
  maxlength="64" placeholder="BluOS Reboot All" />
99
- <div class="bluos-meta">Name it for the room you want to keep it in.</div>
100
102
  </div>
101
103
  </div>
102
104
 
103
105
  <div class="bluos-actions">
104
- <button class="btn btn-success" id="save" disabled>Save</button>
105
- <span class="spacer"></span>
106
106
  <span id="summary" class="text-muted" style="font-size: .85rem;"></span>
107
107
  </div>
108
108
 
@@ -80,9 +80,8 @@
80
80
  platformConfig.devices = []
81
81
  }
82
82
  // Copied rather than aliased: `getPluginConfig` hands back the live objects,
83
- // and editing a control should not change the saved configuration until the
84
- // user presses Save. `devices` is already rebuilt from scratch on save, so
85
- // this closes the same gap for the install-wide settings.
83
+ // and editing a control must not change them. The page pushes a fresh copy
84
+ // into Homebridge; the footer Save is what writes that copy to disk.
86
85
  platformConfig.options = typeof platformConfig.options === 'object' && platformConfig.options !== null
87
86
  ? Object.assign({}, platformConfig.options)
88
87
  : {}
@@ -115,9 +114,9 @@
115
114
  ? device.volumePresets.filter((preset) => preset && typeof preset.name === 'string')
116
115
  .map((preset) => ({ name: preset.name, volume: Number(preset.volume) || 0 }))
117
116
  : [],
118
- // Kept so that saving preserves settings this page does not model, such
119
- // as a per-device sliderService written in the advanced editor. Without
120
- // it, opening this page and pressing Save would silently discard them.
117
+ // Kept so that a later push preserves settings this page does not model,
118
+ // such as a per-device sliderService written in the advanced editor.
119
+ // Without it, opening this page and saving would silently discard them.
121
120
  saved: device,
122
121
  })
123
122
  }
@@ -140,6 +139,9 @@
140
139
  if (existing.fixedVolume) {
141
140
  existing.volumeSlider = false
142
141
  }
142
+ if (!existing.hasBattery) {
143
+ existing.battery = false
144
+ }
143
145
  continue
144
146
  }
145
147
  players.set(player.id, {
@@ -178,7 +180,7 @@
178
180
  port: player.port,
179
181
  volumeSlider: player.volumeSlider === true,
180
182
  mute: player.mute === true,
181
- battery: player.battery === true,
183
+ battery: player.battery === true && player.hasBattery === true,
182
184
  reboot: player.reboot === true,
183
185
  })
184
186
  if (player.brand) {
@@ -239,22 +241,33 @@
239
241
  }
240
242
  }
241
243
 
242
- async function save() {
243
- const devices = toDevices()
244
- platformConfig.devices = devices
244
+ /**
245
+ * Copy the form into Homebridge's in-memory plugin config.
246
+ *
247
+ * The purple footer Save writes that copy to disk. This page does not save on
248
+ * its own: a second Save is easy to miss, and `savePluginConfig` is the same
249
+ * action as that footer button.
250
+ */
251
+ async function pushConfig() {
252
+ platformConfig.devices = toDevices()
245
253
  applyPlatformOptions()
246
254
  try {
247
255
  await homebridge.updatePluginConfig([platformConfig])
248
- await homebridge.savePluginConfig()
249
- homebridge.toast.success(
250
- devices.length === 1 ? '1 player saved.' : `${devices.length} players saved.`,
251
- 'Saved',
252
- )
253
256
  } catch (error) {
254
- homebridge.toast.error(describeError(error), 'Could not save')
257
+ homebridge.toast.error(describeError(error), 'Could not update the configuration')
255
258
  }
256
259
  }
257
260
 
261
+ function changed() {
262
+ refreshSummary()
263
+ void pushConfig()
264
+ }
265
+
266
+ function renderChanged() {
267
+ render()
268
+ void pushConfig()
269
+ }
270
+
258
271
  // --- Rendering ----------------------------------------------------------
259
272
 
260
273
  function renderPresets(player) {
@@ -270,7 +283,7 @@
270
283
  name.value = preset.name
271
284
  name.addEventListener('input', () => {
272
285
  preset.name = name.value
273
- refreshSummary()
286
+ changed()
274
287
  })
275
288
 
276
289
  const volume = el('input', {
@@ -279,12 +292,13 @@
279
292
  volume.value = String(preset.volume)
280
293
  volume.addEventListener('input', () => {
281
294
  preset.volume = Number(volume.value)
295
+ changed()
282
296
  })
283
297
 
284
298
  const remove = el('button', { class: 'btn btn-outline-danger btn-sm', type: 'button' }, 'Remove')
285
299
  remove.addEventListener('click', () => {
286
300
  player.presets.splice(index, 1)
287
- render()
301
+ renderChanged()
288
302
  })
289
303
 
290
304
  row.append(name, volume, remove)
@@ -294,20 +308,23 @@
294
308
  const add = el('button', { class: 'btn btn-outline-secondary btn-sm', type: 'button' }, 'Add volume preset')
295
309
  add.addEventListener('click', () => {
296
310
  player.presets.push({ name: '', volume: 30 })
297
- render()
311
+ renderChanged()
298
312
  })
299
313
  wrapper.append(add)
300
314
  return wrapper
301
315
  }
302
316
 
303
317
  function checkbox(label, checked, disabled, onChange) {
304
- const wrapper = el('div', { class: 'custom-control custom-switch' })
318
+ const wrapper = el('div', { class: 'bluos-check' })
305
319
  const id = `opt-${Math.random().toString(36).slice(2)}`
306
- const input = el('input', { type: 'checkbox', class: 'custom-control-input', id })
320
+ const input = el('input', { type: 'checkbox', id })
307
321
  input.checked = checked
308
322
  input.disabled = disabled === true
309
- input.addEventListener('change', () => onChange(input.checked))
310
- const text = el('label', { class: 'custom-control-label', for: id }, label)
323
+ input.addEventListener('change', () => {
324
+ onChange(input.checked)
325
+ changed()
326
+ })
327
+ const text = el('label', { for: id }, label)
311
328
  wrapper.append(input, text)
312
329
  return wrapper
313
330
  }
@@ -321,7 +338,7 @@
321
338
  include.setAttribute('aria-label', 'Expose this player in HomeKit')
322
339
  include.addEventListener('change', () => {
323
340
  player.selected = include.checked
324
- render()
341
+ renderChanged()
325
342
  })
326
343
 
327
344
  const title = el('div', { class: 'bluos-card-title' })
@@ -329,7 +346,7 @@
329
346
  nameInput.value = player.name
330
347
  nameInput.addEventListener('input', () => {
331
348
  player.name = nameInput.value
332
- refreshSummary()
349
+ changed()
333
350
  })
334
351
  title.append(nameInput)
335
352
 
@@ -371,12 +388,14 @@
371
388
  (value) => { player.volumeSlider = value },
372
389
  ))
373
390
  options.append(checkbox('Mute switch', player.mute, false, (value) => { player.mute = value }))
374
- options.append(checkbox(
375
- player.hasBattery ? 'Battery sensor' : 'Battery sensor (no pack fitted)',
376
- player.battery && player.hasBattery,
377
- !player.hasBattery,
378
- (value) => { player.battery = value },
379
- ))
391
+ if (player.hasBattery) {
392
+ options.append(checkbox(
393
+ 'Battery (on the volume or mute tile; its own tile only if those are off)',
394
+ player.battery,
395
+ false,
396
+ (value) => { player.battery = value },
397
+ ))
398
+ }
380
399
  options.append(checkbox(
381
400
  'Reboot switch (reboots this player, and any zone sharing its box)',
382
401
  player.reboot,
@@ -397,7 +416,7 @@
397
416
  for (const device of devices) {
398
417
  tiles += device.volumeSlider ? 1 : 0
399
418
  tiles += device.mute ? 1 : 0
400
- tiles += device.battery ? 1 : 0
419
+ tiles += device.battery && !device.volumeSlider && !device.mute ? 1 : 0
401
420
  tiles += device.reboot ? 1 : 0
402
421
  tiles += Array.isArray(device.volumePresets) ? device.volumePresets.length : 0
403
422
  }
@@ -407,7 +426,6 @@
407
426
  byId('summary').textContent = devices.length === 0 && tiles === 0
408
427
  ? 'Nothing selected.'
409
428
  : `${devices.length} player(s), ${tiles} HomeKit accessory(s).`
410
- byId('save').disabled = false
411
429
  }
412
430
 
413
431
  function render() {
@@ -449,7 +467,7 @@
449
467
  }
450
468
  }
451
469
  mergeDiscovered(found)
452
- render()
470
+ renderChanged()
453
471
  if (found.length === 0) {
454
472
  homebridge.toast.warning(
455
473
  'No players answered. If multicast is filtered on your network, add a player by address.',
@@ -487,7 +505,7 @@
487
505
  player.selected = true
488
506
  }
489
507
  }
490
- render()
508
+ renderChanged()
491
509
  homebridge.toast.success(`Found ${found.length} zone(s) at ${host}.`, 'Player added')
492
510
  } catch (error) {
493
511
  homebridge.toast.error(describeError(error), 'Probe failed')
@@ -498,10 +516,12 @@
498
516
 
499
517
  byId('discover').addEventListener('click', () => void discover())
500
518
  byId('manual-probe').addEventListener('click', () => void probe())
501
- byId('save').addEventListener('click', () => void save())
502
519
  byId('reboot-all').addEventListener('change', () => {
503
520
  refreshRebootAllName()
504
- refreshSummary()
521
+ changed()
522
+ })
523
+ byId('reboot-all-name').addEventListener('input', () => {
524
+ changed()
505
525
  })
506
526
  byId('toggle-manual').addEventListener('click', () => {
507
527
  const panel = byId('manual')
@@ -524,6 +544,7 @@
524
544
  loadConfig()
525
545
  .then(() => {
526
546
  render()
547
+ return pushConfig()
527
548
  })
528
549
  .catch((error) => {
529
550
  homebridge.toast.error(describeError(error), 'Could not read the configuration')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-bluos",
3
3
  "displayName": "Homebridge BluOS",
4
- "version": "1.1.2",
4
+ "version": "1.1.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",