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,92 @@
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 Error description helpers.
9
+ *
10
+ * Node wraps low-level network failures in `cause` chains, so a bare
11
+ * `error.message` frequently reads "fetch failed" while the useful detail
12
+ * (ECONNREFUSED, ETIMEDOUT) sits one level down.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.ConnectionError = exports.ProtocolError = exports.ConfigValidationError = void 0;
16
+ exports.describeError = describeError;
17
+ exports.describeErrorStack = describeErrorStack;
18
+ /** Longest description produced, so a hostile endpoint cannot flood the log. */
19
+ const MAX_DESCRIPTION_LENGTH = 300;
20
+ function messageOf(error) {
21
+ if (error instanceof Error) {
22
+ const code = error.code;
23
+ return typeof code === 'string' && code.length > 0
24
+ ? `${error.message} (${code})`
25
+ : error.message;
26
+ }
27
+ if (typeof error === 'string') {
28
+ return error;
29
+ }
30
+ try {
31
+ return JSON.stringify(error) ?? String(error);
32
+ }
33
+ catch {
34
+ return String(error);
35
+ }
36
+ }
37
+ /**
38
+ * Describe an error, including any `cause` chain, for a single log line.
39
+ *
40
+ * Control characters are stripped: an error message can contain remote input,
41
+ * and a newline inside a log line lets an attacker forge log entries.
42
+ */
43
+ function describeError(error) {
44
+ const parts = [];
45
+ let current = error;
46
+ const seen = new Set();
47
+ while (current !== undefined && current !== null && !seen.has(current)) {
48
+ seen.add(current);
49
+ const text = messageOf(current);
50
+ if (text.length > 0 && !parts.includes(text)) {
51
+ parts.push(text);
52
+ }
53
+ current = current instanceof Error ? current.cause : undefined;
54
+ }
55
+ const joined = parts.length > 0 ? parts.join(': ') : 'unknown error';
56
+ const sanitized = joined.replace(/[\u0000-\u001F\u007F]/g, '\uFFFD');
57
+ return sanitized.length > MAX_DESCRIPTION_LENGTH
58
+ ? `${sanitized.slice(0, MAX_DESCRIPTION_LENGTH)}\u2026`
59
+ : sanitized;
60
+ }
61
+ /** Describe an error and append its stack, for `log.debug` only. */
62
+ function describeErrorStack(error) {
63
+ const description = describeError(error);
64
+ if (error instanceof Error && typeof error.stack === 'string') {
65
+ return `${description}\n${error.stack}`;
66
+ }
67
+ return description;
68
+ }
69
+ /** Raised when configuration cannot produce a usable accessory set. */
70
+ class ConfigValidationError extends Error {
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = 'ConfigValidationError';
74
+ }
75
+ }
76
+ exports.ConfigValidationError = ConfigValidationError;
77
+ /** Raised when a player answers, but not with something we can parse. */
78
+ class ProtocolError extends Error {
79
+ constructor(message, options) {
80
+ super(message, options);
81
+ this.name = 'ProtocolError';
82
+ }
83
+ }
84
+ exports.ProtocolError = ProtocolError;
85
+ /** Raised when a player cannot be reached at all. */
86
+ class ConnectionError extends Error {
87
+ constructor(message, options) {
88
+ super(message, options);
89
+ this.name = 'ConnectionError';
90
+ }
91
+ }
92
+ exports.ConnectionError = ConnectionError;
@@ -0,0 +1,13 @@
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 Utility barrel.
8
+ */
9
+ export * from './context';
10
+ export * from './errors';
11
+ export * from './serial';
12
+ export * from './timing';
13
+ export * from './validators';
@@ -0,0 +1,29 @@
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 Utility 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("./context"), exports);
26
+ __exportStar(require("./errors"), exports);
27
+ __exportStar(require("./serial"), exports);
28
+ __exportStar(require("./timing"), exports);
29
+ __exportStar(require("./validators"), exports);
@@ -0,0 +1,22 @@
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 Opaque HomeKit serial numbers.
8
+ *
9
+ * The player's MAC address is the obvious candidate and the wrong one. HomeKit
10
+ * shows SerialNumber in the Home app and it ends up in screenshots and bug
11
+ * reports, and a MAC is both identifying and, on a multi-zone chassis, shared
12
+ * between zones. A random value generated once and persisted in accessory
13
+ * context is stable across restarts without disclosing anything.
14
+ */
15
+ import type { PlatformAccessory } from 'homebridge';
16
+ /** Generate a fresh opaque serial number. */
17
+ export declare function newAccessorySerialNumber(): string;
18
+ /**
19
+ * Return this accessory's serial number, generating and persisting one if the
20
+ * cached accessory predates the field.
21
+ */
22
+ export declare function ensureAccessorySerialNumber(accessory: PlatformAccessory): string;
@@ -0,0 +1,37 @@
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 Opaque HomeKit serial numbers.
9
+ *
10
+ * The player's MAC address is the obvious candidate and the wrong one. HomeKit
11
+ * shows SerialNumber in the Home app and it ends up in screenshots and bug
12
+ * reports, and a MAC is both identifying and, on a multi-zone chassis, shared
13
+ * between zones. A random value generated once and persisted in accessory
14
+ * context is stable across restarts without disclosing anything.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.newAccessorySerialNumber = newAccessorySerialNumber;
18
+ exports.ensureAccessorySerialNumber = ensureAccessorySerialNumber;
19
+ const node_crypto_1 = require("node:crypto");
20
+ /** Generate a fresh opaque serial number. */
21
+ function newAccessorySerialNumber() {
22
+ return (0, node_crypto_1.randomUUID)();
23
+ }
24
+ /**
25
+ * Return this accessory's serial number, generating and persisting one if the
26
+ * cached accessory predates the field.
27
+ */
28
+ function ensureAccessorySerialNumber(accessory) {
29
+ const context = accessory.context;
30
+ const existing = context.serialNumber;
31
+ if (typeof existing === 'string' && existing.length > 0) {
32
+ return existing;
33
+ }
34
+ const generated = newAccessorySerialNumber();
35
+ context.serialNumber = generated;
36
+ return generated;
37
+ }
@@ -0,0 +1,52 @@
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 Waiting primitives, in one place so the reference semantics are
8
+ * decided once.
9
+ *
10
+ * The distinction matters and is easy to get wrong. A timer inside an operation
11
+ * somebody is awaiting must keep the event loop alive: an unreferenced timer
12
+ * there lets Node decide the process has nothing left to do and exit while a
13
+ * caller is still waiting for an answer. That was a real defect in this plugin —
14
+ * the API's one-second minimum gap between requests for the same resource was
15
+ * implemented with an unreferenced timer, so a script that awaited a read could
16
+ * exit silently mid-request instead of returning a value.
17
+ *
18
+ * The opposite applies to a timer nothing is waiting on, such as a retry backoff
19
+ * inside a loop that can be cancelled. Those are always cleared rather than
20
+ * merely unreferenced, so shutdown is immediate instead of waiting out a delay
21
+ * that has been rendered pointless.
22
+ */
23
+ /**
24
+ * Wait, keeping the process alive for the duration.
25
+ *
26
+ * For use inside an operation a caller is awaiting.
27
+ */
28
+ export declare function sleep(ms: number): Promise<void>;
29
+ /** A wait that can be abandoned before it elapses. */
30
+ export interface InterruptibleSleep {
31
+ /** Resolves when the delay elapses or {@link interrupt} is called. */
32
+ readonly promise: Promise<void>;
33
+ /** Resolve now and cancel the underlying timer. */
34
+ interrupt(): void;
35
+ }
36
+ /**
37
+ * Wait, but allow the wait to be cut short.
38
+ *
39
+ * The timer is cleared on interruption, so nothing is left holding the event
40
+ * loop open once the delay is no longer wanted.
41
+ */
42
+ export declare function interruptibleSleep(ms: number): InterruptibleSleep;
43
+ /** Returned by {@link raceTimeout} when the deadline came first. */
44
+ export declare const TIMED_OUT: unique symbol;
45
+ /**
46
+ * Race work against a deadline, without leaving the timer behind.
47
+ *
48
+ * The work is not cancelled when the deadline wins — that is the caller's
49
+ * decision — but the timer is always cleared, so a fast result does not leave a
50
+ * pending timer holding the process open.
51
+ */
52
+ export declare function raceTimeout<T>(work: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT>;
@@ -0,0 +1,74 @@
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 Waiting primitives, in one place so the reference semantics are
9
+ * decided once.
10
+ *
11
+ * The distinction matters and is easy to get wrong. A timer inside an operation
12
+ * somebody is awaiting must keep the event loop alive: an unreferenced timer
13
+ * there lets Node decide the process has nothing left to do and exit while a
14
+ * caller is still waiting for an answer. That was a real defect in this plugin —
15
+ * the API's one-second minimum gap between requests for the same resource was
16
+ * implemented with an unreferenced timer, so a script that awaited a read could
17
+ * exit silently mid-request instead of returning a value.
18
+ *
19
+ * The opposite applies to a timer nothing is waiting on, such as a retry backoff
20
+ * inside a loop that can be cancelled. Those are always cleared rather than
21
+ * merely unreferenced, so shutdown is immediate instead of waiting out a delay
22
+ * that has been rendered pointless.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.TIMED_OUT = void 0;
26
+ exports.sleep = sleep;
27
+ exports.interruptibleSleep = interruptibleSleep;
28
+ exports.raceTimeout = raceTimeout;
29
+ /**
30
+ * Wait, keeping the process alive for the duration.
31
+ *
32
+ * For use inside an operation a caller is awaiting.
33
+ */
34
+ function sleep(ms) {
35
+ return new Promise((resolve) => {
36
+ setTimeout(resolve, Math.max(0, ms));
37
+ });
38
+ }
39
+ /**
40
+ * Wait, but allow the wait to be cut short.
41
+ *
42
+ * The timer is cleared on interruption, so nothing is left holding the event
43
+ * loop open once the delay is no longer wanted.
44
+ */
45
+ function interruptibleSleep(ms) {
46
+ let cancel = () => { };
47
+ const promise = new Promise((resolve) => {
48
+ const timer = setTimeout(resolve, Math.max(0, ms));
49
+ cancel = () => {
50
+ clearTimeout(timer);
51
+ resolve();
52
+ };
53
+ });
54
+ return { promise, interrupt: () => cancel() };
55
+ }
56
+ /** Returned by {@link raceTimeout} when the deadline came first. */
57
+ exports.TIMED_OUT = Symbol('timed out');
58
+ /**
59
+ * Race work against a deadline, without leaving the timer behind.
60
+ *
61
+ * The work is not cancelled when the deadline wins — that is the caller's
62
+ * decision — but the timer is always cleared, so a fast result does not leave a
63
+ * pending timer holding the process open.
64
+ */
65
+ async function raceTimeout(work, ms) {
66
+ const deadline = interruptibleSleep(ms);
67
+ const expired = deadline.promise.then(() => exports.TIMED_OUT);
68
+ try {
69
+ return await Promise.race([work, expired]);
70
+ }
71
+ finally {
72
+ deadline.interrupt();
73
+ }
74
+ }
@@ -0,0 +1,99 @@
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 Configuration validation.
8
+ *
9
+ * The split between fatal and non-fatal is deliberate. A structural problem —
10
+ * `devices` present but not an array — means the file does not describe anything
11
+ * we can act on, so the platform disables itself while leaving cached
12
+ * accessories registered, and HomeKit shows them as No Response rather than
13
+ * losing the rooms and automations built on them.
14
+ *
15
+ * A problem with one device is different: rejecting the whole fleet because one
16
+ * entry is malformed would be a worse outcome than skipping that entry. Skipped
17
+ * devices are warned about by name and reason, because a device that silently
18
+ * fails to appear is the hardest kind of bug for a user to report.
19
+ */
20
+ import { type ResolvedAccessory, type ResolvedDevice, type SliderService } from '../types';
21
+ /** Outcome of validating a platform configuration block. */
22
+ export interface ConfigValidationResult {
23
+ /** Fatal problems. Any entry means the platform must not start polling. */
24
+ errors: string[];
25
+ /** Problems worth reporting that do not prevent operation. */
26
+ warnings: string[];
27
+ /** Devices that survived validation, in configuration order. */
28
+ devices: ResolvedDevice[];
29
+ }
30
+ /**
31
+ * Make an untrusted string safe to interpolate into a log line.
32
+ *
33
+ * Device names come from configuration and from the players themselves, so they
34
+ * are attacker-influenced in the threat model where someone can write to either.
35
+ * A newline in a log line lets them forge entries; truncation stops one long
36
+ * name from burying everything else.
37
+ */
38
+ export declare function forLog(value: unknown): string;
39
+ /**
40
+ * Make an untrusted string safe to publish to HomeKit.
41
+ *
42
+ * Same sanitising as {@link forLog} but capped at HomeKit's name budget rather
43
+ * than the log field budget, for values that become characteristic values and
44
+ * are written into the accessory cache. An empty result becomes undefined, so a
45
+ * caller keeps the value it already had instead of publishing a blank.
46
+ */
47
+ export declare function forDisplay(value: string): string | undefined;
48
+ /** True for an IPv4 literal whose octets are all in range. */
49
+ export declare function isIpv4(value: string): boolean;
50
+ /** True for something usable as an HTTP host: an IPv4 literal or a hostname. */
51
+ export declare function isValidHost(value: unknown): value is string;
52
+ /**
53
+ * True for an address outside the ranges treated as local.
54
+ *
55
+ * Local here is RFC 1918, RFC 6598 shared address space (CGNAT / Tailscale),
56
+ * loopback, and link-local. Not blocked in configuration, only warned about:
57
+ * the BluOS API is unauthenticated and intended for a local network, and a
58
+ * routable address in the configuration is much more likely to be a typo than
59
+ * an intention. Hostnames cannot be classified this way and are left alone
60
+ * in configuration; the settings-page probe uses {@link isProbeableHost}.
61
+ */
62
+ export declare function isNonPrivateIpv4(value: string): boolean;
63
+ /**
64
+ * True for a host the settings-page probe is allowed to dial.
65
+ *
66
+ * Narrower than {@link isValidHost}: configuration may name a split-DNS
67
+ * hostname that happens to look public, but the probe is an authenticated
68
+ * Homebridge administrator asking this host to open a connection, so it must
69
+ * not become a scanner. Public IPv4 literals and multi-label public names
70
+ * (`example.com`) are refused. Private IPv4 (including CGNAT) and local
71
+ * hostnames are accepted.
72
+ */
73
+ export declare function isProbeableHost(value: unknown): value is string;
74
+ /** Clamp the discovery window into the supported range. */
75
+ export declare function resolveDiscoveryTimeoutSec(value: unknown, warnings?: string[]): number;
76
+ /**
77
+ * Resolve a slider service, falling back to the platform default then `fan`.
78
+ *
79
+ * An empty string counts as absent, because that is what the Homebridge form
80
+ * writes for the per-device "use the platform setting" option; treating it as a
81
+ * bad value would make choosing that option override the platform setting.
82
+ */
83
+ export declare function resolveSliderService(deviceValue: unknown, platformValue: unknown, warnings?: string[], label?: string): SliderService;
84
+ /**
85
+ * Validate a platform configuration block.
86
+ *
87
+ * Never throws: the platform needs the errors and warnings in order to report
88
+ * them, and a configuration problem should produce a diagnosable log rather than
89
+ * an exception during Homebridge startup.
90
+ */
91
+ export declare function validateConfig(config: unknown): ConfigValidationResult;
92
+ /**
93
+ * Expand validated devices into the accessories to expose.
94
+ *
95
+ * Names are derived here rather than at use time so that duplicates can be
96
+ * detected once: two accessories sharing a name still work, but they make Siri
97
+ * ambiguous, which is worth a warning.
98
+ */
99
+ export declare function resolveAccessories(devices: readonly ResolvedDevice[], warnings?: string[]): ResolvedAccessory[];