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,194 @@
1
+ # BluOS Custom Integration API — what this plugin relies on
2
+
3
+ A reference for the subset of the API this plugin uses, and for the places where real hardware and the published specification disagree. Everything marked **verified** was measured against physical players running BluOS firmware **4.16.6**: a NAD C658, a two-zone NAD CI-S2, a Bluesound P430 soundbar and a battery-equipped portable. The recorded responses are committed under [`tests/fixtures`](../tests/fixtures), so the parser is tested against what the firmware sends rather than what the specification implies.
4
+
5
+ Specification references are to **Custom Integration API v1.7**.
6
+
7
+ ## Transport
8
+
9
+ | Property | Value |
10
+ | --- | --- |
11
+ | Protocol | HTTP/1.1, plain text XML, no authentication |
12
+ | Port | `11000` for a player; `11010`, `11020`, `11030` for the extra zones of a multi-zone chassis (spec §1) |
13
+ | Discovery | mDNS `_musc._tcp.local` (primary) and `_musp._tcp.local` (secondary zones) |
14
+
15
+ There is no authentication of any kind. Anyone who can reach the port can change the volume, which is the security model the plugin inherits and cannot improve on; it is the reason nothing here is exposed beyond the LAN.
16
+
17
+ ### Rate rules the API imposes
18
+
19
+ Spec §2, phrased as requirements rather than advice:
20
+
21
+ - **At least one second** between two consecutive requests for the *same resource*, "even if the first request returns in less than one second"
22
+ - **At most one request every 30 seconds** when long-polling is not used
23
+
24
+ The first is enforced in `api/client.ts` rather than at the call sites, so no caller can violate it by accident. The second does not arise: every zone is long-polled, and a plain read happens only for the single cycle that follows a write, an address change or a failure, which the one-second rule already paces. The plugin adds two rules of its own: 100 ms between control calls to one endpoint, so a HomeKit scene touching several tiles cannot burst a player, and serialised writes per *chassis*, because the zones of a CI-S2 are one box on one IP.
25
+
26
+ ## `/SyncStatus`
27
+
28
+ The state read. Volume, mute and grouping all come from here.
29
+
30
+ A recorded response, from a NAD C658, complete apart from line wrapping — the wire form is one line, and the addresses and the MAC are pseudonymised as everything in [`tests/fixtures`](../tests/fixtures) is:
31
+
32
+ ```xml
33
+ <SyncStatus etag="9" syncStat="9" version="4.16.6" id="192.168.4.10:11000"
34
+ db="-42" volume="41" name="Amplifier" model="C658" modelName="C658"
35
+ class="streamer" icon="/images/players/C658_nt.png" brand="NAD"
36
+ schemaVersion="34" initialized="true"
37
+ mac="90:56:82:0A:00:01"><bluetoothOutput></bluetoothOutput></SyncStatus>
38
+ ```
39
+
40
+ | Attribute | Meaning |
41
+ | --- | --- |
42
+ | `volume` | `0`–`100`, or `-1` for a fixed-output player (spec §2.2) |
43
+ | `db` | Current output in dB; `-100` means silence |
44
+ | `muteVolume` | Level to return to on unmute. **Present only while muted** |
45
+ | `muteDb` | dB to return to on unmute. Present only while muted |
46
+ | `mac` | Chassis NIC. On a secondary zone, the zone's port is appended as a seventh colon-separated field |
47
+ | `etag`, `syncStat` | Opaque state tokens; `etag` is what a long-poll passes back |
48
+ | `id` | The `host:port` the player believes it is. `readSyncRole` compares `<master>` against it, falling back to the address the request was sent to, so a player reached by hostname still recognises its own `<master>` |
49
+ | `brand`, `model`, `modelName`, `version` | Identity, published to HomeKit |
50
+ | `<master>` | Present when this zone follows another; a group follower. Compare against `id`, since a zone can briefly report itself while regrouping |
51
+ | `<slave>` | One per follower when this zone leads a group, carrying the follower's `id`, `port` and `name` |
52
+ | `group` | Display string naming the group's members, e.g. `Zone Three+Zone Four`. Present on the leader only, and not used for any decision |
53
+ | `<battery level="91" charging="false"/>` | Child element, only on players with a pack fitted |
54
+
55
+ ### Long-polling — verified
56
+
57
+ `GET /SyncStatus?timeout=<seconds>&etag=<etag>` holds the connection until the state changes or the window elapses.
58
+
59
+ - With a **current** etag, `timeout=15` held for **15.03 s** and returned the unchanged document
60
+ - With a **stale** etag, the same request answered in **44 ms**
61
+ - Held **per zone, not per chassis**: changing zone one's volume did not wake zone two's poll, so a multi-zone chassis needs one poll per zone
62
+ - A control call on a separate TCP connection completes normally while a poll is being held, so writes need not interrupt reads
63
+
64
+ The plugin uses `timeout=100`. The specification recommends 180 s for `/SyncStatus` and forbids under 10 s; 100 s stays inside that envelope while halving the worst-case delay before an unplugged player's socket read times out, which is how a silent disappearance — as opposed to a connection reset — gets noticed.
65
+
66
+ ### Mute is not what the specification suggests — verified
67
+
68
+ This is the finding that shaped the plugin's state handling. The same zone, in three states:
69
+
70
+ | State | `/SyncStatus` reports |
71
+ | --- | --- |
72
+ | Playing at 60 | `volume="60" db="-32.1"` |
73
+ | **Muted** | `volume="0" db="-100" muteVolume="60" muteDb="-32.1"` |
74
+ | **Level set to 0** | `volume="0" db="-100"` |
75
+
76
+ Three consequences:
77
+
78
+ 1. **`/SyncStatus` never sends a `mute` attribute at all** — not `mute="0"` when unmuted, and not `mute="1"` when muted. `/Volume` does; `/SyncStatus` does not
79
+ 2. `volume="0"` and `db="-100"` are **identical** in the muted and the level-zero cases, so neither can be used to detect mute. Inferring mute from `db` would switch the mute tile on whenever a user dragged the slider to zero
80
+ 3. The only distinguishing signal is `muteVolume`/`muteDb`, which the firmware publishes **exclusively while muted**
81
+
82
+ So mute is inferred from the presence of the remembered pre-mute level. See `readMuted` in `api/sync-status.ts`, and the `*-muted` and `*-level-zero` fixtures that pin both cases.
83
+
84
+ ### Multi-zone identity — verified
85
+
86
+ On a two-zone CI-S2, both zones report the same chassis NIC, but the secondary appends its own port:
87
+
88
+ | Zone | Endpoint | `mac` as reported |
89
+ | --- | --- | --- |
90
+ | One | `:11000` | `90:56:82:0A:00:02` |
91
+ | Two | `:11010` | `90:56:82:0A:00:02:11010` |
92
+
93
+ The MAC alone is therefore ambiguous across zones. Identity is derived as `MAC:port` (`api/identity.ts`), using the zone's own control port, which is stable across firmware updates and DHCP lease changes alike.
94
+
95
+ ## `/Volume`
96
+
97
+ The write, and a second read.
98
+
99
+ ```
100
+ GET /Volume?level=35&tell_slaves=0 → set this zone to 35, and nothing else
101
+ GET /Volume?level=35&tell_slaves=1 → set this zone and anything grouped under it
102
+ GET /Volume?mute=1&tell_slaves=0 → mute this zone
103
+ GET /Volume → read
104
+ ```
105
+
106
+ ```xml
107
+ <volume db="-42" offsetDb="0" mute="0" etag="4f3722e3..." source="Endpoint">41</volume>
108
+ ```
109
+
110
+ The level is the element's **text content**, not an attribute — unlike `/SyncStatus`, where it is an attribute. `mute` *is* present here, in both states.
111
+
112
+ ### `mute=1` mutes — verified, and the spec's parameter table is wrong
113
+
114
+ Spec §3.1's parameter table states the inverse mapping. Sections 3.4 and 3.5, the response attribute tables, and the hardware all agree with the mapping used here: writing `mute=1` produced `mute="1" muteVolume="72"` on a real player. Writing `mute=0` restored the level. The plugin follows the hardware.
115
+
116
+ ### `tell_slaves` is always sent explicitly, and follows the zone's role
117
+
118
+ The parameter decides whether a change propagates to the players grouped under this one. The plugin never leaves it to the firmware's default, because the right answer depends on what the zone currently is:
119
+
120
+ | Zone's `syncRole` | Sent | Reasoning |
121
+ | --- | --- | --- |
122
+ | `primary` (leads a group) | `tell_slaves=1` | While the group exists, the leader's tile *is* the group's control, which is how the BluOS app's own slider behaves |
123
+ | `secondary` (follows one) | `tell_slaves=0` | A tile labelled one room must not change the room leading it |
124
+ | `standalone` | `tell_slaves=0` | Nothing to propagate to |
125
+
126
+ The role comes from the last `/SyncStatus`, so ungrouping takes effect on the next poll with no bookkeeping. The same rule applies to mute and to volume presets: they are all "put this room at this level", and one of them reaching the group while another did not would be indefensible.
127
+
128
+ ### Grouping as reported — verified
129
+
130
+ Recorded from two zones of one CI-S2 while grouped, on firmware 4.16.6. The leader lists one `<slave>` per follower and gains a display-only `group` attribute:
131
+
132
+ ```xml
133
+ <SyncStatus etag="101" id="192.168.4.14:11000" volume="60"
134
+ name="Zone Three" group="Zone Three+Zone Four" mac="90:56:82:0A:00:05">
135
+ <slave id="192.168.4.14" port="11010" name="Zone Four" model="CI-S2" icon="…"></slave>
136
+ <pairWithSub></pairWithSub>
137
+ </SyncStatus>
138
+ ```
139
+
140
+ The follower names its leader, with the port as an attribute rather than in the text:
141
+
142
+ ```xml
143
+ <SyncStatus etag="108" id="192.168.4.14:11010" volume="60"
144
+ name="Zone Four" mac="90:56:82:0A:00:05:11010">
145
+ <master port="11000">192.168.4.14</master>
146
+ </SyncStatus>
147
+ ```
148
+
149
+ Both are pinned as fixtures. Two details worth noting:
150
+
151
+ - Grouping is per zone, not per chassis. These two zones share a NIC and a MAC, and one leads the other, so nothing about identity or write scope can be derived from the MAC alone.
152
+ - The follower keeps reporting **its own** volume while grouped (60 here, independent of the leader's), which is the behaviour `/Status` does not have.
153
+
154
+ ### `zoneMaster="true"` is not grouping
155
+
156
+ A Bluesound player advertises stereo-pairing options as children:
157
+
158
+ ```xml
159
+ <zoneOptions>
160
+ <option zoneMaster="true">left</option>
161
+ <option zoneMaster="true">right</option>
162
+ </zoneOptions>
163
+ ```
164
+
165
+ This is a pairing option and says nothing about groups. Any attribute lookup loose enough to see `zoneMaster` as `master` — a case-insensitive match, or a substring one — turns every paired speaker into a group follower. A fixture pins that such a player reads as `standalone`. A regex in `scripts/grouping.js` originally made exactly this mistake, which is what caught it.
166
+
167
+ ### The player's answer is authoritative
168
+
169
+ A player clamps the requested level into its own configured dB range, so the response can differ from the request. The plugin adopts what the response says rather than optimistically showing what it asked for.
170
+
171
+ This is also why the plugin has no maximum-volume setting of its own. Level 100 is not an absolute loudness: it is the top of whatever range the player is configured for, set per player in the BluOS app. A ceiling configured there is enforced by the player itself, for every controller including this one, and it cannot be bypassed by a HomeKit automation or a Siri phrase. A second ceiling here would only be able to lie about the first.
172
+
173
+ ## Discovery
174
+
175
+ mDNS, browsing two service types:
176
+
177
+ | Service | Advertised by |
178
+ | --- | --- |
179
+ | `_musc._tcp.local` | Primary players |
180
+ | `_musp._tcp.local` | Secondary zones of a multi-zone chassis (spec appendix §13.1, LSDP class `0x0003`) |
181
+
182
+ A zone is usable once its `SRV` record (for the port) and an IPv4 address are both known; the address comes from an `A` record when one is offered and otherwise from the responder's own source address. `TXT` records carry `model`, `version`, `mac` and `zs`, but secondary zones **omit `mac`**, which is why identity is always confirmed by reading `/SyncStatus` rather than trusted from the advertisement.
183
+
184
+ **LSDP** (UDP 11430) is documented as an alternative and was tried first: it failed repeatedly against this fleet, while mDNS answered reliably. mDNS is therefore the only discovery path, with manual address entry as the fallback for networks that filter multicast.
185
+
186
+ ## What this plugin does not use yet
187
+
188
+ `/Status`, `/Play`, `/Pause`, `/Skip`, `/Back`, `/Presets`, `/Browse`, `/AddSlave` and `/RemoveSlave` are unused today. Grouping and transport are on the [roadmap](../README.md#roadmap). Browsing, queue editing and artwork are not planned: HomeKit cannot render them, and a second controller with its own idea of that state is worse than none. See the scope note in the [README](../README.md).
189
+
190
+ ## Sources
191
+
192
+ - BluOS Custom Integration API v1.7 (§1 ports, §2 polling and rate rules, §2.2 fixed volume, §3.1/3.4/3.5 `/Volume`, appendix §13.1 LSDP classes)
193
+ - Measurements against NAD C658, NAD CI-S2 (both zones), Bluesound P430 and a battery-equipped portable, all on firmware 4.16.6
194
+ - Recorded responses: [`tests/fixtures`](../tests/fixtures)
@@ -0,0 +1,87 @@
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
+ Markup and styles for the custom configuration UI. The behaviour lives in
8
+ index.js, which is a separate file so that the linter and the tests can reach
9
+ it.
10
+ -->
11
+ <style>
12
+ .bluos-intro { margin-bottom: 1rem; }
13
+ .bluos-toolbar { display: flex; gap: .5rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: 1rem; }
14
+ .bluos-toolbar .form-group { margin-bottom: 0; }
15
+ .bluos-card { border: 1px solid rgba(128, 128, 128, .3); border-radius: .5rem; padding: .85rem 1rem; margin-bottom: .75rem; }
16
+ .bluos-card.is-selected { border-color: #007bff; box-shadow: 0 0 0 1px rgba(0, 123, 255, .35); }
17
+ .bluos-card-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }
18
+ .bluos-card-title { font-weight: 600; flex: 1 1 12rem; min-width: 12rem; }
19
+ .bluos-meta { font-size: .82rem; opacity: .75; margin-top: .15rem; }
20
+ .bluos-badge { font-size: .7rem; text-transform: uppercase; letter-spacing: .03em; padding: .12rem .4rem; border-radius: .25rem; background: rgba(128, 128, 128, .2); }
21
+ .bluos-badge.warn { background: rgba(255, 193, 7, .28); }
22
+ .bluos-options { display: flex; gap: 1.1rem; flex-wrap: wrap; margin-top: .6rem; }
23
+ .bluos-presets { margin-top: .6rem; }
24
+ .bluos-preset-row { display: flex; gap: .4rem; align-items: center; margin-bottom: .35rem; }
25
+ .bluos-preset-row input[type="text"] { flex: 1 1 8rem; }
26
+ .bluos-preset-row input[type="number"] { width: 5.5rem; }
27
+ .bluos-empty { opacity: .7; font-style: italic; padding: 1rem 0; }
28
+ .bluos-actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; margin-top: 1rem; }
29
+ .bluos-actions .spacer { flex: 1 1 auto; }
30
+ </style>
31
+
32
+ <div class="bluos-intro">
33
+ <p class="mb-1">
34
+ Discovery browses your network for BluOS zones. Multi-zone amplifiers such as the
35
+ NAD CI&nbsp;S2 and CI&nbsp;580 appear once per zone, each with its own volume.
36
+ </p>
37
+ <p class="mb-0 text-muted" style="font-size: .85rem;">
38
+ HomeKit has no speaker volume control that the Home app draws, so the volume slider is
39
+ exposed as a fan. Turning it off sets the level to zero; the mute switch is separate.
40
+ </p>
41
+ </div>
42
+
43
+ <div class="bluos-toolbar">
44
+ <button class="btn btn-primary" id="discover">Discover Players</button>
45
+ <div class="form-group">
46
+ <label for="timeout" class="mb-0" style="font-size: .8rem;">Listen for</label>
47
+ <select class="form-control form-control-sm" id="timeout">
48
+ <option value="3">3 seconds</option>
49
+ <option value="5" selected>5 seconds</option>
50
+ <option value="10">10 seconds</option>
51
+ <option value="20">20 seconds</option>
52
+ </select>
53
+ </div>
54
+ <div class="spacer" style="flex: 1 1 auto;"></div>
55
+ <button class="btn btn-outline-secondary btn-sm" id="toggle-manual">Add by address</button>
56
+ <button class="btn btn-outline-secondary btn-sm" id="toggle-json">Advanced editor</button>
57
+ </div>
58
+
59
+ <div id="manual" class="bluos-card" hidden>
60
+ <div class="form-row">
61
+ <div class="form-group col-sm-7 mb-2">
62
+ <label for="manual-host" style="font-size: .8rem;">IP address or hostname</label>
63
+ <input type="text" class="form-control form-control-sm" id="manual-host" placeholder="192.168.4.10" />
64
+ </div>
65
+ <div class="form-group col-sm-3 mb-2">
66
+ <label for="manual-port" style="font-size: .8rem;">Port (optional)</label>
67
+ <input type="number" class="form-control form-control-sm" id="manual-port" placeholder="all" />
68
+ </div>
69
+ <div class="form-group col-sm-2 mb-2 d-flex align-items-end">
70
+ <button class="btn btn-secondary btn-sm btn-block" id="manual-probe">Probe</button>
71
+ </div>
72
+ </div>
73
+ <div class="text-muted" style="font-size: .8rem;">
74
+ Use this when multicast is filtered on your network. Leave the port empty to try every
75
+ documented BluOS port.
76
+ </div>
77
+ </div>
78
+
79
+ <div id="players"></div>
80
+
81
+ <div class="bluos-actions">
82
+ <button class="btn btn-success" id="save" disabled>Save</button>
83
+ <span class="spacer"></span>
84
+ <span id="summary" class="text-muted" style="font-size: .85rem;"></span>
85
+ </div>
86
+
87
+ <script src="index.js"></script>