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,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;
|