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.
- package/CHANGELOG.md +7 -0
- package/DEVELOPMENT.md +65 -0
- package/LICENSE +202 -0
- package/README.md +251 -0
- package/SECURITY.md +43 -0
- package/config.schema.json +135 -0
- package/dist/api/client.d.ts +131 -0
- package/dist/api/client.js +226 -0
- package/dist/api/discovery.d.ts +136 -0
- package/dist/api/discovery.js +402 -0
- package/dist/api/http.d.ts +52 -0
- package/dist/api/http.js +136 -0
- package/dist/api/identity.d.ts +73 -0
- package/dist/api/identity.js +120 -0
- package/dist/api/index.d.ts +14 -0
- package/dist/api/index.js +30 -0
- package/dist/api/sync-status.d.ts +50 -0
- package/dist/api/sync-status.js +191 -0
- package/dist/api/xml.d.ts +76 -0
- package/dist/api/xml.js +365 -0
- package/dist/devices/base-accessory.d.ts +131 -0
- package/dist/devices/base-accessory.js +236 -0
- package/dist/devices/battery-accessory.d.ts +28 -0
- package/dist/devices/battery-accessory.js +85 -0
- package/dist/devices/host.d.ts +45 -0
- package/dist/devices/host.js +14 -0
- package/dist/devices/index.d.ts +14 -0
- package/dist/devices/index.js +30 -0
- package/dist/devices/mute-accessory.d.ts +35 -0
- package/dist/devices/mute-accessory.js +71 -0
- package/dist/devices/volume-accessory.d.ts +66 -0
- package/dist/devices/volume-accessory.js +218 -0
- package/dist/devices/volume-preset-accessory.d.ts +32 -0
- package/dist/devices/volume-preset-accessory.js +89 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +19 -0
- package/dist/platform.d.ts +124 -0
- package/dist/platform.js +489 -0
- package/dist/poller.d.ts +109 -0
- package/dist/poller.js +300 -0
- package/dist/settings.d.ts +184 -0
- package/dist/settings.js +210 -0
- package/dist/types/index.d.ts +218 -0
- package/dist/types/index.js +38 -0
- package/dist/ui-api.d.ts +20 -0
- package/dist/ui-api.js +32 -0
- package/dist/utils/context.d.ts +18 -0
- package/dist/utils/context.js +56 -0
- package/dist/utils/errors.d.ts +37 -0
- package/dist/utils/errors.js +92 -0
- package/dist/utils/index.d.ts +13 -0
- package/dist/utils/index.js +29 -0
- package/dist/utils/serial.d.ts +22 -0
- package/dist/utils/serial.js +37 -0
- package/dist/utils/timing.d.ts +52 -0
- package/dist/utils/timing.js +74 -0
- package/dist/utils/validators.d.ts +99 -0
- package/dist/utils/validators.js +461 -0
- package/docs/FEATURES.md +91 -0
- package/docs/PROTOCOL.md +194 -0
- package/homebridge-ui/public/index.html +87 -0
- package/homebridge-ui/public/index.js +475 -0
- package/homebridge-ui/server.js +189 -0
- 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
|
+
}
|