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,120 @@
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 Stable player identity.
9
+ *
10
+ * Accessory identity must survive a DHCP lease change, so it is built from the
11
+ * chassis MAC and the zone's control port, never from an address. Verified on
12
+ * firmware 4.16.6 against a NAD CI-S2, where the primary zone reports
13
+ * `mac="90:56:82:0A:00:01"` and its secondary zone reports the same NIC with a
14
+ * port suffix, `mac="90:56:82:0A:00:01:11010"`.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.parseMac = parseMac;
18
+ exports.normalizeMac = normalizeMac;
19
+ exports.makePlayerId = makePlayerId;
20
+ exports.makeGeneratedPlayerId = makeGeneratedPlayerId;
21
+ exports.isValidPlayerId = isValidPlayerId;
22
+ exports.formatEndpoint = formatEndpoint;
23
+ exports.accessoryIdentityKey = accessoryIdentityKey;
24
+ exports.hasAccessoryIdentity = hasAccessoryIdentity;
25
+ const node_crypto_1 = require("node:crypto");
26
+ const settings_1 = require("../settings");
27
+ const MAC_OCTET = /^[0-9A-Fa-f]{2}$/;
28
+ const BARE_MAC = /^[0-9A-Fa-f]{12}$/;
29
+ const PLAYER_ID = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,127}$/;
30
+ /**
31
+ * Parse a MAC as reported by `/SyncStatus` or an mDNS TXT record.
32
+ *
33
+ * Accepts three shapes seen in the field: six colon-separated octets, six
34
+ * octets plus a numeric zone-port suffix (multi-zone secondaries), and twelve
35
+ * bare hex digits (mDNS TXT records report `mac=9056820A0002`).
36
+ *
37
+ * Returns undefined rather than guessing, so a caller can fall back to a
38
+ * persisted identity instead of inventing an unstable one.
39
+ */
40
+ function parseMac(value) {
41
+ if (typeof value !== 'string') {
42
+ return undefined;
43
+ }
44
+ const cleaned = value.trim();
45
+ if (cleaned.length === 0) {
46
+ return undefined;
47
+ }
48
+ if (BARE_MAC.test(cleaned)) {
49
+ const octets = cleaned.toUpperCase().match(/.{2}/g);
50
+ return octets ? { mac: octets.join(':') } : undefined;
51
+ }
52
+ const parts = cleaned.split(':');
53
+ const octetsOk = parts.length >= 6 && parts.slice(0, 6).every((part) => MAC_OCTET.test(part));
54
+ if (!octetsOk) {
55
+ return undefined;
56
+ }
57
+ const mac = parts.slice(0, 6).join(':').toUpperCase();
58
+ if (parts.length === 6) {
59
+ return { mac };
60
+ }
61
+ const suffix = parts[6];
62
+ if (parts.length === 7 && suffix !== undefined && /^\d+$/.test(suffix)) {
63
+ const suffixPort = Number(suffix);
64
+ if (suffixPort >= settings_1.MIN_PORT && suffixPort <= settings_1.MAX_PORT) {
65
+ return { mac, suffixPort };
66
+ }
67
+ }
68
+ // Extra colon-separated fields we do not recognise: keep the NIC, drop the rest.
69
+ return { mac };
70
+ }
71
+ /** Normalise a MAC to six upper-case colon-separated octets, dropping any suffix. */
72
+ function normalizeMac(value) {
73
+ return parseMac(value)?.mac;
74
+ }
75
+ /**
76
+ * Build a player id from a chassis MAC and the zone's control port.
77
+ *
78
+ * The result deliberately matches the shape a multi-zone secondary already
79
+ * reports for itself (`90:56:82:0A:00:01:11010`), so ids read the same whether
80
+ * they were derived here or observed on the wire.
81
+ */
82
+ function makePlayerId(mac, port) {
83
+ return `${mac.toUpperCase()}:${port}`;
84
+ }
85
+ /**
86
+ * Generate a persisted identity for a player that reports no usable MAC.
87
+ *
88
+ * Deliberately random rather than derived from name or address: both change,
89
+ * and a changing id silently orphans the accessory. The discovery UI writes
90
+ * this into configuration once and it is stable from then on.
91
+ */
92
+ function makeGeneratedPlayerId() {
93
+ return `gen-${(0, node_crypto_1.randomUUID)()}`;
94
+ }
95
+ /** True when a value is safe to use as a player id and accessory UUID seed. */
96
+ function isValidPlayerId(value) {
97
+ return typeof value === 'string' && PLAYER_ID.test(value);
98
+ }
99
+ /** Canonical `host:port` string for an endpoint. */
100
+ function formatEndpoint(host, port = settings_1.DEFAULT_BLUOS_PORT) {
101
+ return `${host}:${port}`;
102
+ }
103
+ /**
104
+ * The identity of an accessory: what it does, for which player.
105
+ *
106
+ * Two accessories with the same key are the same accessory, and one whose key
107
+ * changes is a different accessory. Contains no address, so re-addressing a
108
+ * player leaves every UUID untouched.
109
+ */
110
+ function accessoryIdentityKey(accessory) {
111
+ const base = `${accessory.deviceId}:${accessory.kind}`;
112
+ return accessory.kind === 'volumePreset' ? `${base}:${accessory.volume ?? 0}` : base;
113
+ }
114
+ /** True when a cached context describes the same accessory as a resolved one. */
115
+ function hasAccessoryIdentity(context, accessory) {
116
+ if (context.kind !== accessory.kind || context.deviceId !== accessory.deviceId) {
117
+ return false;
118
+ }
119
+ return accessory.kind !== 'volumePreset' || context.volume === accessory.volume;
120
+ }
@@ -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 API barrel.
8
+ */
9
+ export * from './client';
10
+ export * from './discovery';
11
+ export * from './http';
12
+ export * from './identity';
13
+ export * from './sync-status';
14
+ export * from './xml';
@@ -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 API 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("./client"), exports);
26
+ __exportStar(require("./discovery"), exports);
27
+ __exportStar(require("./http"), exports);
28
+ __exportStar(require("./identity"), exports);
29
+ __exportStar(require("./sync-status"), exports);
30
+ __exportStar(require("./xml"), exports);
@@ -0,0 +1,50 @@
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 Readers for `/SyncStatus` and `/Volume` responses.
8
+ *
9
+ * `/Status` is intentionally never requested. API v1.7 section 2 says
10
+ * "/SyncStatus should be polled if only the name, volume and grouping status of
11
+ * a player is of interest", which is precisely this plugin's scope, and for a
12
+ * group secondary `/Status` reports the *group's* volume while `/SyncStatus`
13
+ * reports that player's own. Polling only `/SyncStatus` is therefore both
14
+ * cheaper and more correct here.
15
+ */
16
+ import type { PlayerObservation } from '../types';
17
+ /**
18
+ * Parse a `/SyncStatus` response.
19
+ *
20
+ * @param endpoint canonical `host:port` this response came from. Used, alongside
21
+ * the response's own `id`, to tell a self-referential `<master>` apart from a
22
+ * real group leader.
23
+ */
24
+ export declare function parseSyncStatus(body: string, endpoint: string): PlayerObservation;
25
+ /** A parsed `/Volume` response. */
26
+ export interface VolumeResult {
27
+ /** Level 0..100, or undefined when the player reports fixed volume. */
28
+ level?: number;
29
+ fixedVolume: boolean;
30
+ muted: boolean;
31
+ /** Pre-mute level, present only while muted. */
32
+ muteVolume?: number;
33
+ db?: number;
34
+ }
35
+ /**
36
+ * Parse a `/Volume` response.
37
+ *
38
+ * The level is the element's text content rather than an attribute:
39
+ * `<volume db="-39.8" mute="0" ...>35</volume>`.
40
+ */
41
+ export declare function parseVolume(body: string): VolumeResult;
42
+ /**
43
+ * The level to restore when a muted or zeroed player is switched back on.
44
+ *
45
+ * `muteVolume` is preferred because the player itself remembers the pre-mute
46
+ * level, which makes an unmute lossless without any state of our own. The
47
+ * remembered level is only a fallback for the other way of reaching silence,
48
+ * writing `level=0`, which the player cannot undo for us.
49
+ */
50
+ export declare function restoreLevelFrom(observation: Pick<PlayerObservation, 'muteVolume'>, remembered: number | undefined, fallback: number): number;
@@ -0,0 +1,191 @@
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 Readers for `/SyncStatus` and `/Volume` responses.
9
+ *
10
+ * `/Status` is intentionally never requested. API v1.7 section 2 says
11
+ * "/SyncStatus should be polled if only the name, volume and grouping status of
12
+ * a player is of interest", which is precisely this plugin's scope, and for a
13
+ * group secondary `/Status` reports the *group's* volume while `/SyncStatus`
14
+ * reports that player's own. Polling only `/SyncStatus` is therefore both
15
+ * cheaper and more correct here.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.parseSyncStatus = parseSyncStatus;
19
+ exports.parseVolume = parseVolume;
20
+ exports.restoreLevelFrom = restoreLevelFrom;
21
+ const settings_1 = require("../settings");
22
+ const errors_1 = require("../utils/errors");
23
+ const identity_1 = require("./identity");
24
+ const xml_1 = require("./xml");
25
+ /** Clamp a reported level into the documented 0..100 range. */
26
+ function clampVolume(value) {
27
+ return Math.min(settings_1.VOLUME_MAX, Math.max(settings_1.VOLUME_MIN, Math.round(value)));
28
+ }
29
+ /**
30
+ * Work out whether this player leads a group, follows one, or is standalone.
31
+ *
32
+ * `<slave>` children mean it is the primary; a `<master>` pointing anywhere
33
+ * other than itself means it is a secondary. A player can briefly report a
34
+ * `<master>` equal to its own endpoint while regrouping, which is not a
35
+ * following state.
36
+ *
37
+ * "Itself" is the `id` the player reports, falling back to the address we
38
+ * dialled. The distinction matters when a player is configured by hostname: its
39
+ * own `<master>` would then never match the endpoint, and a regrouping player
40
+ * would look like a follower.
41
+ */
42
+ function readSyncRole(root, endpoint) {
43
+ if ((0, xml_1.children)(root, 'slave').length > 0) {
44
+ return 'primary';
45
+ }
46
+ const self = (0, xml_1.attr)(root, 'id') ?? endpoint;
47
+ const isSelf = (value) => value === self || value === endpoint;
48
+ const masterElement = (0, xml_1.child)(root, 'master');
49
+ if (masterElement !== undefined) {
50
+ const host = masterElement.text.trim();
51
+ const port = (0, xml_1.attr)(masterElement, 'port');
52
+ const master = host.includes(':') || port === undefined ? host : `${host}:${port}`;
53
+ if (master.length > 0 && !isSelf(master)) {
54
+ return 'secondary';
55
+ }
56
+ }
57
+ const legacyMaster = (0, xml_1.attr)(root, 'master');
58
+ if (legacyMaster !== undefined && !isSelf(legacyMaster)) {
59
+ return 'secondary';
60
+ }
61
+ return 'standalone';
62
+ }
63
+ /**
64
+ * Decide whether a player is muted.
65
+ *
66
+ * `/Volume` says so outright with `mute="1"`, but `/SyncStatus` carries no mute
67
+ * attribute at all. Verified against BluOS 4.16.6, where the same zone reports:
68
+ *
69
+ * - playing: `volume="60" db="-32.1"`
70
+ * - muted: `volume="0" db="-100" muteVolume="60" muteDb="-32.1"`
71
+ * - level 0: `volume="0" db="-100"`
72
+ *
73
+ * So `db="-100"` is silence, not mute, and the two ways of reaching silence are
74
+ * told apart only by the remembered pre-mute level, which the firmware publishes
75
+ * exclusively while muted. Inferring mute from `db` instead would turn the mute
76
+ * switch on whenever a user dragged the slider to zero.
77
+ */
78
+ function readMuted(root) {
79
+ if ((0, xml_1.attr)(root, 'mute') !== undefined) {
80
+ return (0, xml_1.boolAttr)(root, 'mute');
81
+ }
82
+ return (0, xml_1.attr)(root, 'muteVolume') !== undefined || (0, xml_1.attr)(root, 'muteDb') !== undefined;
83
+ }
84
+ function readBattery(root) {
85
+ const element = (0, xml_1.child)(root, 'battery');
86
+ if (element === undefined) {
87
+ return undefined;
88
+ }
89
+ const level = (0, xml_1.intAttr)(element, 'level');
90
+ if (level === undefined) {
91
+ return undefined;
92
+ }
93
+ return {
94
+ level: Math.min(100, Math.max(0, level)),
95
+ charging: (0, xml_1.boolAttr)(element, 'charging'),
96
+ };
97
+ }
98
+ /**
99
+ * Parse a `/SyncStatus` response.
100
+ *
101
+ * @param endpoint canonical `host:port` this response came from. Used, alongside
102
+ * the response's own `id`, to tell a self-referential `<master>` apart from a
103
+ * real group leader.
104
+ */
105
+ function parseSyncStatus(body, endpoint) {
106
+ const root = (0, xml_1.parseXml)(body);
107
+ // Firmware has shipped both `SyncStatus` and lowercase variants; matching
108
+ // case-insensitively costs nothing and avoids a needless incompatibility.
109
+ if (root.name.toLowerCase() !== 'syncstatus') {
110
+ throw new errors_1.ProtocolError(`expected a SyncStatus response, got <${root.name}>`);
111
+ }
112
+ const rawVolume = (0, xml_1.intAttr)(root, 'volume');
113
+ const fixedVolume = rawVolume === settings_1.FIXED_VOLUME_SENTINEL;
114
+ const muted = readMuted(root);
115
+ const rawDb = (0, xml_1.floatAttr)(root, 'db');
116
+ const observation = {
117
+ name: (0, xml_1.attr)(root, 'name') ?? '',
118
+ brand: (0, xml_1.attr)(root, 'brand'),
119
+ model: (0, xml_1.attr)(root, 'model'),
120
+ modelName: (0, xml_1.attr)(root, 'modelName'),
121
+ firmware: (0, xml_1.attr)(root, 'version'),
122
+ mac: (0, identity_1.normalizeMac)((0, xml_1.attr)(root, 'mac')),
123
+ fixedVolume,
124
+ muted,
125
+ syncRole: readSyncRole(root, endpoint),
126
+ etag: (0, xml_1.attr)(root, 'etag'),
127
+ syncStat: (0, xml_1.attrOrChildText)(root, 'syncStat'),
128
+ };
129
+ if (rawVolume !== undefined && !fixedVolume) {
130
+ observation.volume = clampVolume(rawVolume);
131
+ }
132
+ const muteVolume = (0, xml_1.intAttr)(root, 'muteVolume');
133
+ if (muteVolume !== undefined) {
134
+ observation.muteVolume = clampVolume(muteVolume);
135
+ }
136
+ // `db="-100"` is the mute sentinel rather than a real output level, so it is
137
+ // dropped instead of being reported as if the amplifier were at -100 dB.
138
+ if (rawDb !== undefined && !(muted && rawDb <= settings_1.MUTED_DB_SENTINEL)) {
139
+ observation.db = rawDb;
140
+ }
141
+ const battery = readBattery(root);
142
+ if (battery !== undefined) {
143
+ observation.battery = battery;
144
+ }
145
+ return observation;
146
+ }
147
+ /**
148
+ * Parse a `/Volume` response.
149
+ *
150
+ * The level is the element's text content rather than an attribute:
151
+ * `<volume db="-39.8" mute="0" ...>35</volume>`.
152
+ */
153
+ function parseVolume(body) {
154
+ const root = (0, xml_1.parseXml)(body);
155
+ if (root.name.toLowerCase() !== 'volume') {
156
+ throw new errors_1.ProtocolError(`expected a Volume response, got <${root.name}>`);
157
+ }
158
+ const rawLevel = Number.parseInt(root.text, 10);
159
+ const fixedVolume = rawLevel === settings_1.FIXED_VOLUME_SENTINEL;
160
+ const muted = (0, xml_1.boolAttr)(root, 'mute');
161
+ const rawDb = (0, xml_1.floatAttr)(root, 'db');
162
+ const result = { fixedVolume, muted };
163
+ if (Number.isInteger(rawLevel) && !fixedVolume) {
164
+ result.level = clampVolume(rawLevel);
165
+ }
166
+ const muteVolume = (0, xml_1.intAttr)(root, 'muteVolume');
167
+ if (muteVolume !== undefined) {
168
+ result.muteVolume = clampVolume(muteVolume);
169
+ }
170
+ if (rawDb !== undefined && !(muted && rawDb <= settings_1.MUTED_DB_SENTINEL)) {
171
+ result.db = rawDb;
172
+ }
173
+ return result;
174
+ }
175
+ /**
176
+ * The level to restore when a muted or zeroed player is switched back on.
177
+ *
178
+ * `muteVolume` is preferred because the player itself remembers the pre-mute
179
+ * level, which makes an unmute lossless without any state of our own. The
180
+ * remembered level is only a fallback for the other way of reaching silence,
181
+ * writing `level=0`, which the player cannot undo for us.
182
+ */
183
+ function restoreLevelFrom(observation, remembered, fallback) {
184
+ const candidates = [observation.muteVolume, remembered, fallback];
185
+ for (const candidate of candidates) {
186
+ if (candidate !== undefined && candidate > settings_1.VOLUME_MIN && candidate <= settings_1.VOLUME_MAX) {
187
+ return candidate;
188
+ }
189
+ }
190
+ return fallback;
191
+ }
@@ -0,0 +1,76 @@
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 deliberately small XML reader for BluOS responses.
8
+ *
9
+ * BluOS answers with flat documents: attributes on the root element plus a
10
+ * handful of shallow children. A general-purpose parser would be a new
11
+ * dependency and a much larger attack surface for input that arrives unattested
12
+ * over the LAN, so this reads exactly the subset the API uses and refuses
13
+ * everything else.
14
+ *
15
+ * Hardening, in order of importance:
16
+ *
17
+ * - Document type declarations and entity declarations are rejected outright.
18
+ * No `DOCTYPE` means no external entities (XXE) and no recursive entity
19
+ * expansion (the "billion laughs" denial of service).
20
+ * - Only the five predefined entities and numeric character references are
21
+ * decoded, and numeric references are bounded to valid Unicode scalars.
22
+ * - Byte length, nesting depth, element count and per-element attribute count
23
+ * are all capped, so a malfunctioning or hostile endpoint cannot exhaust
24
+ * memory.
25
+ */
26
+ /** A parsed element. Immutable by contract; callers only read. */
27
+ export interface XmlElement {
28
+ name: string;
29
+ attributes: Readonly<Record<string, string>>;
30
+ children: readonly XmlElement[];
31
+ /** Concatenated direct text content, trimmed. */
32
+ text: string;
33
+ }
34
+ /** Overridable limits, so tests can exercise the guards cheaply. */
35
+ export interface XmlLimits {
36
+ maxBytes: number;
37
+ maxDepth: number;
38
+ maxElements: number;
39
+ maxAttributes: number;
40
+ }
41
+ /**
42
+ * Parse a BluOS XML response.
43
+ *
44
+ * @throws ProtocolError when the input breaks a limit, declares a document type
45
+ * or entity, or is not well-formed enough to read.
46
+ */
47
+ export declare function parseXml(input: string | Buffer, overrides?: Partial<XmlLimits>): XmlElement;
48
+ /** Read an attribute, or undefined when absent or empty after trimming. */
49
+ export declare function attr(element: XmlElement | undefined, name: string): string | undefined;
50
+ /** First direct child with the given name. */
51
+ export declare function child(element: XmlElement | undefined, name: string): XmlElement | undefined;
52
+ /** All direct children with the given name. */
53
+ export declare function children(element: XmlElement | undefined, name: string): readonly XmlElement[];
54
+ /** Text of the first direct child with the given name, when non-empty. */
55
+ export declare function childText(element: XmlElement | undefined, name: string): string | undefined;
56
+ /**
57
+ * Read a value that BluOS may report either as a root attribute or as a child
58
+ * element, preferring the attribute.
59
+ *
60
+ * `/SyncStatus` puts `syncStat` on the root while `/Status` makes it a child,
61
+ * and firmware versions differ on others, so callers should not have to care.
62
+ */
63
+ export declare function attrOrChildText(element: XmlElement | undefined, name: string): string | undefined;
64
+ /** Parse an integer attribute, returning undefined when absent or unparseable. */
65
+ export declare function intAttr(element: XmlElement | undefined, name: string): number | undefined;
66
+ /** Parse a decimal attribute, returning undefined when absent or unparseable. */
67
+ export declare function floatAttr(element: XmlElement | undefined, name: string): number | undefined;
68
+ /**
69
+ * Interpret a BluOS boolean.
70
+ *
71
+ * The API is inconsistent: `mute` is `0`/`1`, `initialized` and `charging` are
72
+ * `true`/`false`. Absent means false throughout, so callers that need to tell
73
+ * "absent" apart from "false" must check the attribute themselves — mute in
74
+ * `/SyncStatus` being the case that matters, since it is never present there.
75
+ */
76
+ export declare function boolAttr(element: XmlElement | undefined, name: string): boolean;