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,210 @@
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-wide constants and the plugin version reader.
9
+ *
10
+ * Numbers that came from the BluOS Custom Integration API v1.7 specification or
11
+ * from measurements against real players cite their source, so a future reader
12
+ * can tell a vendor requirement apart from a judgement call.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.MAX_NAME_LENGTH = exports.MAX_LOG_FIELD_LENGTH = exports.DISCOVERY_VERIFY_CONCURRENCY = exports.MAX_DISCOVERY_CANDIDATES = exports.MAX_DISCOVERY_RECORDS = exports.REDISCOVERY_MIN_INTERVAL_MS = exports.FAILURES_BEFORE_REDISCOVERY = exports.MAX_DISCOVERY_TIMEOUT_SEC = exports.MIN_DISCOVERY_TIMEOUT_SEC = exports.DEFAULT_DISCOVERY_TIMEOUT_SEC = exports.MDNS_SERVICE_SECONDARY = exports.MDNS_SERVICE_PRIMARY = exports.MAX_XML_ATTRIBUTES = exports.MAX_XML_ELEMENTS = exports.MAX_XML_DEPTH = exports.MAX_XML_BYTES = exports.MUTED_DB_SENTINEL = exports.FIXED_VOLUME_SENTINEL = exports.VOLUME_MAX = exports.VOLUME_MIN = exports.SLIDER_COALESCE_MS = exports.DEFAULT_RESTORE_VOLUME = exports.HOMEKIT_WRITE_BUDGET_MS = exports.POLL_FAILURE_REWARN_MS = exports.POLL_FAILURES_BEFORE_UNKNOWN = exports.POLL_BACKOFF_MAX_MS = exports.POLL_BACKOFF_BASE_MS = exports.CONTROL_RATE_LIMIT_MS = exports.STATUS_TIMEOUT_MS = exports.CONTROL_TIMEOUT_MS = exports.CONNECT_TIMEOUT_MS = exports.SAME_RESOURCE_MIN_GAP_MS = exports.LONG_POLL_READ_SLACK_MS = exports.LONG_POLL_SEC = exports.MAX_PORT = exports.MIN_PORT = exports.DOCUMENTED_BLUOS_PORTS = exports.DEFAULT_BLUOS_PORT = exports.DEFAULT_MODEL = exports.DEFAULT_BRAND = exports.UNKNOWN_PLUGIN_VERSION = exports.UUID_PREFIX = exports.PLATFORM_NAME = exports.PLUGIN_NAME = void 0;
16
+ exports.readPluginVersion = readPluginVersion;
17
+ /** npm package name. Must match `package.json` `name` for Homebridge to load us. */
18
+ exports.PLUGIN_NAME = 'homebridge-bluos';
19
+ /** Platform alias used in `config.json` and `config.schema.json`. */
20
+ exports.PLATFORM_NAME = 'BluOS';
21
+ /**
22
+ * Namespace for generated accessory UUIDs.
23
+ *
24
+ * Deliberately does not include a host or port: seeding accessory identity with
25
+ * an address means a DHCP lease change re-creates every accessory and destroys
26
+ * the rooms, scenes and automations the user built on top of them.
27
+ */
28
+ exports.UUID_PREFIX = 'homebridge-bluos:';
29
+ /** Reported when `package.json` cannot be read. */
30
+ exports.UNKNOWN_PLUGIN_VERSION = '0.0.0';
31
+ /** Manufacturer shown when `/SyncStatus` does not report a brand. */
32
+ exports.DEFAULT_BRAND = 'BluOS';
33
+ /** Model shown when `/SyncStatus` does not report one. */
34
+ exports.DEFAULT_MODEL = 'BluOS Player';
35
+ // --- Endpoints -------------------------------------------------------------
36
+ /** Control port for a primary player (API v1.7 section 1). */
37
+ exports.DEFAULT_BLUOS_PORT = 11_000;
38
+ /**
39
+ * Ports the specification documents for multi-zone chassis.
40
+ *
41
+ * API v1.7 section 1: the CI 580 exposes four streamer nodes on one IP, using
42
+ * 11000, 11010, 11020 and 11030. The CI-S2 uses 11000 and 11010. Ports outside
43
+ * this set are accepted but warned about, because mDNS SRV records are the
44
+ * authority on which port a zone actually listens to.
45
+ */
46
+ exports.DOCUMENTED_BLUOS_PORTS = [11_000, 11_010, 11_020, 11_030];
47
+ /** Lowest port number accepted anywhere in configuration. */
48
+ exports.MIN_PORT = 1;
49
+ /** Highest port number accepted anywhere in configuration. */
50
+ exports.MAX_PORT = 65_535;
51
+ // --- Polling ---------------------------------------------------------------
52
+ /**
53
+ * Duration passed as `/SyncStatus?timeout=`.
54
+ *
55
+ * The specification recommends 180 s for `/SyncStatus`, and forbids anything
56
+ * faster than 10 s. 100 s is inside that envelope and halves the worst-case
57
+ * delay before an unplugged player's socket read times out, which is how a
58
+ * silent disappearance (as opposed to a connection reset) gets noticed.
59
+ */
60
+ exports.LONG_POLL_SEC = 100;
61
+ /** Read headroom beyond the player's own long-poll timeout. */
62
+ exports.LONG_POLL_READ_SLACK_MS = 5_000;
63
+ /**
64
+ * Minimum gap between two consecutive requests for the same resource.
65
+ *
66
+ * API v1.7 section 2 makes this a requirement, not a suggestion: "a client must
67
+ * not make two consecutive requests for the same resource less than one second
68
+ * apart, even if the first request returns in less than one second".
69
+ */
70
+ exports.SAME_RESOURCE_MIN_GAP_MS = 1_000;
71
+ /** Connect timeout. Short so a powered-off player cannot stall startup. */
72
+ exports.CONNECT_TIMEOUT_MS = 2_500;
73
+ /** Total timeout for a control call (`/Volume?level=`, `/Volume?mute=`). */
74
+ exports.CONTROL_TIMEOUT_MS = 5_000;
75
+ /** Total timeout for a plain, non-long-poll status read. */
76
+ exports.STATUS_TIMEOUT_MS = 6_000;
77
+ /** Minimum spacing between control calls to one endpoint. */
78
+ exports.CONTROL_RATE_LIMIT_MS = 100;
79
+ /** First reconnect delay after a failed poll. Doubles up to the ceiling. */
80
+ exports.POLL_BACKOFF_BASE_MS = 2_000;
81
+ /** Ceiling for poll backoff. */
82
+ exports.POLL_BACKOFF_MAX_MS = 60_000;
83
+ /** Consecutive poll failures before an accessory reports No Response. */
84
+ exports.POLL_FAILURES_BEFORE_UNKNOWN = 3;
85
+ /** How long before a still-failing player is warned about again. */
86
+ exports.POLL_FAILURE_REWARN_MS = 3_600_000;
87
+ // --- HomeKit ---------------------------------------------------------------
88
+ /**
89
+ * How long a HomeKit write may block before HAP gives up on it.
90
+ *
91
+ * HAP-NodeJS warns at `Accessory.TIMEOUT_WARNING` (3 s) and abandons the write
92
+ * at 9 s total, returning `OPERATION_TIMED_OUT` and discarding whatever the
93
+ * handler eventually returns. A set therefore has to answer well inside that
94
+ * window and finish any slower work in the background.
95
+ */
96
+ exports.HOMEKIT_WRITE_BUDGET_MS = 2_500;
97
+ /** Level restored when the slider is switched on and no previous level is known. */
98
+ exports.DEFAULT_RESTORE_VOLUME = 20;
99
+ /**
100
+ * Window for coalescing the pair of writes HomeKit sends when a slider moves
101
+ * from off.
102
+ *
103
+ * Dragging a Fanv2 up from zero produces an `Active` write immediately followed
104
+ * by a `RotationSpeed` write. Acting on both would set the restore level and then
105
+ * the requested level, which the user hears as a jump. Waiting briefly lets the
106
+ * second write supersede the first, so one value reaches the player. Short enough
107
+ * to be imperceptible next to the LAN round trip.
108
+ */
109
+ exports.SLIDER_COALESCE_MS = 150;
110
+ // --- Volume ----------------------------------------------------------------
111
+ /** Lowest BluOS volume level. */
112
+ exports.VOLUME_MIN = 0;
113
+ /** Highest BluOS volume level. */
114
+ exports.VOLUME_MAX = 100;
115
+ /**
116
+ * `volume="-1"` means the player's output level is fixed.
117
+ *
118
+ * API v1.7 section 2.2: "-1 means fixed volume". Such a player must not be
119
+ * given a volume slider; writing a level to it is meaningless.
120
+ */
121
+ exports.FIXED_VOLUME_SENTINEL = -1;
122
+ /**
123
+ * `db="-100"` means silence, and is not a real output level.
124
+ *
125
+ * Measured on firmware 4.16.6: muting reports `volume="0" db="-100"` alongside
126
+ * `muteVolume` and `muteDb` carrying the pre-mute values. Note that writing
127
+ * `level=0` reports the same `db="-100"` with no `muteVolume`, so this value
128
+ * cannot be used to detect mute — see `readMuted` in `api/sync-status.ts`.
129
+ */
130
+ exports.MUTED_DB_SENTINEL = -100;
131
+ // --- XML parsing -----------------------------------------------------------
132
+ /**
133
+ * Largest `/SyncStatus` body accepted.
134
+ *
135
+ * Real responses measure a few hundred bytes; a fully grouped CI 580 is still
136
+ * far under a kilobyte. 128 KiB is generous while keeping a hostile or
137
+ * malfunctioning endpoint from growing the heap.
138
+ */
139
+ exports.MAX_XML_BYTES = 131_072;
140
+ /** Deepest element nesting accepted. */
141
+ exports.MAX_XML_DEPTH = 16;
142
+ /** Most elements accepted in one document. */
143
+ exports.MAX_XML_ELEMENTS = 2_000;
144
+ /** Most attributes accepted on one element. */
145
+ exports.MAX_XML_ATTRIBUTES = 64;
146
+ // --- Discovery -------------------------------------------------------------
147
+ /** mDNS service type advertised by primary players. */
148
+ exports.MDNS_SERVICE_PRIMARY = '_musc._tcp.local';
149
+ /**
150
+ * mDNS service type advertised by secondary zones of a multi-zone chassis.
151
+ *
152
+ * API v1.7 appendix 13.1 maps this to LSDP class 0x0003, "BluOS Player
153
+ * (secondary in multi-zone players such as the CI580)".
154
+ */
155
+ exports.MDNS_SERVICE_SECONDARY = '_musp._tcp.local';
156
+ /** Default discovery window, in seconds. */
157
+ exports.DEFAULT_DISCOVERY_TIMEOUT_SEC = 5;
158
+ /** Shortest configurable discovery window. */
159
+ exports.MIN_DISCOVERY_TIMEOUT_SEC = 1;
160
+ /** Longest configurable discovery window. */
161
+ exports.MAX_DISCOVERY_TIMEOUT_SEC = 30;
162
+ /** Consecutive poll failures before the platform tries to re-resolve an address. */
163
+ exports.FAILURES_BEFORE_REDISCOVERY = 3;
164
+ /** Minimum gap between address re-resolution attempts. */
165
+ exports.REDISCOVERY_MIN_INTERVAL_MS = 60_000;
166
+ /**
167
+ * Most records of one kind kept from a browse window.
168
+ *
169
+ * Anything on the segment can answer a multicast query, and a browse window can
170
+ * be as long as 30 s, so the maps that accumulate advertisements need a ceiling
171
+ * they cannot be talked past. A household fleet uses a handful of entries.
172
+ */
173
+ exports.MAX_DISCOVERY_RECORDS = 256;
174
+ /**
175
+ * Most candidate endpoints verified from one browse.
176
+ *
177
+ * Verification opens a real connection per candidate, so the count of
178
+ * advertisements must not decide how many sockets this plugin opens.
179
+ */
180
+ exports.MAX_DISCOVERY_CANDIDATES = 64;
181
+ /** Candidate endpoints verified at once. Bounds concurrent sockets during a sweep. */
182
+ exports.DISCOVERY_VERIFY_CONCURRENCY = 6;
183
+ // --- Logging ---------------------------------------------------------------
184
+ /** Longest untrusted string interpolated into a log line. */
185
+ exports.MAX_LOG_FIELD_LENGTH = 100;
186
+ /** Longest accepted HomeKit display name. */
187
+ exports.MAX_NAME_LENGTH = 64;
188
+ let cachedVersion;
189
+ /**
190
+ * Read this plugin's version from `package.json`.
191
+ *
192
+ * Reported to HomeKit as FirmwareRevision, which makes the version visible in
193
+ * the Home app and therefore in bug reports.
194
+ */
195
+ function readPluginVersion(log) {
196
+ if (cachedVersion !== undefined) {
197
+ return cachedVersion;
198
+ }
199
+ try {
200
+ const pkg = require('../package.json');
201
+ cachedVersion = typeof pkg.version === 'string' && pkg.version.length > 0
202
+ ? pkg.version
203
+ : exports.UNKNOWN_PLUGIN_VERSION;
204
+ }
205
+ catch (error) {
206
+ log?.debug(`could not read plugin version: ${String(error)}`);
207
+ cachedVersion = exports.UNKNOWN_PLUGIN_VERSION;
208
+ }
209
+ return cachedVersion;
210
+ }
@@ -0,0 +1,218 @@
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 Shared types for configuration, accessory identity and the
8
+ * observations read back from a player.
9
+ */
10
+ /** The subset of Homebridge's logger this plugin uses. */
11
+ export interface PluginLogger {
12
+ info(message: string): void;
13
+ warn(message: string): void;
14
+ error(message: string): void;
15
+ debug(message: string): void;
16
+ }
17
+ /**
18
+ * What an accessory does. Part of its identity, and therefore of its UUID.
19
+ *
20
+ * `volume` is the fake slider, `mute` the mute switch, `volumePreset` a
21
+ * one-level switch, and `battery` the state-of-charge sensor for portables.
22
+ */
23
+ export declare const ACCESSORY_KINDS: readonly ["volume", "mute", "volumePreset", "battery"];
24
+ /** @see ACCESSORY_KINDS */
25
+ export type AccessoryKind = (typeof ACCESSORY_KINDS)[number];
26
+ /** Narrow an unknown value to an {@link AccessoryKind}. */
27
+ export declare function isAccessoryKind(value: unknown): value is AccessoryKind;
28
+ /**
29
+ * Which HAP service impersonates the volume slider.
30
+ *
31
+ * HomeKit has no first-class speaker volume control that the Home app renders,
32
+ * so a slider has to borrow one. `fan` uses Fanv2 `RotationSpeed`; `lightbulb`
33
+ * uses `Brightness`. They render identically, but a Lightbulb is swept up by
34
+ * "turn off all the lights", which silences the house.
35
+ */
36
+ export declare const SLIDER_SERVICES: readonly ["fan", "lightbulb"];
37
+ /** @see SLIDER_SERVICES */
38
+ export type SliderService = (typeof SLIDER_SERVICES)[number];
39
+ /** Narrow an unknown value to a {@link SliderService}. */
40
+ export declare function isSliderService(value: unknown): value is SliderService;
41
+ /** A named volume level exposed as a switch. */
42
+ export interface VolumePresetConfig {
43
+ name: string;
44
+ volume: number;
45
+ }
46
+ /** One configured player zone. */
47
+ export interface BluOSDeviceConfig {
48
+ /**
49
+ * Stable identity, normally `{normalised-mac}:{port}`.
50
+ *
51
+ * Never contains an IP address, so a DHCP change does not re-create the
52
+ * accessory. Written by the discovery UI; hand-editable but not derived.
53
+ */
54
+ id: string;
55
+ name: string;
56
+ /** Last known address. Re-resolved at launch and when the player goes silent. */
57
+ host: string;
58
+ port?: number;
59
+ model?: string;
60
+ brand?: string;
61
+ volumeSlider?: boolean;
62
+ sliderService?: SliderService;
63
+ mute?: boolean;
64
+ battery?: boolean;
65
+ volumePresets?: VolumePresetConfig[];
66
+ }
67
+ /** Platform-level tuning. */
68
+ export interface BluOSPlatformOptions {
69
+ discoveryTimeoutSec?: number;
70
+ sliderService?: SliderService;
71
+ }
72
+ /** Shape of one `platforms[]` entry in `config.json`. */
73
+ export interface BluOSPlatformConfig {
74
+ platform: string;
75
+ name?: string;
76
+ devices?: BluOSDeviceConfig[];
77
+ options?: BluOSPlatformOptions;
78
+ }
79
+ /** A configured device after validation and defaulting. */
80
+ export interface ResolvedDevice {
81
+ id: string;
82
+ name: string;
83
+ host: string;
84
+ port: number;
85
+ model?: string;
86
+ brand?: string;
87
+ volumeSlider: boolean;
88
+ sliderService: SliderService;
89
+ mute: boolean;
90
+ battery: boolean;
91
+ volumePresets: VolumePresetConfig[];
92
+ }
93
+ /** One accessory to expose, derived from a {@link ResolvedDevice}. */
94
+ export interface ResolvedAccessory {
95
+ kind: AccessoryKind;
96
+ /** Owning device's stable id. */
97
+ deviceId: string;
98
+ /** HomeKit display name. */
99
+ name: string;
100
+ sliderService: SliderService;
101
+ /** Target level, for `volumePreset` only. */
102
+ volume?: number;
103
+ }
104
+ /**
105
+ * What gets persisted in `PlatformAccessory.context`.
106
+ *
107
+ * Survives restarts, so anything needed to serve HomeKit before the first poll
108
+ * completes belongs here.
109
+ */
110
+ export interface AccessoryContext {
111
+ kind: AccessoryKind;
112
+ deviceId: string;
113
+ /** Last known address. Informational only; never part of identity. */
114
+ host: string;
115
+ port: number;
116
+ brand: string;
117
+ model: string;
118
+ /** Opaque, stable, generated once. Not the MAC: that would leak and churn. */
119
+ serialNumber: string;
120
+ /** True when this accessory was adopted from a different UUID scheme. */
121
+ adoptedLegacyUuid: boolean;
122
+ sliderService: SliderService;
123
+ /** Target level, for `volumePreset` only. */
124
+ volume?: number;
125
+ /**
126
+ * Last level seen above zero, used to restore the slider when switched on.
127
+ *
128
+ * Only a fallback: a player that was muted reports `muteVolume`, which is
129
+ * authoritative and preferred.
130
+ */
131
+ lastNonZeroVolume?: number;
132
+ }
133
+ /**
134
+ * How a player relates to a runtime sync group.
135
+ *
136
+ * A union rather than a const array plus a narrower, because nothing has to
137
+ * validate an incoming value against it: the role is derived from the shape of a
138
+ * `/SyncStatus` response, never read from configuration.
139
+ */
140
+ export type SyncRole = 'standalone' | 'primary' | 'secondary';
141
+ /** Battery state, present only on players with a battery pack. */
142
+ export interface BatteryObservation {
143
+ level: number;
144
+ charging: boolean;
145
+ }
146
+ /**
147
+ * A parsed `/SyncStatus` response.
148
+ *
149
+ * `/Status` is deliberately not used: the specification points at `/SyncStatus`
150
+ * when "only the name, volume and grouping status of a player is of interest",
151
+ * and for a group secondary it is the only endpoint that reports that player's
152
+ * own volume rather than the group's.
153
+ */
154
+ export interface PlayerObservation {
155
+ name: string;
156
+ brand?: string;
157
+ /** Model code, e.g. `CI-S2`. */
158
+ model?: string;
159
+ /** Display model name, e.g. `CI S2`. */
160
+ modelName?: string;
161
+ firmware?: string;
162
+ /** Normalised chassis MAC with no zone-port suffix, upper case. */
163
+ mac?: string;
164
+ /** Level 0..100, or undefined when the player reports fixed volume. */
165
+ volume?: number;
166
+ /** True when fixed-output; a slider must not be exposed. */
167
+ fixedVolume: boolean;
168
+ muted: boolean;
169
+ /** Pre-mute level, present only while muted. */
170
+ muteVolume?: number;
171
+ /**
172
+ * Output level in dB. Absent or sentinel while muted.
173
+ *
174
+ * Parsed and carried but not yet acted on: no accessory needs it, because
175
+ * HomeKit has no decibel characteristic. Kept because it is the only reading
176
+ * that describes the actual output of a player whose 0-100 level is mapped onto
177
+ * a restricted dB range, which is what a future dB-aware control would need.
178
+ */
179
+ db?: number;
180
+ syncRole: SyncRole;
181
+ battery?: BatteryObservation;
182
+ /** Opaque long-poll token from the response root. */
183
+ etag?: string;
184
+ /**
185
+ * Opaque sync-generation token from the response root.
186
+ *
187
+ * Parsed and carried but not yet acted on: grouping is derived from the
188
+ * `<master>` and `<slave>` elements instead. Kept because it changes whenever
189
+ * group membership does, which is the cheap way to notice a regrouping without
190
+ * comparing element lists.
191
+ */
192
+ syncStat?: string;
193
+ }
194
+ /** A player found by discovery and confirmed to answer `/SyncStatus`. */
195
+ export interface DiscoveredPlayer {
196
+ id: string;
197
+ name: string;
198
+ host: string;
199
+ port: number;
200
+ brand?: string;
201
+ model?: string;
202
+ modelName?: string;
203
+ firmware?: string;
204
+ mac?: string;
205
+ fixedVolume: boolean;
206
+ hasBattery: boolean;
207
+ }
208
+ /** Why a refresh was requested, for logging and coalescing. */
209
+ export type RefreshReason = 'poll' | 'post-set' | 'startup';
210
+ /** An accessory handler the platform can drive. */
211
+ export interface RefreshableAccessory {
212
+ readonly deviceId: string;
213
+ readonly displayName: string;
214
+ /** Apply a fresh observation to HomeKit characteristics. */
215
+ applyObservation(observation: PlayerObservation, reason: RefreshReason): void;
216
+ /** Report that the player could not be reached. */
217
+ noteUnreachable(error: unknown): void;
218
+ }
@@ -0,0 +1,38 @@
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 types for configuration, accessory identity and the
9
+ * observations read back from a player.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.SLIDER_SERVICES = exports.ACCESSORY_KINDS = void 0;
13
+ exports.isAccessoryKind = isAccessoryKind;
14
+ exports.isSliderService = isSliderService;
15
+ /**
16
+ * What an accessory does. Part of its identity, and therefore of its UUID.
17
+ *
18
+ * `volume` is the fake slider, `mute` the mute switch, `volumePreset` a
19
+ * one-level switch, and `battery` the state-of-charge sensor for portables.
20
+ */
21
+ exports.ACCESSORY_KINDS = ['volume', 'mute', 'volumePreset', 'battery'];
22
+ /** Narrow an unknown value to an {@link AccessoryKind}. */
23
+ function isAccessoryKind(value) {
24
+ return typeof value === 'string' && exports.ACCESSORY_KINDS.includes(value);
25
+ }
26
+ /**
27
+ * Which HAP service impersonates the volume slider.
28
+ *
29
+ * HomeKit has no first-class speaker volume control that the Home app renders,
30
+ * so a slider has to borrow one. `fan` uses Fanv2 `RotationSpeed`; `lightbulb`
31
+ * uses `Brightness`. They render identically, but a Lightbulb is swept up by
32
+ * "turn off all the lights", which silences the house.
33
+ */
34
+ exports.SLIDER_SERVICES = ['fan', 'lightbulb'];
35
+ /** Narrow an unknown value to a {@link SliderService}. */
36
+ function isSliderService(value) {
37
+ return typeof value === 'string' && exports.SLIDER_SERVICES.includes(value);
38
+ }
@@ -0,0 +1,20 @@
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 surface the configuration UI server is allowed to use.
8
+ *
9
+ * An explicit contract rather than a barrel. The UI runs in a separate process
10
+ * and is the one consumer outside the plugin itself, so pinning what it may
11
+ * reach means its dependencies are visible here instead of being discovered by
12
+ * breaking it. In particular the identity helpers are shared rather than
13
+ * reimplemented: the ids the UI writes into configuration have to match what the
14
+ * platform derives, and two implementations would eventually disagree.
15
+ */
16
+ export { BluOSClient } from './api/client';
17
+ export { BluOSDiscovery } from './api/discovery';
18
+ export { makeGeneratedPlayerId } from './api/identity';
19
+ export { DEFAULT_DISCOVERY_TIMEOUT_SEC, DOCUMENTED_BLUOS_PORTS, MAX_DISCOVERY_TIMEOUT_SEC, MIN_DISCOVERY_TIMEOUT_SEC, } from './settings';
20
+ export { isProbeableHost, isValidHost } from './utils/validators';
package/dist/ui-api.js ADDED
@@ -0,0 +1,32 @@
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 surface the configuration UI server is allowed to use.
9
+ *
10
+ * An explicit contract rather than a barrel. The UI runs in a separate process
11
+ * and is the one consumer outside the plugin itself, so pinning what it may
12
+ * reach means its dependencies are visible here instead of being discovered by
13
+ * breaking it. In particular the identity helpers are shared rather than
14
+ * reimplemented: the ids the UI writes into configuration have to match what the
15
+ * platform derives, and two implementations would eventually disagree.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.isValidHost = exports.isProbeableHost = exports.MIN_DISCOVERY_TIMEOUT_SEC = exports.MAX_DISCOVERY_TIMEOUT_SEC = exports.DOCUMENTED_BLUOS_PORTS = exports.DEFAULT_DISCOVERY_TIMEOUT_SEC = exports.makeGeneratedPlayerId = exports.BluOSDiscovery = exports.BluOSClient = void 0;
19
+ var client_1 = require("./api/client");
20
+ Object.defineProperty(exports, "BluOSClient", { enumerable: true, get: function () { return client_1.BluOSClient; } });
21
+ var discovery_1 = require("./api/discovery");
22
+ Object.defineProperty(exports, "BluOSDiscovery", { enumerable: true, get: function () { return discovery_1.BluOSDiscovery; } });
23
+ var identity_1 = require("./api/identity");
24
+ Object.defineProperty(exports, "makeGeneratedPlayerId", { enumerable: true, get: function () { return identity_1.makeGeneratedPlayerId; } });
25
+ var settings_1 = require("./settings");
26
+ Object.defineProperty(exports, "DEFAULT_DISCOVERY_TIMEOUT_SEC", { enumerable: true, get: function () { return settings_1.DEFAULT_DISCOVERY_TIMEOUT_SEC; } });
27
+ Object.defineProperty(exports, "DOCUMENTED_BLUOS_PORTS", { enumerable: true, get: function () { return settings_1.DOCUMENTED_BLUOS_PORTS; } });
28
+ Object.defineProperty(exports, "MAX_DISCOVERY_TIMEOUT_SEC", { enumerable: true, get: function () { return settings_1.MAX_DISCOVERY_TIMEOUT_SEC; } });
29
+ Object.defineProperty(exports, "MIN_DISCOVERY_TIMEOUT_SEC", { enumerable: true, get: function () { return settings_1.MIN_DISCOVERY_TIMEOUT_SEC; } });
30
+ var validators_1 = require("./utils/validators");
31
+ Object.defineProperty(exports, "isProbeableHost", { enumerable: true, get: function () { return validators_1.isProbeableHost; } });
32
+ Object.defineProperty(exports, "isValidHost", { enumerable: true, get: function () { return validators_1.isValidHost; } });
@@ -0,0 +1,18 @@
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 Validation of the data cached on a restored accessory.
8
+ *
9
+ * Homebridge hands back whatever was persisted when the plugin last ran, which
10
+ * may have been written by an older version with a different shape. Reading it
11
+ * through one checked path means a stale or hand-edited cache produces a clear
12
+ * error for that one accessory instead of an undefined field surfacing much
13
+ * later as a wrong characteristic value.
14
+ */
15
+ import type { PlatformAccessory } from 'homebridge';
16
+ import { type AccessoryContext } from '../types';
17
+ /** Read and validate a restored accessory's context. */
18
+ export declare function parseAccessoryContext(accessory: PlatformAccessory): AccessoryContext;
@@ -0,0 +1,56 @@
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 Validation of the data cached on a restored accessory.
9
+ *
10
+ * Homebridge hands back whatever was persisted when the plugin last ran, which
11
+ * may have been written by an older version with a different shape. Reading it
12
+ * through one checked path means a stale or hand-edited cache produces a clear
13
+ * error for that one accessory instead of an undefined field surfacing much
14
+ * later as a wrong characteristic value.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.parseAccessoryContext = parseAccessoryContext;
18
+ const settings_1 = require("../settings");
19
+ const types_1 = require("../types");
20
+ const errors_1 = require("./errors");
21
+ const validators_1 = require("./validators");
22
+ /** Read and validate a restored accessory's context. */
23
+ function parseAccessoryContext(accessory) {
24
+ const raw = (accessory.context ?? {});
25
+ if (!(0, types_1.isAccessoryKind)(raw.kind)) {
26
+ throw new errors_1.ConfigValidationError(`cached accessory ${(0, validators_1.forLog)(accessory.displayName)} has an unknown kind ${(0, validators_1.forLog)(raw.kind)}`);
27
+ }
28
+ if (typeof raw.deviceId !== 'string' || raw.deviceId.length === 0) {
29
+ throw new errors_1.ConfigValidationError(`cached accessory ${(0, validators_1.forLog)(accessory.displayName)} has no device id`);
30
+ }
31
+ if (typeof raw.serialNumber !== 'string' || raw.serialNumber.length === 0) {
32
+ throw new errors_1.ConfigValidationError(`cached accessory ${(0, validators_1.forLog)(accessory.displayName)} has no serial number`);
33
+ }
34
+ if (raw.kind === 'volumePreset' && !Number.isInteger(raw.volume)) {
35
+ throw new errors_1.ConfigValidationError(`cached preset ${(0, validators_1.forLog)(accessory.displayName)} has no target volume`);
36
+ }
37
+ const sliderService = (0, types_1.isSliderService)(raw.sliderService) ? raw.sliderService : 'fan';
38
+ const context = {
39
+ kind: raw.kind,
40
+ deviceId: raw.deviceId,
41
+ host: typeof raw.host === 'string' ? raw.host : '',
42
+ port: Number.isInteger(raw.port) ? raw.port : settings_1.DEFAULT_BLUOS_PORT,
43
+ brand: typeof raw.brand === 'string' && raw.brand.length > 0 ? raw.brand : settings_1.DEFAULT_BRAND,
44
+ model: typeof raw.model === 'string' && raw.model.length > 0 ? raw.model : settings_1.DEFAULT_MODEL,
45
+ serialNumber: raw.serialNumber,
46
+ adoptedLegacyUuid: raw.adoptedLegacyUuid === true,
47
+ sliderService,
48
+ };
49
+ if (Number.isInteger(raw.volume)) {
50
+ context.volume = raw.volume;
51
+ }
52
+ if (Number.isInteger(raw.lastNonZeroVolume)) {
53
+ context.lastNonZeroVolume = raw.lastNonZeroVolume;
54
+ }
55
+ return context;
56
+ }
@@ -0,0 +1,37 @@
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 Error description helpers.
8
+ *
9
+ * Node wraps low-level network failures in `cause` chains, so a bare
10
+ * `error.message` frequently reads "fetch failed" while the useful detail
11
+ * (ECONNREFUSED, ETIMEDOUT) sits one level down.
12
+ */
13
+ /**
14
+ * Describe an error, including any `cause` chain, for a single log line.
15
+ *
16
+ * Control characters are stripped: an error message can contain remote input,
17
+ * and a newline inside a log line lets an attacker forge log entries.
18
+ */
19
+ export declare function describeError(error: unknown): string;
20
+ /** Describe an error and append its stack, for `log.debug` only. */
21
+ export declare function describeErrorStack(error: unknown): string;
22
+ /** Raised when configuration cannot produce a usable accessory set. */
23
+ export declare class ConfigValidationError extends Error {
24
+ constructor(message: string);
25
+ }
26
+ /** Raised when a player answers, but not with something we can parse. */
27
+ export declare class ProtocolError extends Error {
28
+ constructor(message: string, options?: {
29
+ cause?: unknown;
30
+ });
31
+ }
32
+ /** Raised when a player cannot be reached at all. */
33
+ export declare class ConnectionError extends Error {
34
+ constructor(message: string, options?: {
35
+ cause?: unknown;
36
+ });
37
+ }