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,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
+ }