homebridge-bluos 0.1.0

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/DEVELOPMENT.md +65 -0
  3. package/LICENSE +202 -0
  4. package/README.md +251 -0
  5. package/SECURITY.md +43 -0
  6. package/config.schema.json +135 -0
  7. package/dist/api/client.d.ts +131 -0
  8. package/dist/api/client.js +226 -0
  9. package/dist/api/discovery.d.ts +136 -0
  10. package/dist/api/discovery.js +402 -0
  11. package/dist/api/http.d.ts +52 -0
  12. package/dist/api/http.js +136 -0
  13. package/dist/api/identity.d.ts +73 -0
  14. package/dist/api/identity.js +120 -0
  15. package/dist/api/index.d.ts +14 -0
  16. package/dist/api/index.js +30 -0
  17. package/dist/api/sync-status.d.ts +50 -0
  18. package/dist/api/sync-status.js +191 -0
  19. package/dist/api/xml.d.ts +76 -0
  20. package/dist/api/xml.js +365 -0
  21. package/dist/devices/base-accessory.d.ts +131 -0
  22. package/dist/devices/base-accessory.js +236 -0
  23. package/dist/devices/battery-accessory.d.ts +28 -0
  24. package/dist/devices/battery-accessory.js +85 -0
  25. package/dist/devices/host.d.ts +45 -0
  26. package/dist/devices/host.js +14 -0
  27. package/dist/devices/index.d.ts +14 -0
  28. package/dist/devices/index.js +30 -0
  29. package/dist/devices/mute-accessory.d.ts +35 -0
  30. package/dist/devices/mute-accessory.js +71 -0
  31. package/dist/devices/volume-accessory.d.ts +66 -0
  32. package/dist/devices/volume-accessory.js +218 -0
  33. package/dist/devices/volume-preset-accessory.d.ts +32 -0
  34. package/dist/devices/volume-preset-accessory.js +89 -0
  35. package/dist/index.d.ts +15 -0
  36. package/dist/index.js +19 -0
  37. package/dist/platform.d.ts +124 -0
  38. package/dist/platform.js +489 -0
  39. package/dist/poller.d.ts +109 -0
  40. package/dist/poller.js +300 -0
  41. package/dist/settings.d.ts +184 -0
  42. package/dist/settings.js +210 -0
  43. package/dist/types/index.d.ts +218 -0
  44. package/dist/types/index.js +38 -0
  45. package/dist/ui-api.d.ts +20 -0
  46. package/dist/ui-api.js +32 -0
  47. package/dist/utils/context.d.ts +18 -0
  48. package/dist/utils/context.js +56 -0
  49. package/dist/utils/errors.d.ts +37 -0
  50. package/dist/utils/errors.js +92 -0
  51. package/dist/utils/index.d.ts +13 -0
  52. package/dist/utils/index.js +29 -0
  53. package/dist/utils/serial.d.ts +22 -0
  54. package/dist/utils/serial.js +37 -0
  55. package/dist/utils/timing.d.ts +52 -0
  56. package/dist/utils/timing.js +74 -0
  57. package/dist/utils/validators.d.ts +99 -0
  58. package/dist/utils/validators.js +461 -0
  59. package/docs/FEATURES.md +91 -0
  60. package/docs/PROTOCOL.md +194 -0
  61. package/homebridge-ui/public/index.html +87 -0
  62. package/homebridge-ui/public/index.js +475 -0
  63. package/homebridge-ui/server.js +189 -0
  64. package/package.json +91 -0
@@ -0,0 +1,236 @@
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 Shared accessory behaviour: honesty about unknown state, and
9
+ * writes that answer HomeKit inside its patience.
10
+ *
11
+ * Two rules are implemented once, here, because getting either wrong produces
12
+ * bugs that are very hard to diagnose from a user's description.
13
+ *
14
+ * Never guess a characteristic value. Until a player has actually been read, and
15
+ * whenever it has stopped answering, reads fail with
16
+ * `SERVICE_COMMUNICATION_FAILURE` so the Home app shows No Response. A plausible
17
+ * default is worse than no answer: it makes automations fire against fiction.
18
+ *
19
+ * Never let a write outlive HomeKit's patience. HAP-NodeJS warns at three
20
+ * seconds and abandons a write at nine, discarding the eventual result. A set
21
+ * handler therefore returns within {@link HOMEKIT_WRITE_BUDGET_MS} and finishes
22
+ * anything slower in the background, where its outcome still reaches HomeKit
23
+ * through the normal update path.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.BaseAccessory = void 0;
27
+ const settings_1 = require("../settings");
28
+ const utils_1 = require("../utils");
29
+ /** Base class for every accessory this plugin exposes. */
30
+ class BaseAccessory {
31
+ host;
32
+ accessory;
33
+ context;
34
+ /** True once a real observation has been applied. */
35
+ observed = false;
36
+ /** True while the player is not answering. */
37
+ offline = false;
38
+ /** Throttles repeated warnings about the same persistent failure. */
39
+ lastWarningAt = 0;
40
+ /** Ensures a one-time explanation is logged only once. */
41
+ warnedOnce = new Set();
42
+ constructor(init) {
43
+ this.host = init.host;
44
+ this.accessory = init.accessory;
45
+ this.context = init.context;
46
+ this.configureAccessoryInformation();
47
+ }
48
+ get deviceId() {
49
+ return this.context.deviceId;
50
+ }
51
+ get displayName() {
52
+ return this.accessory.displayName;
53
+ }
54
+ /** Apply a fresh observation, and remember that state is now known. */
55
+ applyObservation(observation, reason) {
56
+ this.offline = false;
57
+ this.observed = true;
58
+ this.lastWarningAt = 0;
59
+ this.refreshAccessoryInformation(observation);
60
+ this.updateFromObservation(observation, reason);
61
+ }
62
+ /**
63
+ * Report that the player could not be reached.
64
+ *
65
+ * Characteristic values are left untouched rather than zeroed: HomeKit is told
66
+ * the accessory is unreachable, and inventing a value on the way out would
67
+ * defeat that.
68
+ */
69
+ noteUnreachable(error) {
70
+ const wasOnline = !this.offline;
71
+ this.offline = true;
72
+ const now = Date.now();
73
+ if (wasOnline || now - this.lastWarningAt > settings_1.POLL_FAILURE_REWARN_MS) {
74
+ this.lastWarningAt = now;
75
+ // The player id is included because two accessories may legitimately share a
76
+ // display name, and this is the line a user pastes into a bug report.
77
+ this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)} [${(0, utils_1.forLog)(this.deviceId)}] is not responding: `
78
+ + (0, utils_1.describeError)(error));
79
+ }
80
+ else {
81
+ this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)} [${(0, utils_1.forLog)(this.deviceId)}] still not responding: `
82
+ + (0, utils_1.describeError)(error));
83
+ }
84
+ this.markUnavailable();
85
+ }
86
+ /** True once this accessory has seen a real reading. */
87
+ hasObservedState() {
88
+ return this.observed && !this.offline;
89
+ }
90
+ /**
91
+ * The error to return from a read when the true value is unknown.
92
+ *
93
+ * `SERVICE_COMMUNICATION_FAILURE` is what makes the Home app render No
94
+ * Response, which is the honest answer before the first successful poll.
95
+ */
96
+ communicationFailure() {
97
+ return new this.host.hap.HapStatusError(-70402 /* this.host.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
98
+ }
99
+ /** Throw if this accessory has no observed state to report. */
100
+ requireObservedState() {
101
+ if (!this.hasObservedState()) {
102
+ throw this.communicationFailure();
103
+ }
104
+ }
105
+ /**
106
+ * How far this accessory's volume writes should reach.
107
+ *
108
+ * A zone that leads a group carries its followers with it, matching what the
109
+ * BluOS app does when you move a leader's slider: the tile is the group's
110
+ * control while the group exists. Every other zone — standalone, or a follower
111
+ * addressed directly — moves alone, because a tile labelled one room must not
112
+ * quietly change another.
113
+ *
114
+ * Derived from the last observation rather than remembered, so ungrouping takes
115
+ * effect on the next poll without any bookkeeping here. When a player reports
116
+ * no grouping at all the answer is false, which is the pre-grouping behaviour.
117
+ */
118
+ writeScope() {
119
+ const leads = this.host.observationFor(this.deviceId)?.syncRole === 'primary';
120
+ return { tellSlaves: leads };
121
+ }
122
+ /** One info line after a HomeKit write reached the player. */
123
+ logAction(action, scope) {
124
+ const suffix = scope.tellSlaves ? ' (group)' : '';
125
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)}: ${action}${suffix}`);
126
+ }
127
+ /** Log an explanation the first time a condition is met, then stay quiet. */
128
+ warnOnce(key, message) {
129
+ if (this.warnedOnce.has(key)) {
130
+ this.host.log.debug(message);
131
+ return;
132
+ }
133
+ this.warnedOnce.add(key);
134
+ this.host.log.warn(message);
135
+ }
136
+ /**
137
+ * Run a HomeKit write, returning once it finishes or the budget expires.
138
+ *
139
+ * Slow work is not cancelled when the budget runs out, only stopped being
140
+ * waited on: the player will still apply the change, and the resulting state
141
+ * reaches HomeKit through the poll that our own write triggers.
142
+ */
143
+ async completeWithinBudget(label, work) {
144
+ let finished = false;
145
+ const tracked = work()
146
+ .catch((error) => {
147
+ this.host.log.warn(`${(0, utils_1.forLog)(this.displayName)} ${label} failed: ${(0, utils_1.describeError)(error)}`);
148
+ if (!finished) {
149
+ // Surfaces to HomeKit as a failed write when we are still inside the
150
+ // budget; afterwards HAP has stopped listening and the log is all we
151
+ // have, which is why the message above is unconditional.
152
+ throw error;
153
+ }
154
+ })
155
+ .finally(() => {
156
+ finished = true;
157
+ });
158
+ const outcome = await (0, utils_1.raceTimeout)(tracked, settings_1.HOMEKIT_WRITE_BUDGET_MS);
159
+ if (outcome === utils_1.TIMED_OUT) {
160
+ this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)} ${label} exceeded ${settings_1.HOMEKIT_WRITE_BUDGET_MS}ms; `
161
+ + 'completing in the background');
162
+ // Prevents an unhandled rejection once nothing is awaiting this any more.
163
+ tracked.catch(() => undefined);
164
+ }
165
+ }
166
+ /**
167
+ * The service this accessory's state lives on, created if necessary.
168
+ *
169
+ * Reusing a restored service rather than replacing it is what preserves the
170
+ * user's HomeKit room assignment, name and automations across a restart.
171
+ */
172
+ requireService(type) {
173
+ // Matched by UUID against the restored service list rather than through
174
+ // `getService`, whose generic signature does not admit a concrete service
175
+ // subclass without a cast.
176
+ const existing = this.accessory.services.find((service) => service.UUID === type.UUID);
177
+ return existing ?? this.accessory.addService(new type(this.displayName));
178
+ }
179
+ /**
180
+ * Remove a service this accessory no longer represents.
181
+ *
182
+ * Needed when a setting changes which service carries the state, since the
183
+ * accessory itself is adopted rather than recreated and would otherwise keep
184
+ * both — one of them unbound to any handler.
185
+ */
186
+ dropService(type) {
187
+ const stale = this.accessory.services.find((service) => service.UUID === type.UUID);
188
+ if (stale === undefined) {
189
+ return;
190
+ }
191
+ this.host.log.info(`${(0, utils_1.forLog)(this.displayName)} changed control style; removing the previous control`);
192
+ this.accessory.removeService(stale);
193
+ }
194
+ /**
195
+ * Publish identity to HomeKit.
196
+ *
197
+ * SerialNumber is the opaque generated value rather than the player's MAC: the
198
+ * Home app displays it, so it ends up in screenshots and bug reports, and on a
199
+ * multi-zone chassis a MAC is shared between zones anyway.
200
+ */
201
+ configureAccessoryInformation() {
202
+ const { Characteristic, Service: HapService } = this.host.hap;
203
+ const information = this.accessory.getService(HapService.AccessoryInformation)
204
+ ?? this.accessory.addService(HapService.AccessoryInformation);
205
+ information
206
+ .setCharacteristic(Characteristic.Manufacturer, this.context.brand)
207
+ .setCharacteristic(Characteristic.Model, this.context.model)
208
+ .setCharacteristic(Characteristic.SerialNumber, this.context.serialNumber)
209
+ .setCharacteristic(Characteristic.FirmwareRevision, this.host.pluginVersion)
210
+ .setCharacteristic(Characteristic.Name, this.displayName);
211
+ }
212
+ /** Update identity from a live reading, when the player knows better. */
213
+ refreshAccessoryInformation(observation) {
214
+ const { Characteristic, Service: HapService } = this.host.hap;
215
+ const information = this.accessory.getService(HapService.AccessoryInformation);
216
+ if (information === undefined) {
217
+ return;
218
+ }
219
+ // Sanitised on the way in, exactly like the same fields from configuration:
220
+ // these come off the wire from an unauthenticated endpoint, they can be as
221
+ // long as the whole response body, and they land in HomeKit characteristics
222
+ // and in the on-disk accessory cache.
223
+ const brand = observation.brand === undefined ? undefined : (0, utils_1.forDisplay)(observation.brand);
224
+ const rawModel = observation.modelName ?? observation.model;
225
+ const model = rawModel === undefined ? undefined : (0, utils_1.forDisplay)(rawModel);
226
+ if (brand !== undefined && brand !== this.context.brand) {
227
+ this.context.brand = brand;
228
+ information.updateCharacteristic(Characteristic.Manufacturer, brand);
229
+ }
230
+ if (model !== undefined && model !== this.context.model) {
231
+ this.context.model = model;
232
+ information.updateCharacteristic(Characteristic.Model, model);
233
+ }
234
+ }
235
+ }
236
+ exports.BaseAccessory = BaseAccessory;
@@ -0,0 +1,28 @@
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 State of charge for a portable player.
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.
13
+ */
14
+ import type { PlayerObservation, RefreshReason } from '../types';
15
+ import { BaseAccessory, type AccessoryInit } from './base-accessory';
16
+ /** A battery sensor for one player. */
17
+ export declare class BatteryAccessory extends BaseAccessory {
18
+ private readonly service;
19
+ private level;
20
+ private charging;
21
+ constructor(init: AccessoryInit);
22
+ private readLevel;
23
+ private readLowBattery;
24
+ private readChargingState;
25
+ private requireBattery;
26
+ protected updateFromObservation(observation: PlayerObservation, _reason: RefreshReason): void;
27
+ protected markUnavailable(): void;
28
+ }
@@ -0,0 +1,85 @@
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 State of charge for a portable player.
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.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.BatteryAccessory = void 0;
17
+ const utils_1 = require("../utils");
18
+ 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. */
22
+ class BatteryAccessory extends base_accessory_1.BaseAccessory {
23
+ service;
24
+ level;
25
+ charging = false;
26
+ constructor(init) {
27
+ 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
+ }
56
+ }
57
+ 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);
76
+ }
77
+ 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);
83
+ }
84
+ }
85
+ exports.BatteryAccessory = BatteryAccessory;
@@ -0,0 +1,45 @@
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 contract between an accessory and the platform.
8
+ *
9
+ * Accessories depend on this narrow interface rather than on the platform class,
10
+ * which keeps the import graph acyclic and lets a test drive an accessory with a
11
+ * handful of stubs instead of a whole platform.
12
+ */
13
+ import type { HAP, PlatformAccessory } from 'homebridge';
14
+ import type { BluOSClient, Endpoint } from '../api/client';
15
+ import type { PlayerObservation, PluginLogger } from '../types';
16
+ import type { VolumeResult } from '../api/sync-status';
17
+ /** What an accessory may ask of the platform. */
18
+ export interface AccessoryHost {
19
+ readonly log: PluginLogger;
20
+ readonly hap: HAP;
21
+ /** Shared client, so every request goes through one set of rate limits. */
22
+ readonly client: BluOSClient;
23
+ /** Plugin version, reported to HomeKit as FirmwareRevision. */
24
+ readonly pluginVersion: string;
25
+ /** Current address of a player, or undefined when it is not configured. */
26
+ endpointFor(deviceId: string): Endpoint | undefined;
27
+ /** Latest observation for a player, if one has been made since launch. */
28
+ observationFor(deviceId: string): PlayerObservation | undefined;
29
+ /**
30
+ * Adopt the authoritative state returned by a write.
31
+ *
32
+ * Also invalidates any long-poll response already in flight for that player,
33
+ * so a reply that was computed before the write cannot overwrite the result of
34
+ * it. This is the whole set/poll race defence, and it lives here so that every
35
+ * accessory gets it without having to reimplement it.
36
+ */
37
+ adoptWriteResult(deviceId: string, result: VolumeResult): void;
38
+ /**
39
+ * Persist accessory context after a change worth surviving a restart.
40
+ *
41
+ * Pass the accessory whose context changed. Omitting it rewrites the context of
42
+ * every active accessory, which is only wanted when several changed at once.
43
+ */
44
+ persistContext(accessory?: PlatformAccessory): void;
45
+ }
@@ -0,0 +1,14 @@
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 contract between an accessory and the platform.
9
+ *
10
+ * Accessories depend on this narrow interface rather than on the platform class,
11
+ * which keeps the import graph acyclic and lets a test drive an accessory with a
12
+ * handful of stubs instead of a whole platform.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,14 @@
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 Devices barrel.
8
+ */
9
+ export * from './base-accessory';
10
+ export * from './battery-accessory';
11
+ export * from './host';
12
+ export * from './mute-accessory';
13
+ export * from './volume-accessory';
14
+ export * from './volume-preset-accessory';
@@ -0,0 +1,30 @@
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 Devices barrel.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
22
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
23
+ };
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ __exportStar(require("./base-accessory"), exports);
26
+ __exportStar(require("./battery-accessory"), exports);
27
+ __exportStar(require("./host"), exports);
28
+ __exportStar(require("./mute-accessory"), exports);
29
+ __exportStar(require("./volume-accessory"), exports);
30
+ __exportStar(require("./volume-preset-accessory"), exports);
@@ -0,0 +1,35 @@
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 mute switch.
8
+ *
9
+ * On means muted, which reads correctly in an automation: "turn on Study Mute"
10
+ * silences the study.
11
+ *
12
+ * Mute is deliberately kept independent of the volume slider. Switching the
13
+ * slider off writes level zero instead of muting, so the two controls never fight
14
+ * over one piece of state, and a scene can mute a room without disturbing the
15
+ * level it will return to. That works because the player remembers the pre-mute
16
+ * level itself: measured on firmware 4.16.6, `mute=1` reports
17
+ * `volume="0" muteVolume="72"` and unmuting restores 72 unaided.
18
+ *
19
+ * Muting a zone that leads a group mutes the group, for the same reason its
20
+ * slider moves the group: while the group exists, the leader's tile is the
21
+ * group's control. Muting a follower directly silences only that follower.
22
+ */
23
+ import type { PlayerObservation, RefreshReason } from '../types';
24
+ import { BaseAccessory, type AccessoryInit } from './base-accessory';
25
+ /** A mute switch for one player. */
26
+ export declare class MuteAccessory extends BaseAccessory {
27
+ private readonly service;
28
+ /** Last mute state read from the player. */
29
+ private muted;
30
+ constructor(init: AccessoryInit);
31
+ private readOn;
32
+ private writeOn;
33
+ protected updateFromObservation(observation: PlayerObservation, _reason: RefreshReason): void;
34
+ protected markUnavailable(): void;
35
+ }
@@ -0,0 +1,71 @@
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 mute switch.
9
+ *
10
+ * On means muted, which reads correctly in an automation: "turn on Study Mute"
11
+ * silences the study.
12
+ *
13
+ * Mute is deliberately kept independent of the volume slider. Switching the
14
+ * slider off writes level zero instead of muting, so the two controls never fight
15
+ * over one piece of state, and a scene can mute a room without disturbing the
16
+ * level it will return to. That works because the player remembers the pre-mute
17
+ * level itself: measured on firmware 4.16.6, `mute=1` reports
18
+ * `volume="0" muteVolume="72"` and unmuting restores 72 unaided.
19
+ *
20
+ * Muting a zone that leads a group mutes the group, for the same reason its
21
+ * slider moves the group: while the group exists, the leader's tile is the
22
+ * group's control. Muting a follower directly silences only that follower.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.MuteAccessory = void 0;
26
+ const utils_1 = require("../utils");
27
+ const base_accessory_1 = require("./base-accessory");
28
+ /** A mute switch for one player. */
29
+ class MuteAccessory extends base_accessory_1.BaseAccessory {
30
+ service;
31
+ /** Last mute state read from the player. */
32
+ muted;
33
+ constructor(init) {
34
+ super(init);
35
+ const { Characteristic: Char, Service: HapService } = this.host.hap;
36
+ this.service = this.requireService(HapService.Switch);
37
+ this.service.setCharacteristic(Char.Name, this.displayName);
38
+ this.service
39
+ .getCharacteristic(Char.On)
40
+ .onGet(() => this.readOn())
41
+ .onSet(async (value) => this.writeOn(value));
42
+ }
43
+ readOn() {
44
+ this.requireObservedState();
45
+ return this.muted === true;
46
+ }
47
+ async writeOn(value) {
48
+ const shouldMute = value === true;
49
+ await this.completeWithinBudget('mute write', async () => {
50
+ const endpoint = this.host.endpointFor(this.deviceId);
51
+ if (endpoint === undefined) {
52
+ throw new Error('player is no longer configured');
53
+ }
54
+ const scope = this.writeScope();
55
+ const result = await this.host.client.setMute(endpoint, shouldMute, scope);
56
+ this.host.adoptWriteResult(this.deviceId, result);
57
+ this.logAction(shouldMute ? 'ON' : 'OFF', scope);
58
+ if (result.muted !== shouldMute) {
59
+ this.warnOnce('mute-refused', `${(0, utils_1.forLog)(this.displayName)} did not accept mute=${shouldMute ? 1 : 0}`);
60
+ }
61
+ });
62
+ }
63
+ updateFromObservation(observation, _reason) {
64
+ this.muted = observation.muted;
65
+ this.service.updateCharacteristic(this.host.hap.Characteristic.On, observation.muted);
66
+ }
67
+ markUnavailable() {
68
+ this.service.updateCharacteristic(this.host.hap.Characteristic.On, this.communicationFailure());
69
+ }
70
+ }
71
+ exports.MuteAccessory = MuteAccessory;
@@ -0,0 +1,66 @@
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 volume slider.
8
+ *
9
+ * HomeKit has no speaker volume control that the Home app renders, so the slider
10
+ * borrows one. Fanv2 `RotationSpeed` is the default and a Lightbulb's
11
+ * `Brightness` is offered as an alternative; they look identical in the Home app.
12
+ * The default is the fan because "turn off all the lights" and "set the lights to
13
+ * 100%" both sweep up a Lightbulb, and a speaker jumping to full volume because
14
+ * someone addressed the lights is a genuinely bad outcome. Every mature plugin in
15
+ * this space carries the same warning.
16
+ *
17
+ * Mapping to BluOS:
18
+ *
19
+ * - The slider position is the player's 0..100 level. The player converts that to
20
+ * its own configured dB range, which differs per player and per configuration —
21
+ * two identical PULSE FLEX 2i units measured 0.7 dB apart at levels 26 and 35 —
22
+ * so the level is deliberately passed through rather than scaled here.
23
+ * - Off means level zero. A muted player reports `volume="0"`, so an external mute
24
+ * shows up as Off without this accessory having to know about mute at all.
25
+ * - Any explicit level write unmutes first, because a slider that appears to move
26
+ * while the player stays silent is indistinguishable from a broken plugin.
27
+ */
28
+ import type { PlayerObservation, RefreshReason } from '../types';
29
+ import { BaseAccessory, type AccessoryInit } from './base-accessory';
30
+ /** A volume slider for one player. */
31
+ export declare class VolumeAccessory extends BaseAccessory {
32
+ private readonly surface;
33
+ /** Last level read from the player. Undefined until the first observation. */
34
+ private level;
35
+ /** True when the player reports a fixed output level. */
36
+ private fixedVolume;
37
+ /** Level awaiting a coalesced write. */
38
+ private pendingLevel;
39
+ /** In-flight coalesced write, shared by every handler that queued into it. */
40
+ private flush;
41
+ constructor(init: AccessoryInit);
42
+ private buildSurface;
43
+ private bindHandlers;
44
+ private readActive;
45
+ private readLevel;
46
+ /**
47
+ * Refuse a read when the value would be a guess.
48
+ *
49
+ * A fixed-output player is treated the same as an unreachable one: there is no
50
+ * level to report and writing one would do nothing, so No Response is the
51
+ * truthful answer rather than a slider that silently ignores input.
52
+ */
53
+ private requireSlider;
54
+ private writeActive;
55
+ private writeLevel;
56
+ /**
57
+ * Record the intended level and write it once the coalescing window closes.
58
+ *
59
+ * The most recent request wins, which is what makes the `Active` + level pair
60
+ * that HomeKit sends when a slider leaves zero result in a single write.
61
+ */
62
+ private queueLevel;
63
+ private applyPendingLevel;
64
+ protected updateFromObservation(observation: PlayerObservation, _reason: RefreshReason): void;
65
+ protected markUnavailable(): void;
66
+ }