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,218 @@
|
|
|
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 volume slider.
|
|
9
|
+
*
|
|
10
|
+
* HomeKit has no speaker volume control that the Home app renders, so the slider
|
|
11
|
+
* borrows one. Fanv2 `RotationSpeed` is the default and a Lightbulb's
|
|
12
|
+
* `Brightness` is offered as an alternative; they look identical in the Home app.
|
|
13
|
+
* The default is the fan because "turn off all the lights" and "set the lights to
|
|
14
|
+
* 100%" both sweep up a Lightbulb, and a speaker jumping to full volume because
|
|
15
|
+
* someone addressed the lights is a genuinely bad outcome. Every mature plugin in
|
|
16
|
+
* this space carries the same warning.
|
|
17
|
+
*
|
|
18
|
+
* Mapping to BluOS:
|
|
19
|
+
*
|
|
20
|
+
* - The slider position is the player's 0..100 level. The player converts that to
|
|
21
|
+
* its own configured dB range, which differs per player and per configuration —
|
|
22
|
+
* two identical PULSE FLEX 2i units measured 0.7 dB apart at levels 26 and 35 —
|
|
23
|
+
* so the level is deliberately passed through rather than scaled here.
|
|
24
|
+
* - Off means level zero. A muted player reports `volume="0"`, so an external mute
|
|
25
|
+
* shows up as Off without this accessory having to know about mute at all.
|
|
26
|
+
* - Any explicit level write unmutes first, because a slider that appears to move
|
|
27
|
+
* while the player stays silent is indistinguishable from a broken plugin.
|
|
28
|
+
*/
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.VolumeAccessory = void 0;
|
|
31
|
+
const sync_status_1 = require("../api/sync-status");
|
|
32
|
+
const settings_1 = require("../settings");
|
|
33
|
+
const utils_1 = require("../utils");
|
|
34
|
+
const base_accessory_1 = require("./base-accessory");
|
|
35
|
+
/** A volume slider for one player. */
|
|
36
|
+
class VolumeAccessory extends base_accessory_1.BaseAccessory {
|
|
37
|
+
surface;
|
|
38
|
+
/** Last level read from the player. Undefined until the first observation. */
|
|
39
|
+
level;
|
|
40
|
+
/** True when the player reports a fixed output level. */
|
|
41
|
+
fixedVolume = false;
|
|
42
|
+
/** Level awaiting a coalesced write. */
|
|
43
|
+
pendingLevel;
|
|
44
|
+
/** In-flight coalesced write, shared by every handler that queued into it. */
|
|
45
|
+
flush;
|
|
46
|
+
constructor(init) {
|
|
47
|
+
super(init);
|
|
48
|
+
this.surface = this.buildSurface();
|
|
49
|
+
this.bindHandlers();
|
|
50
|
+
}
|
|
51
|
+
buildSurface() {
|
|
52
|
+
const { Characteristic: Char, Service: HapService } = this.host.hap;
|
|
53
|
+
const wantsLightbulb = this.context.sliderService === 'lightbulb';
|
|
54
|
+
// The slider style is deliberately not part of accessory identity, so
|
|
55
|
+
// switching it adopts the same accessory rather than replacing it. Without
|
|
56
|
+
// removing the old service the accessory would carry both, and the Home app
|
|
57
|
+
// would show two controls of which only one is bound to any handler.
|
|
58
|
+
this.dropService(wantsLightbulb ? HapService.Fanv2 : HapService.Lightbulb);
|
|
59
|
+
if (wantsLightbulb) {
|
|
60
|
+
const service = this.requireService(HapService.Lightbulb);
|
|
61
|
+
return {
|
|
62
|
+
service,
|
|
63
|
+
active: service.getCharacteristic(Char.On),
|
|
64
|
+
level: service.getCharacteristic(Char.Brightness),
|
|
65
|
+
toActiveValue: (on) => on,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const service = this.requireService(HapService.Fanv2);
|
|
69
|
+
return {
|
|
70
|
+
service,
|
|
71
|
+
active: service.getCharacteristic(Char.Active),
|
|
72
|
+
level: service.getCharacteristic(Char.RotationSpeed),
|
|
73
|
+
toActiveValue: (on) => (on ? Char.Active.ACTIVE : Char.Active.INACTIVE),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
bindHandlers() {
|
|
77
|
+
const { Characteristic: Char } = this.host.hap;
|
|
78
|
+
this.surface.service.setCharacteristic(Char.Name, this.displayName);
|
|
79
|
+
// A whole-number step: BluOS levels are integers, and offering finer
|
|
80
|
+
// granularity would only invite rounding surprises.
|
|
81
|
+
this.surface.level.setProps({
|
|
82
|
+
minValue: settings_1.VOLUME_MIN,
|
|
83
|
+
maxValue: settings_1.VOLUME_MAX,
|
|
84
|
+
minStep: 1,
|
|
85
|
+
});
|
|
86
|
+
this.surface.active
|
|
87
|
+
.onGet(() => this.readActive())
|
|
88
|
+
.onSet(async (value) => this.writeActive(value));
|
|
89
|
+
this.surface.level
|
|
90
|
+
.onGet(() => this.readLevel())
|
|
91
|
+
.onSet(async (value) => this.writeLevel(value));
|
|
92
|
+
}
|
|
93
|
+
readActive() {
|
|
94
|
+
this.requireSlider();
|
|
95
|
+
return this.surface.toActiveValue((this.level ?? 0) > settings_1.VOLUME_MIN);
|
|
96
|
+
}
|
|
97
|
+
readLevel() {
|
|
98
|
+
this.requireSlider();
|
|
99
|
+
return this.level ?? settings_1.VOLUME_MIN;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Refuse a read when the value would be a guess.
|
|
103
|
+
*
|
|
104
|
+
* A fixed-output player is treated the same as an unreachable one: there is no
|
|
105
|
+
* level to report and writing one would do nothing, so No Response is the
|
|
106
|
+
* truthful answer rather than a slider that silently ignores input.
|
|
107
|
+
*/
|
|
108
|
+
requireSlider() {
|
|
109
|
+
if (this.fixedVolume) {
|
|
110
|
+
throw this.communicationFailure();
|
|
111
|
+
}
|
|
112
|
+
this.requireObservedState();
|
|
113
|
+
}
|
|
114
|
+
async writeActive(value) {
|
|
115
|
+
const { Characteristic: Char } = this.host.hap;
|
|
116
|
+
const on = this.context.sliderService === 'lightbulb'
|
|
117
|
+
? value === true
|
|
118
|
+
: value === Char.Active.ACTIVE;
|
|
119
|
+
if (!on) {
|
|
120
|
+
await this.queueLevel(settings_1.VOLUME_MIN);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const observation = this.host.observationFor(this.deviceId);
|
|
124
|
+
const restore = (0, sync_status_1.restoreLevelFrom)(observation ?? {}, this.context.lastNonZeroVolume, settings_1.DEFAULT_RESTORE_VOLUME);
|
|
125
|
+
await this.queueLevel(restore);
|
|
126
|
+
}
|
|
127
|
+
async writeLevel(value) {
|
|
128
|
+
const requested = typeof value === 'number' ? Math.round(value) : Number.NaN;
|
|
129
|
+
if (!Number.isInteger(requested)) {
|
|
130
|
+
this.host.log.debug(`${(0, utils_1.forLog)(this.displayName)} ignoring non-numeric level ${String(value)}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
await this.queueLevel(Math.min(settings_1.VOLUME_MAX, Math.max(settings_1.VOLUME_MIN, requested)));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Record the intended level and write it once the coalescing window closes.
|
|
137
|
+
*
|
|
138
|
+
* The most recent request wins, which is what makes the `Active` + level pair
|
|
139
|
+
* that HomeKit sends when a slider leaves zero result in a single write.
|
|
140
|
+
*/
|
|
141
|
+
async queueLevel(level) {
|
|
142
|
+
this.pendingLevel = level;
|
|
143
|
+
if (this.flush === undefined) {
|
|
144
|
+
this.flush = (0, utils_1.sleep)(settings_1.SLIDER_COALESCE_MS).then(async () => this.applyPendingLevel());
|
|
145
|
+
}
|
|
146
|
+
const flush = this.flush;
|
|
147
|
+
await this.completeWithinBudget('volume write', async () => flush);
|
|
148
|
+
}
|
|
149
|
+
async applyPendingLevel() {
|
|
150
|
+
const level = this.pendingLevel;
|
|
151
|
+
this.pendingLevel = undefined;
|
|
152
|
+
this.flush = undefined;
|
|
153
|
+
if (level === undefined) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (this.fixedVolume) {
|
|
157
|
+
this.warnOnce('fixed-volume-write', `${(0, utils_1.forLog)(this.displayName)} reports a fixed output level; ignoring the volume write`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const endpoint = this.host.endpointFor(this.deviceId);
|
|
161
|
+
if (endpoint === undefined) {
|
|
162
|
+
throw new Error('player is no longer configured');
|
|
163
|
+
}
|
|
164
|
+
// An explicit level is meaningless while muted, so unmute first. Writing the
|
|
165
|
+
// level alone would leave the slider showing a value the room cannot hear.
|
|
166
|
+
const observation = this.host.observationFor(this.deviceId);
|
|
167
|
+
const scope = this.writeScope();
|
|
168
|
+
if (level > settings_1.VOLUME_MIN && observation?.muted === true) {
|
|
169
|
+
const unmuted = await this.host.client.setMute(endpoint, false, scope);
|
|
170
|
+
this.host.adoptWriteResult(this.deviceId, unmuted);
|
|
171
|
+
}
|
|
172
|
+
// A group write changes the followers too. They are not touched here: each
|
|
173
|
+
// zone runs its own long-poll, so their own etags move and they update within
|
|
174
|
+
// a poll round trip, without this accessory having to know who they are.
|
|
175
|
+
const result = await this.host.client.setVolume(endpoint, level, scope);
|
|
176
|
+
// The player clamps into its own configured range, so its answer is the
|
|
177
|
+
// truth and the requested value is only a request.
|
|
178
|
+
this.host.adoptWriteResult(this.deviceId, result);
|
|
179
|
+
this.logAction(`SET ${result.level ?? level}`, scope);
|
|
180
|
+
if (result.level !== undefined && result.level !== level) {
|
|
181
|
+
this.warnOnce('clamped', `${(0, utils_1.forLog)(this.displayName)} clamped volume ${level} to ${result.level}; `
|
|
182
|
+
+ 'the player has a configured volume range');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
updateFromObservation(observation, _reason) {
|
|
186
|
+
if (observation.fixedVolume) {
|
|
187
|
+
if (!this.fixedVolume) {
|
|
188
|
+
this.fixedVolume = true;
|
|
189
|
+
this.warnOnce('fixed-volume', `${(0, utils_1.forLog)(this.displayName)} reports a fixed output level, so its volume slider `
|
|
190
|
+
+ 'cannot do anything; disable the slider for this player in the plugin settings');
|
|
191
|
+
this.markUnavailable();
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
this.fixedVolume = false;
|
|
196
|
+
// A muted player already reports level 0, so this needs no mute handling of
|
|
197
|
+
// its own; the guard is only for firmware that reports otherwise.
|
|
198
|
+
const level = observation.muted ? settings_1.VOLUME_MIN : observation.volume ?? settings_1.VOLUME_MIN;
|
|
199
|
+
this.level = level;
|
|
200
|
+
if (level > settings_1.VOLUME_MIN && this.context.lastNonZeroVolume !== level) {
|
|
201
|
+
this.context.lastNonZeroVolume = level;
|
|
202
|
+
// This accessory only: a front-panel knob produces a level change per step,
|
|
203
|
+
// and each one would otherwise rewrite the context of every accessory in the
|
|
204
|
+
// fleet to the cache file on disk.
|
|
205
|
+
this.host.persistContext(this.accessory);
|
|
206
|
+
}
|
|
207
|
+
const { Characteristic: Char } = this.host.hap;
|
|
208
|
+
this.surface.service.updateCharacteristic(this.context.sliderService === 'lightbulb' ? Char.On : Char.Active, this.surface.toActiveValue(level > settings_1.VOLUME_MIN));
|
|
209
|
+
this.surface.service.updateCharacteristic(this.context.sliderService === 'lightbulb' ? Char.Brightness : Char.RotationSpeed, level);
|
|
210
|
+
}
|
|
211
|
+
markUnavailable() {
|
|
212
|
+
const { Characteristic: Char } = this.host.hap;
|
|
213
|
+
const error = this.communicationFailure();
|
|
214
|
+
this.surface.service.updateCharacteristic(this.context.sliderService === 'lightbulb' ? Char.On : Char.Active, error);
|
|
215
|
+
this.surface.service.updateCharacteristic(this.context.sliderService === 'lightbulb' ? Char.Brightness : Char.RotationSpeed, error);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
exports.VolumeAccessory = VolumeAccessory;
|
|
@@ -0,0 +1,32 @@
|
|
|
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 A switch that sets one specific volume level.
|
|
8
|
+
*
|
|
9
|
+
* This is the automation-safe counterpart to the slider. A scene or a Siri phrase
|
|
10
|
+
* can only ever put the player at the configured level, so there is no way for a
|
|
11
|
+
* misfiring automation or a misheard command to land on full volume at three in
|
|
12
|
+
* the morning. It is also the only control here that is meaningfully addressable
|
|
13
|
+
* by voice without a number: "turn on Bedtime Volume".
|
|
14
|
+
*
|
|
15
|
+
* On means the player is currently at this level and not muted. Turning the switch
|
|
16
|
+
* off has no meaning — there is no opposite of "be at 30" — so it is a no-op that
|
|
17
|
+
* re-asserts the real state.
|
|
18
|
+
*/
|
|
19
|
+
import type { PlayerObservation, RefreshReason } from '../types';
|
|
20
|
+
import { BaseAccessory, type AccessoryInit } from './base-accessory';
|
|
21
|
+
/** A one-level volume switch for one player. */
|
|
22
|
+
export declare class VolumePresetAccessory extends BaseAccessory {
|
|
23
|
+
private readonly service;
|
|
24
|
+
private readonly target;
|
|
25
|
+
/** Whether the player is currently sitting at this preset's level. */
|
|
26
|
+
private applied;
|
|
27
|
+
constructor(init: AccessoryInit);
|
|
28
|
+
private readOn;
|
|
29
|
+
private writeOn;
|
|
30
|
+
protected updateFromObservation(observation: PlayerObservation, _reason: RefreshReason): void;
|
|
31
|
+
protected markUnavailable(): void;
|
|
32
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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 A switch that sets one specific volume level.
|
|
9
|
+
*
|
|
10
|
+
* This is the automation-safe counterpart to the slider. A scene or a Siri phrase
|
|
11
|
+
* can only ever put the player at the configured level, so there is no way for a
|
|
12
|
+
* misfiring automation or a misheard command to land on full volume at three in
|
|
13
|
+
* the morning. It is also the only control here that is meaningfully addressable
|
|
14
|
+
* by voice without a number: "turn on Bedtime Volume".
|
|
15
|
+
*
|
|
16
|
+
* On means the player is currently at this level and not muted. Turning the switch
|
|
17
|
+
* off has no meaning — there is no opposite of "be at 30" — so it is a no-op that
|
|
18
|
+
* re-asserts the real state.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.VolumePresetAccessory = void 0;
|
|
22
|
+
const settings_1 = require("../settings");
|
|
23
|
+
const utils_1 = require("../utils");
|
|
24
|
+
const base_accessory_1 = require("./base-accessory");
|
|
25
|
+
/** A one-level volume switch for one player. */
|
|
26
|
+
class VolumePresetAccessory extends base_accessory_1.BaseAccessory {
|
|
27
|
+
service;
|
|
28
|
+
target;
|
|
29
|
+
/** Whether the player is currently sitting at this preset's level. */
|
|
30
|
+
applied;
|
|
31
|
+
constructor(init) {
|
|
32
|
+
super(init);
|
|
33
|
+
this.target = init.context.volume ?? settings_1.VOLUME_MIN;
|
|
34
|
+
const { Characteristic: Char, Service: HapService } = this.host.hap;
|
|
35
|
+
this.service = this.requireService(HapService.Switch);
|
|
36
|
+
this.service.setCharacteristic(Char.Name, this.displayName);
|
|
37
|
+
this.service
|
|
38
|
+
.getCharacteristic(Char.On)
|
|
39
|
+
.onGet(() => this.readOn())
|
|
40
|
+
.onSet(async (value) => this.writeOn(value));
|
|
41
|
+
}
|
|
42
|
+
readOn() {
|
|
43
|
+
this.requireObservedState();
|
|
44
|
+
return this.applied === true;
|
|
45
|
+
}
|
|
46
|
+
async writeOn(value) {
|
|
47
|
+
if (value !== true) {
|
|
48
|
+
// Nothing to undo. Re-assert observed state so the tile stops showing the
|
|
49
|
+
// user's tap rather than the player's reality.
|
|
50
|
+
const observation = this.host.observationFor(this.deviceId);
|
|
51
|
+
if (observation !== undefined) {
|
|
52
|
+
this.updateFromObservation(observation, 'post-set');
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await this.completeWithinBudget('preset write', async () => {
|
|
57
|
+
const endpoint = this.host.endpointFor(this.deviceId);
|
|
58
|
+
if (endpoint === undefined) {
|
|
59
|
+
throw new Error('player is no longer configured');
|
|
60
|
+
}
|
|
61
|
+
const observation = this.host.observationFor(this.deviceId);
|
|
62
|
+
const scope = this.writeScope();
|
|
63
|
+
// A preset that leaves the room silent would look like it had failed.
|
|
64
|
+
if (this.target > settings_1.VOLUME_MIN && observation?.muted === true) {
|
|
65
|
+
this.host.adoptWriteResult(this.deviceId, await this.host.client.setMute(endpoint, false, scope));
|
|
66
|
+
}
|
|
67
|
+
const result = await this.host.client.setVolume(endpoint, this.target, scope);
|
|
68
|
+
this.host.adoptWriteResult(this.deviceId, result);
|
|
69
|
+
this.logAction(`SET ${result.level ?? this.target}`, scope);
|
|
70
|
+
if (result.level !== undefined && result.level !== this.target) {
|
|
71
|
+
// The player's configured range can make a preset unreachable, in which
|
|
72
|
+
// case the switch would otherwise sit off forever with no explanation.
|
|
73
|
+
this.warnOnce('unreachable-preset', `${(0, utils_1.forLog)(this.displayName)} asked for volume ${this.target} but the player `
|
|
74
|
+
+ `settled at ${result.level}; its configured volume range cannot reach ${this.target}`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
updateFromObservation(observation, _reason) {
|
|
79
|
+
const applied = !observation.muted
|
|
80
|
+
&& !observation.fixedVolume
|
|
81
|
+
&& observation.volume === this.target;
|
|
82
|
+
this.applied = applied;
|
|
83
|
+
this.service.updateCharacteristic(this.host.hap.Characteristic.On, applied);
|
|
84
|
+
}
|
|
85
|
+
markUnavailable() {
|
|
86
|
+
this.service.updateCharacteristic(this.host.hap.Characteristic.On, this.communicationFailure());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
exports.VolumePresetAccessory = VolumePresetAccessory;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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 Plugin entry point.
|
|
8
|
+
*
|
|
9
|
+
* The registered platform name must match `config.schema.json` and the
|
|
10
|
+
* `platform` value in the user's `config.json`, or Homebridge silently loads
|
|
11
|
+
* nothing at all.
|
|
12
|
+
*/
|
|
13
|
+
import type { API } from 'homebridge';
|
|
14
|
+
declare const _default: (api: API) => void;
|
|
15
|
+
export default _default;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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 Plugin entry point.
|
|
9
|
+
*
|
|
10
|
+
* The registered platform name must match `config.schema.json` and the
|
|
11
|
+
* `platform` value in the user's `config.json`, or Homebridge silently loads
|
|
12
|
+
* nothing at all.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
const platform_1 = require("./platform");
|
|
16
|
+
const settings_1 = require("./settings");
|
|
17
|
+
exports.default = (api) => {
|
|
18
|
+
api.registerPlatform(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, platform_1.BluOSPlatform);
|
|
19
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
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 platform: configuration, accessory lifecycle, orchestration.
|
|
8
|
+
*
|
|
9
|
+
* Two policies here matter more than the mechanics.
|
|
10
|
+
*
|
|
11
|
+
* Accessories are adopted, never replaced. Identity is derived from the player's
|
|
12
|
+
* MAC and zone port and never from its address, so a DHCP lease change leaves
|
|
13
|
+
* every UUID untouched. When a cached accessory carries the right identity in its
|
|
14
|
+
* context but a different UUID — the situation an identity-scheme change would
|
|
15
|
+
* create — it is adopted rather than orphaned, because losing an accessory takes
|
|
16
|
+
* the user's rooms, scenes and automations with it.
|
|
17
|
+
*
|
|
18
|
+
* A broken configuration disables the platform instead of deleting anything. The
|
|
19
|
+
* accessories stay registered and report No Response, which is recoverable; a
|
|
20
|
+
* plugin that unregisters accessories when it cannot parse its own settings
|
|
21
|
+
* destroys work the user cannot get back.
|
|
22
|
+
*/
|
|
23
|
+
import type { API, DynamicPlatformPlugin, Logging, PlatformAccessory, PlatformConfig } from 'homebridge';
|
|
24
|
+
import { BluOSClient, type Endpoint } from './api/client';
|
|
25
|
+
import type { VolumeResult } from './api/sync-status';
|
|
26
|
+
import { type AccessoryHost } from './devices';
|
|
27
|
+
import type { PlayerObservation } from './types';
|
|
28
|
+
/** The BluOS dynamic platform. */
|
|
29
|
+
export declare class BluOSPlatform implements DynamicPlatformPlugin, AccessoryHost {
|
|
30
|
+
readonly log: Logging;
|
|
31
|
+
readonly client: BluOSClient;
|
|
32
|
+
readonly pluginVersion: string;
|
|
33
|
+
private readonly api;
|
|
34
|
+
private readonly config;
|
|
35
|
+
private readonly discovery;
|
|
36
|
+
/** Accessories restored from disk, by UUID. */
|
|
37
|
+
private readonly restored;
|
|
38
|
+
/** Live accessories, by UUID. */
|
|
39
|
+
private readonly active;
|
|
40
|
+
/** Accessory handlers grouped by the player they belong to. */
|
|
41
|
+
private readonly handlers;
|
|
42
|
+
private readonly pollers;
|
|
43
|
+
private devices;
|
|
44
|
+
private discoveryTimeoutSec;
|
|
45
|
+
/** True when configuration could not be used; nothing is polled. */
|
|
46
|
+
private disabled;
|
|
47
|
+
private shuttingDown;
|
|
48
|
+
/** Warnings raised while resolving options in the constructor, logged at start. */
|
|
49
|
+
private readonly discoveryWarnings;
|
|
50
|
+
/** Pending poller starts, tracked so a shutdown can clear them. */
|
|
51
|
+
private readonly staggerTimers;
|
|
52
|
+
/** The launch address sweep, tracked so a shutdown can wait for it to end. */
|
|
53
|
+
private launchSweep;
|
|
54
|
+
constructor(log: Logging, config: PlatformConfig, api: API);
|
|
55
|
+
get hap(): API['hap'];
|
|
56
|
+
/** Homebridge hands back every accessory it restored from disk. */
|
|
57
|
+
configureAccessory(accessory: PlatformAccessory): void;
|
|
58
|
+
endpointFor(deviceId: string): Endpoint | undefined;
|
|
59
|
+
observationFor(deviceId: string): PlayerObservation | undefined;
|
|
60
|
+
adoptWriteResult(deviceId: string, result: VolumeResult): void;
|
|
61
|
+
/**
|
|
62
|
+
* Write accessory context back to the Homebridge cache.
|
|
63
|
+
*
|
|
64
|
+
* One accessory by default, because `updatePlatformAccessories` makes
|
|
65
|
+
* Homebridge serialise and rewrite the whole cache file: a front-panel volume
|
|
66
|
+
* knob produces a stream of observations, and writing every accessory's context
|
|
67
|
+
* for each of them is sustained disk churn on the SD card of a typical host.
|
|
68
|
+
*/
|
|
69
|
+
persistContext(accessory?: PlatformAccessory): void;
|
|
70
|
+
private start;
|
|
71
|
+
private stop;
|
|
72
|
+
/**
|
|
73
|
+
* Bring the registered accessory set in line with configuration.
|
|
74
|
+
*
|
|
75
|
+
* Creates what is missing, adopts what matches by identity, and unregisters
|
|
76
|
+
* only what configuration no longer asks for.
|
|
77
|
+
*/
|
|
78
|
+
private syncAccessories;
|
|
79
|
+
private uuidFor;
|
|
80
|
+
/**
|
|
81
|
+
* Find a cached accessory that describes this one but under a different UUID.
|
|
82
|
+
*
|
|
83
|
+
* The safety net for an identity-scheme change: matching on the persisted
|
|
84
|
+
* context lets the accessory be adopted instead of being replaced by a fresh
|
|
85
|
+
* one, which would silently drop it out of every scene it belongs to.
|
|
86
|
+
*/
|
|
87
|
+
private findByIdentity;
|
|
88
|
+
private createAccessory;
|
|
89
|
+
private adoptAccessory;
|
|
90
|
+
private buildContext;
|
|
91
|
+
/**
|
|
92
|
+
* Build the handler for an accessory from its persisted context.
|
|
93
|
+
*
|
|
94
|
+
* Driven by context rather than configuration so that the same path works when
|
|
95
|
+
* the platform is disabled and there is no valid configuration to consult.
|
|
96
|
+
*/
|
|
97
|
+
private attachHandler;
|
|
98
|
+
private startPollers;
|
|
99
|
+
/** Correct addresses once at launch, so a DHCP change needs no user action. */
|
|
100
|
+
private correctAddresses;
|
|
101
|
+
/** Record a new address in accessory context so it survives a restart. */
|
|
102
|
+
private rememberEndpoint;
|
|
103
|
+
private publish;
|
|
104
|
+
private reportUnavailable;
|
|
105
|
+
/**
|
|
106
|
+
* Put every cached accessory into No Response.
|
|
107
|
+
*
|
|
108
|
+
* Used when configuration is unusable. Handlers are attached first so that the
|
|
109
|
+
* characteristics exist to be marked, which also means HomeKit sees a
|
|
110
|
+
* well-formed accessory that happens to be unreachable rather than a
|
|
111
|
+
* half-registered one.
|
|
112
|
+
*/
|
|
113
|
+
private reportEverythingUnavailable;
|
|
114
|
+
/**
|
|
115
|
+
* Push No Response onto an accessory that has no handler.
|
|
116
|
+
*
|
|
117
|
+
* Generic rather than per accessory kind, because the reason it is needed is
|
|
118
|
+
* that the context which would have told us the kind could not be read.
|
|
119
|
+
* Accessory Information is left alone so the tile keeps its name and model.
|
|
120
|
+
*/
|
|
121
|
+
private markAccessoryUnavailable;
|
|
122
|
+
/** True when the platform gave up on its configuration. Exposed for tests. */
|
|
123
|
+
get isDisabled(): boolean;
|
|
124
|
+
}
|