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
package/dist/api/xml.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
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 A deliberately small XML reader for BluOS responses.
|
|
9
|
+
*
|
|
10
|
+
* BluOS answers with flat documents: attributes on the root element plus a
|
|
11
|
+
* handful of shallow children. A general-purpose parser would be a new
|
|
12
|
+
* dependency and a much larger attack surface for input that arrives unattested
|
|
13
|
+
* over the LAN, so this reads exactly the subset the API uses and refuses
|
|
14
|
+
* everything else.
|
|
15
|
+
*
|
|
16
|
+
* Hardening, in order of importance:
|
|
17
|
+
*
|
|
18
|
+
* - Document type declarations and entity declarations are rejected outright.
|
|
19
|
+
* No `DOCTYPE` means no external entities (XXE) and no recursive entity
|
|
20
|
+
* expansion (the "billion laughs" denial of service).
|
|
21
|
+
* - Only the five predefined entities and numeric character references are
|
|
22
|
+
* decoded, and numeric references are bounded to valid Unicode scalars.
|
|
23
|
+
* - Byte length, nesting depth, element count and per-element attribute count
|
|
24
|
+
* are all capped, so a malfunctioning or hostile endpoint cannot exhaust
|
|
25
|
+
* memory.
|
|
26
|
+
*/
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.parseXml = parseXml;
|
|
29
|
+
exports.attr = attr;
|
|
30
|
+
exports.child = child;
|
|
31
|
+
exports.children = children;
|
|
32
|
+
exports.childText = childText;
|
|
33
|
+
exports.attrOrChildText = attrOrChildText;
|
|
34
|
+
exports.intAttr = intAttr;
|
|
35
|
+
exports.floatAttr = floatAttr;
|
|
36
|
+
exports.boolAttr = boolAttr;
|
|
37
|
+
const settings_1 = require("../settings");
|
|
38
|
+
const errors_1 = require("../utils/errors");
|
|
39
|
+
const DEFAULT_LIMITS = {
|
|
40
|
+
maxBytes: settings_1.MAX_XML_BYTES,
|
|
41
|
+
maxDepth: settings_1.MAX_XML_DEPTH,
|
|
42
|
+
maxElements: settings_1.MAX_XML_ELEMENTS,
|
|
43
|
+
maxAttributes: settings_1.MAX_XML_ATTRIBUTES,
|
|
44
|
+
};
|
|
45
|
+
const NAME_START = /[A-Za-z_:]/;
|
|
46
|
+
const NAME_CHAR = /[A-Za-z0-9_:.-]/;
|
|
47
|
+
const WHITESPACE = /\s/;
|
|
48
|
+
/**
|
|
49
|
+
* Null-prototyped on purpose. A plain object literal inherits from
|
|
50
|
+
* `Object.prototype`, so `&constructor;` and `&toString;` would resolve to
|
|
51
|
+
* engine internals and be substituted into attribute values — which reach
|
|
52
|
+
* HomeKit's Manufacturer and Model characteristics and the accessory cache.
|
|
53
|
+
*/
|
|
54
|
+
const NAMED_ENTITIES = Object.assign(Object.create(null), {
|
|
55
|
+
amp: '&',
|
|
56
|
+
lt: '<',
|
|
57
|
+
gt: '>',
|
|
58
|
+
quot: '"',
|
|
59
|
+
apos: '\'',
|
|
60
|
+
});
|
|
61
|
+
/**
|
|
62
|
+
* Decode the entity subset XML predefines, plus numeric character references.
|
|
63
|
+
*
|
|
64
|
+
* Anything else is left verbatim rather than resolved: an unrecognised entity
|
|
65
|
+
* in a BluOS response is far more likely to be a literal ampersand in a track
|
|
66
|
+
* title than a reference we are supposed to expand.
|
|
67
|
+
*/
|
|
68
|
+
function decodeEntities(raw) {
|
|
69
|
+
if (!raw.includes('&')) {
|
|
70
|
+
return raw;
|
|
71
|
+
}
|
|
72
|
+
return raw.replace(/&(#[0-9]+|#[xX][0-9A-Fa-f]+|[A-Za-z]+);/g, (match, body) => {
|
|
73
|
+
if (body.startsWith('#')) {
|
|
74
|
+
const isHex = body[1] === 'x' || body[1] === 'X';
|
|
75
|
+
const digits = isHex ? body.slice(2) : body.slice(1);
|
|
76
|
+
const code = Number.parseInt(digits, isHex ? 16 : 10);
|
|
77
|
+
if (!Number.isInteger(code) || code < 0 || code > 0x10ffff) {
|
|
78
|
+
return match;
|
|
79
|
+
}
|
|
80
|
+
// Lone surrogates are not valid scalar values and would corrupt the string.
|
|
81
|
+
if (code >= 0xd800 && code <= 0xdfff) {
|
|
82
|
+
return match;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return String.fromCodePoint(code);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return match;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body)
|
|
92
|
+
? NAMED_ENTITIES[body] ?? match
|
|
93
|
+
: match;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Find the end of a tag, ignoring `>` inside quoted attribute values. */
|
|
97
|
+
function findTagEnd(input, from) {
|
|
98
|
+
let quote;
|
|
99
|
+
for (let i = from; i < input.length; i += 1) {
|
|
100
|
+
const char = input[i];
|
|
101
|
+
if (quote !== undefined) {
|
|
102
|
+
if (char === quote) {
|
|
103
|
+
quote = undefined;
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (char === '"' || char === '\'') {
|
|
108
|
+
quote = char;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (char === '>') {
|
|
112
|
+
return i;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return -1;
|
|
116
|
+
}
|
|
117
|
+
function readName(input, from) {
|
|
118
|
+
const first = input[from];
|
|
119
|
+
if (first === undefined || !NAME_START.test(first)) {
|
|
120
|
+
throw new errors_1.ProtocolError('malformed XML: expected an element name');
|
|
121
|
+
}
|
|
122
|
+
let i = from + 1;
|
|
123
|
+
while (i < input.length) {
|
|
124
|
+
const char = input[i];
|
|
125
|
+
if (char === undefined || !NAME_CHAR.test(char)) {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
i += 1;
|
|
129
|
+
}
|
|
130
|
+
return { name: input.slice(from, i), next: i };
|
|
131
|
+
}
|
|
132
|
+
function parseAttributes(source, elementName, limits) {
|
|
133
|
+
// Null-prototyped for the same reason as NAMED_ENTITIES: an attribute named
|
|
134
|
+
// `__proto__` or `constructor` must be a plain key, not a write or a read
|
|
135
|
+
// through the prototype chain.
|
|
136
|
+
const attributes = Object.create(null);
|
|
137
|
+
let count = 0;
|
|
138
|
+
let i = 0;
|
|
139
|
+
while (i < source.length) {
|
|
140
|
+
const char = source[i];
|
|
141
|
+
if (char === undefined || WHITESPACE.test(char)) {
|
|
142
|
+
i += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!NAME_START.test(char)) {
|
|
146
|
+
throw new errors_1.ProtocolError(`malformed XML: bad attribute on <${elementName}>`);
|
|
147
|
+
}
|
|
148
|
+
const { name, next } = readName(source, i);
|
|
149
|
+
i = next;
|
|
150
|
+
// Counted here rather than per branch below: a valueless attribute used to
|
|
151
|
+
// skip the limit check, so a body of thousands of bare names could build an
|
|
152
|
+
// object far larger than maxAttributes allows.
|
|
153
|
+
count += 1;
|
|
154
|
+
if (count > limits.maxAttributes) {
|
|
155
|
+
throw new errors_1.ProtocolError(`XML rejected: <${elementName}> exceeds ${limits.maxAttributes} attributes`);
|
|
156
|
+
}
|
|
157
|
+
while (i < source.length && WHITESPACE.test(source[i] ?? '')) {
|
|
158
|
+
i += 1;
|
|
159
|
+
}
|
|
160
|
+
if (source[i] !== '=') {
|
|
161
|
+
// A valueless attribute is not well-formed XML; treat it as empty rather
|
|
162
|
+
// than failing the whole response over a cosmetic defect.
|
|
163
|
+
attributes[name] = '';
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
i += 1;
|
|
167
|
+
while (i < source.length && WHITESPACE.test(source[i] ?? '')) {
|
|
168
|
+
i += 1;
|
|
169
|
+
}
|
|
170
|
+
const quote = source[i];
|
|
171
|
+
if (quote !== '"' && quote !== '\'') {
|
|
172
|
+
throw new errors_1.ProtocolError(`malformed XML: unquoted attribute "${name}" on <${elementName}>`);
|
|
173
|
+
}
|
|
174
|
+
const close = source.indexOf(quote, i + 1);
|
|
175
|
+
if (close === -1) {
|
|
176
|
+
throw new errors_1.ProtocolError(`malformed XML: unterminated attribute "${name}" on <${elementName}>`);
|
|
177
|
+
}
|
|
178
|
+
attributes[name] = decodeEntities(source.slice(i + 1, close));
|
|
179
|
+
i = close + 1;
|
|
180
|
+
}
|
|
181
|
+
return attributes;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Parse a BluOS XML response.
|
|
185
|
+
*
|
|
186
|
+
* @throws ProtocolError when the input breaks a limit, declares a document type
|
|
187
|
+
* or entity, or is not well-formed enough to read.
|
|
188
|
+
*/
|
|
189
|
+
function parseXml(input, overrides = {}) {
|
|
190
|
+
const limits = { ...DEFAULT_LIMITS, ...overrides };
|
|
191
|
+
const byteLength = typeof input === 'string' ? Buffer.byteLength(input, 'utf8') : input.length;
|
|
192
|
+
if (byteLength > limits.maxBytes) {
|
|
193
|
+
throw new errors_1.ProtocolError(`XML rejected: ${byteLength} bytes exceeds ${limits.maxBytes}`);
|
|
194
|
+
}
|
|
195
|
+
const text = typeof input === 'string' ? input : input.toString('utf8');
|
|
196
|
+
if (text.trim().length === 0) {
|
|
197
|
+
throw new errors_1.ProtocolError('XML rejected: empty response');
|
|
198
|
+
}
|
|
199
|
+
// Checked before any parsing: refusing the declaration outright is what makes
|
|
200
|
+
// external-entity and entity-expansion attacks structurally impossible here.
|
|
201
|
+
if (/<!\s*(DOCTYPE|ENTITY)/i.test(text)) {
|
|
202
|
+
throw new errors_1.ProtocolError('XML rejected: document type and entity declarations are not accepted');
|
|
203
|
+
}
|
|
204
|
+
const stack = [];
|
|
205
|
+
let root;
|
|
206
|
+
let elementCount = 0;
|
|
207
|
+
let i = 0;
|
|
208
|
+
while (i < text.length) {
|
|
209
|
+
const lt = text.indexOf('<', i);
|
|
210
|
+
if (lt === -1) {
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
const parent = stack[stack.length - 1];
|
|
214
|
+
if (parent !== undefined && lt > i) {
|
|
215
|
+
parent.text += decodeEntities(text.slice(i, lt));
|
|
216
|
+
}
|
|
217
|
+
if (text.startsWith('<!--', lt)) {
|
|
218
|
+
const end = text.indexOf('-->', lt + 4);
|
|
219
|
+
if (end === -1) {
|
|
220
|
+
throw new errors_1.ProtocolError('malformed XML: unterminated comment');
|
|
221
|
+
}
|
|
222
|
+
i = end + 3;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (text.startsWith('<![CDATA[', lt)) {
|
|
226
|
+
const end = text.indexOf(']]>', lt + 9);
|
|
227
|
+
if (end === -1) {
|
|
228
|
+
throw new errors_1.ProtocolError('malformed XML: unterminated CDATA section');
|
|
229
|
+
}
|
|
230
|
+
if (parent !== undefined) {
|
|
231
|
+
// CDATA content is literal by definition, so it is not entity-decoded.
|
|
232
|
+
parent.text += text.slice(lt + 9, end);
|
|
233
|
+
}
|
|
234
|
+
i = end + 3;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (text.startsWith('<?', lt)) {
|
|
238
|
+
const end = text.indexOf('?>', lt + 2);
|
|
239
|
+
if (end === -1) {
|
|
240
|
+
throw new errors_1.ProtocolError('malformed XML: unterminated processing instruction');
|
|
241
|
+
}
|
|
242
|
+
i = end + 2;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const tagEnd = findTagEnd(text, lt + 1);
|
|
246
|
+
if (tagEnd === -1) {
|
|
247
|
+
throw new errors_1.ProtocolError('malformed XML: unterminated tag');
|
|
248
|
+
}
|
|
249
|
+
if (text[lt + 1] === '/') {
|
|
250
|
+
const { name } = readName(text, lt + 2);
|
|
251
|
+
const open = stack.pop();
|
|
252
|
+
if (open === undefined) {
|
|
253
|
+
throw new errors_1.ProtocolError(`malformed XML: unexpected </${name}>`);
|
|
254
|
+
}
|
|
255
|
+
if (open.name !== name) {
|
|
256
|
+
throw new errors_1.ProtocolError(`malformed XML: </${name}> closes <${open.name}>`);
|
|
257
|
+
}
|
|
258
|
+
open.text = open.text.trim();
|
|
259
|
+
i = tagEnd + 1;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const { name, next } = readName(text, lt + 1);
|
|
263
|
+
let inner = text.slice(next, tagEnd);
|
|
264
|
+
let selfClosing = false;
|
|
265
|
+
if (inner.endsWith('/')) {
|
|
266
|
+
selfClosing = true;
|
|
267
|
+
inner = inner.slice(0, -1);
|
|
268
|
+
}
|
|
269
|
+
elementCount += 1;
|
|
270
|
+
if (elementCount > limits.maxElements) {
|
|
271
|
+
throw new errors_1.ProtocolError(`XML rejected: more than ${limits.maxElements} elements`);
|
|
272
|
+
}
|
|
273
|
+
const element = {
|
|
274
|
+
name,
|
|
275
|
+
attributes: parseAttributes(inner, name, limits),
|
|
276
|
+
children: [],
|
|
277
|
+
text: '',
|
|
278
|
+
};
|
|
279
|
+
if (parent === undefined) {
|
|
280
|
+
if (root !== undefined) {
|
|
281
|
+
throw new errors_1.ProtocolError('malformed XML: more than one root element');
|
|
282
|
+
}
|
|
283
|
+
root = element;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
parent.children.push(element);
|
|
287
|
+
}
|
|
288
|
+
if (!selfClosing) {
|
|
289
|
+
stack.push(element);
|
|
290
|
+
if (stack.length > limits.maxDepth) {
|
|
291
|
+
throw new errors_1.ProtocolError(`XML rejected: nesting deeper than ${limits.maxDepth}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
i = tagEnd + 1;
|
|
295
|
+
}
|
|
296
|
+
if (stack.length > 0) {
|
|
297
|
+
throw new errors_1.ProtocolError(`malformed XML: <${stack[stack.length - 1]?.name}> is never closed`);
|
|
298
|
+
}
|
|
299
|
+
if (root === undefined) {
|
|
300
|
+
throw new errors_1.ProtocolError('malformed XML: no root element');
|
|
301
|
+
}
|
|
302
|
+
return root;
|
|
303
|
+
}
|
|
304
|
+
/** Read an attribute, or undefined when absent or empty after trimming. */
|
|
305
|
+
function attr(element, name) {
|
|
306
|
+
const raw = element?.attributes[name];
|
|
307
|
+
if (raw === undefined) {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
const trimmed = raw.trim();
|
|
311
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
312
|
+
}
|
|
313
|
+
/** First direct child with the given name. */
|
|
314
|
+
function child(element, name) {
|
|
315
|
+
return element?.children.find((candidate) => candidate.name === name);
|
|
316
|
+
}
|
|
317
|
+
/** All direct children with the given name. */
|
|
318
|
+
function children(element, name) {
|
|
319
|
+
return element?.children.filter((candidate) => candidate.name === name) ?? [];
|
|
320
|
+
}
|
|
321
|
+
/** Text of the first direct child with the given name, when non-empty. */
|
|
322
|
+
function childText(element, name) {
|
|
323
|
+
const found = child(element, name)?.text.trim();
|
|
324
|
+
return found !== undefined && found.length > 0 ? found : undefined;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Read a value that BluOS may report either as a root attribute or as a child
|
|
328
|
+
* element, preferring the attribute.
|
|
329
|
+
*
|
|
330
|
+
* `/SyncStatus` puts `syncStat` on the root while `/Status` makes it a child,
|
|
331
|
+
* and firmware versions differ on others, so callers should not have to care.
|
|
332
|
+
*/
|
|
333
|
+
function attrOrChildText(element, name) {
|
|
334
|
+
return attr(element, name) ?? childText(element, name);
|
|
335
|
+
}
|
|
336
|
+
/** Parse an integer attribute, returning undefined when absent or unparseable. */
|
|
337
|
+
function intAttr(element, name) {
|
|
338
|
+
const raw = attr(element, name);
|
|
339
|
+
if (raw === undefined) {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
const value = Number.parseInt(raw, 10);
|
|
343
|
+
return Number.isInteger(value) ? value : undefined;
|
|
344
|
+
}
|
|
345
|
+
/** Parse a decimal attribute, returning undefined when absent or unparseable. */
|
|
346
|
+
function floatAttr(element, name) {
|
|
347
|
+
const raw = attr(element, name);
|
|
348
|
+
if (raw === undefined) {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
const value = Number.parseFloat(raw);
|
|
352
|
+
return Number.isFinite(value) ? value : undefined;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Interpret a BluOS boolean.
|
|
356
|
+
*
|
|
357
|
+
* The API is inconsistent: `mute` is `0`/`1`, `initialized` and `charging` are
|
|
358
|
+
* `true`/`false`. Absent means false throughout, so callers that need to tell
|
|
359
|
+
* "absent" apart from "false" must check the attribute themselves — mute in
|
|
360
|
+
* `/SyncStatus` being the case that matters, since it is never present there.
|
|
361
|
+
*/
|
|
362
|
+
function boolAttr(element, name) {
|
|
363
|
+
const raw = attr(element, name)?.toLowerCase();
|
|
364
|
+
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
365
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 tbaur
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0
|
|
5
|
+
* See LICENSE file for full license text
|
|
6
|
+
*
|
|
7
|
+
* @fileoverview Shared accessory behaviour: honesty about unknown state, and
|
|
8
|
+
* writes that answer HomeKit inside its patience.
|
|
9
|
+
*
|
|
10
|
+
* Two rules are implemented once, here, because getting either wrong produces
|
|
11
|
+
* bugs that are very hard to diagnose from a user's description.
|
|
12
|
+
*
|
|
13
|
+
* Never guess a characteristic value. Until a player has actually been read, and
|
|
14
|
+
* whenever it has stopped answering, reads fail with
|
|
15
|
+
* `SERVICE_COMMUNICATION_FAILURE` so the Home app shows No Response. A plausible
|
|
16
|
+
* default is worse than no answer: it makes automations fire against fiction.
|
|
17
|
+
*
|
|
18
|
+
* Never let a write outlive HomeKit's patience. HAP-NodeJS warns at three
|
|
19
|
+
* seconds and abandons a write at nine, discarding the eventual result. A set
|
|
20
|
+
* handler therefore returns within {@link HOMEKIT_WRITE_BUDGET_MS} and finishes
|
|
21
|
+
* anything slower in the background, where its outcome still reaches HomeKit
|
|
22
|
+
* through the normal update path.
|
|
23
|
+
*/
|
|
24
|
+
import type { PlatformAccessory, Service } from 'homebridge';
|
|
25
|
+
import type { WriteScope } from '../api/client';
|
|
26
|
+
import type { AccessoryContext, PlayerObservation, RefreshReason, RefreshableAccessory } from '../types';
|
|
27
|
+
import type { AccessoryHost } from './host';
|
|
28
|
+
/** A concrete HAP service class, such as `Service.Fanv2`. */
|
|
29
|
+
export type ServiceConstructor = {
|
|
30
|
+
UUID: string;
|
|
31
|
+
new (displayName?: string, subtype?: string): Service;
|
|
32
|
+
};
|
|
33
|
+
/** Everything an accessory needs to attach itself to a restored accessory. */
|
|
34
|
+
export interface AccessoryInit {
|
|
35
|
+
host: AccessoryHost;
|
|
36
|
+
accessory: PlatformAccessory;
|
|
37
|
+
context: AccessoryContext;
|
|
38
|
+
}
|
|
39
|
+
/** Base class for every accessory this plugin exposes. */
|
|
40
|
+
export declare abstract class BaseAccessory implements RefreshableAccessory {
|
|
41
|
+
protected readonly host: AccessoryHost;
|
|
42
|
+
protected readonly accessory: PlatformAccessory;
|
|
43
|
+
protected readonly context: AccessoryContext;
|
|
44
|
+
/** True once a real observation has been applied. */
|
|
45
|
+
private observed;
|
|
46
|
+
/** True while the player is not answering. */
|
|
47
|
+
private offline;
|
|
48
|
+
/** Throttles repeated warnings about the same persistent failure. */
|
|
49
|
+
private lastWarningAt;
|
|
50
|
+
/** Ensures a one-time explanation is logged only once. */
|
|
51
|
+
private readonly warnedOnce;
|
|
52
|
+
constructor(init: AccessoryInit);
|
|
53
|
+
get deviceId(): string;
|
|
54
|
+
get displayName(): string;
|
|
55
|
+
/** Apply a fresh observation, and remember that state is now known. */
|
|
56
|
+
applyObservation(observation: PlayerObservation, reason: RefreshReason): void;
|
|
57
|
+
/**
|
|
58
|
+
* Report that the player could not be reached.
|
|
59
|
+
*
|
|
60
|
+
* Characteristic values are left untouched rather than zeroed: HomeKit is told
|
|
61
|
+
* the accessory is unreachable, and inventing a value on the way out would
|
|
62
|
+
* defeat that.
|
|
63
|
+
*/
|
|
64
|
+
noteUnreachable(error: unknown): void;
|
|
65
|
+
/** Apply an observation to this accessory's characteristics. */
|
|
66
|
+
protected abstract updateFromObservation(observation: PlayerObservation, reason: RefreshReason): void;
|
|
67
|
+
/** Push an unreachable state to HomeKit. */
|
|
68
|
+
protected abstract markUnavailable(): void;
|
|
69
|
+
/** True once this accessory has seen a real reading. */
|
|
70
|
+
protected hasObservedState(): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The error to return from a read when the true value is unknown.
|
|
73
|
+
*
|
|
74
|
+
* `SERVICE_COMMUNICATION_FAILURE` is what makes the Home app render No
|
|
75
|
+
* Response, which is the honest answer before the first successful poll.
|
|
76
|
+
*/
|
|
77
|
+
protected communicationFailure(): Error;
|
|
78
|
+
/** Throw if this accessory has no observed state to report. */
|
|
79
|
+
protected requireObservedState(): void;
|
|
80
|
+
/**
|
|
81
|
+
* How far this accessory's volume writes should reach.
|
|
82
|
+
*
|
|
83
|
+
* A zone that leads a group carries its followers with it, matching what the
|
|
84
|
+
* BluOS app does when you move a leader's slider: the tile is the group's
|
|
85
|
+
* control while the group exists. Every other zone — standalone, or a follower
|
|
86
|
+
* addressed directly — moves alone, because a tile labelled one room must not
|
|
87
|
+
* quietly change another.
|
|
88
|
+
*
|
|
89
|
+
* Derived from the last observation rather than remembered, so ungrouping takes
|
|
90
|
+
* effect on the next poll without any bookkeeping here. When a player reports
|
|
91
|
+
* no grouping at all the answer is false, which is the pre-grouping behaviour.
|
|
92
|
+
*/
|
|
93
|
+
protected writeScope(): WriteScope;
|
|
94
|
+
/** One info line after a HomeKit write reached the player. */
|
|
95
|
+
protected logAction(action: string, scope: WriteScope): void;
|
|
96
|
+
/** Log an explanation the first time a condition is met, then stay quiet. */
|
|
97
|
+
protected warnOnce(key: string, message: string): void;
|
|
98
|
+
/**
|
|
99
|
+
* Run a HomeKit write, returning once it finishes or the budget expires.
|
|
100
|
+
*
|
|
101
|
+
* Slow work is not cancelled when the budget runs out, only stopped being
|
|
102
|
+
* waited on: the player will still apply the change, and the resulting state
|
|
103
|
+
* reaches HomeKit through the poll that our own write triggers.
|
|
104
|
+
*/
|
|
105
|
+
protected completeWithinBudget(label: string, work: () => Promise<void>): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* The service this accessory's state lives on, created if necessary.
|
|
108
|
+
*
|
|
109
|
+
* Reusing a restored service rather than replacing it is what preserves the
|
|
110
|
+
* user's HomeKit room assignment, name and automations across a restart.
|
|
111
|
+
*/
|
|
112
|
+
protected requireService(type: ServiceConstructor): Service;
|
|
113
|
+
/**
|
|
114
|
+
* Remove a service this accessory no longer represents.
|
|
115
|
+
*
|
|
116
|
+
* Needed when a setting changes which service carries the state, since the
|
|
117
|
+
* accessory itself is adopted rather than recreated and would otherwise keep
|
|
118
|
+
* both — one of them unbound to any handler.
|
|
119
|
+
*/
|
|
120
|
+
protected dropService(type: ServiceConstructor): void;
|
|
121
|
+
/**
|
|
122
|
+
* Publish identity to HomeKit.
|
|
123
|
+
*
|
|
124
|
+
* SerialNumber is the opaque generated value rather than the player's MAC: the
|
|
125
|
+
* Home app displays it, so it ends up in screenshots and bug reports, and on a
|
|
126
|
+
* multi-zone chassis a MAC is shared between zones anyway.
|
|
127
|
+
*/
|
|
128
|
+
private configureAccessoryInformation;
|
|
129
|
+
/** Update identity from a live reading, when the player knows better. */
|
|
130
|
+
private refreshAccessoryInformation;
|
|
131
|
+
}
|