@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.1 → 5.0.0-beta.11
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/README.md +4 -6
- package/config.schema.json +20 -0
- package/dist/configuration.js +26 -2
- package/dist/configuration.js.map +1 -1
- package/dist/device/member-evidence.js +8 -1
- package/dist/device/member-evidence.js.map +1 -1
- package/dist/diagnostics.js +438 -60
- package/dist/diagnostics.js.map +1 -1
- package/dist/homekit/adapters/camera-streaming.js +54 -26
- package/dist/homekit/adapters/camera-streaming.js.map +1 -1
- package/dist/homekit/adapters/security-system.js +85 -44
- package/dist/homekit/adapters/security-system.js.map +1 -1
- package/dist/homekit/reconciler.js +5 -2
- package/dist/homekit/reconciler.js.map +1 -1
- package/dist/media/live-stream.js +98 -54
- package/dist/media/live-stream.js.map +1 -1
- package/dist/media/recording.js +7 -5
- package/dist/media/recording.js.map +1 -1
- package/dist/media/snapshot.js +4 -4
- package/dist/media/station-live-sessions.js +47 -43
- package/dist/media/station-live-sessions.js.map +1 -1
- package/dist/platform.js +24 -5
- package/dist/platform.js.map +1 -1
- package/dist/ui/dashboard.js +5 -1
- package/dist/ui/dashboard.js.map +1 -1
- package/dist/ui/server.js +26 -4
- package/dist/ui/server.js.map +1 -1
- package/homebridge-ui/public/app.css +356 -3
- package/homebridge-ui/public/i18n/en.json +25 -14
- package/homebridge-ui/public/i18n/fr.json +25 -14
- package/homebridge-ui/public/index.html +24 -32
- package/homebridge-ui/public/js/app.js +187 -65
- package/homebridge-ui/public/js/dashboard.js +80 -1
- package/homebridge-ui/public/js/profile-wizard.js +40 -28
- package/package.json +2 -2
package/dist/diagnostics.js
CHANGED
|
@@ -33,6 +33,19 @@ export function unconfirmedWriteCondition(property) {
|
|
|
33
33
|
? undefined
|
|
34
34
|
: { code: 'camera-control-operation-failed', ...named, active: true, reason: 'not-confirmed' };
|
|
35
35
|
}
|
|
36
|
+
/** Why a live session gave its source back: a stop from outside it, or the session ending itself. */
|
|
37
|
+
const LIVE_SESSION_RELEASES = ['requested', 'failed'];
|
|
38
|
+
/** The claims that contend for a station's one live channel, and the decisions taken over it. */
|
|
39
|
+
const STATION_CLAIMS = ['live', 'recording', 'snapshot'];
|
|
40
|
+
const STATION_CLAIM_ACTIONS = ['held', 'yielded', 'refused', 'released'];
|
|
41
|
+
/**
|
|
42
|
+
* The widest camera index a station may name as the one it is serving.
|
|
43
|
+
*
|
|
44
|
+
* A base fans a bounded number of cameras over one session, and an index beyond that is a value this plugin
|
|
45
|
+
* did not expect rather than a channel. The index carries no identity: which camera holds it is stated by the
|
|
46
|
+
* records about that camera.
|
|
47
|
+
*/
|
|
48
|
+
const MAX_STATION_CHANNEL = 63;
|
|
36
49
|
/** The bounded reasons a stream request is refused before it reaches the source. */
|
|
37
50
|
const REFUSAL_REASONS = new Set(['disabled', 'at-capacity', 'cancelled', 'prepare-failed']);
|
|
38
51
|
/**
|
|
@@ -47,6 +60,26 @@ const HOMEKIT_LIVE_REQUEST_EVENTS = new Set([
|
|
|
47
60
|
'live-session-released',
|
|
48
61
|
'live-session-streaming',
|
|
49
62
|
]);
|
|
63
|
+
/**
|
|
64
|
+
* The error identities an SDK record may name, which is all a record carries of one.
|
|
65
|
+
*
|
|
66
|
+
* A message is never among them, so an identity absent here is recorded as `Error` and reads as any other
|
|
67
|
+
* failure.
|
|
68
|
+
*/
|
|
69
|
+
const SDK_ERROR_TYPES = [
|
|
70
|
+
'Error',
|
|
71
|
+
'RangeError',
|
|
72
|
+
'SessionExpiredError',
|
|
73
|
+
'StationKeyUnavailableError',
|
|
74
|
+
'StationUnreachableError',
|
|
75
|
+
'TypeError',
|
|
76
|
+
];
|
|
77
|
+
/**
|
|
78
|
+
* The retained fields that stand for a device rather than describing one, which is the privacy class a manifest
|
|
79
|
+
* declares for them. Each holds a per-case alias: stable within one archive, and resolvable to a device only by
|
|
80
|
+
* the owner whose console paired it with a name.
|
|
81
|
+
*/
|
|
82
|
+
const PSEUDONYMOUS_FIELDS = new Set(['accessory', 'accessoryAliases']);
|
|
50
83
|
const MAX_SDK_DETAILS = 16;
|
|
51
84
|
const MAX_LOG_RECORD_BYTES = 64 * 1024;
|
|
52
85
|
const MAX_CURRENT_LOG_BYTES = 50 * 1024 * 1024;
|
|
@@ -71,7 +104,12 @@ const DIAGNOSTICS_DIRECTORY = 'diagnostics';
|
|
|
71
104
|
const GUIDED_SESSION_FILE = 'session.json';
|
|
72
105
|
const REPRODUCTION_MARKERS_FILE = 'reproduction-markers.jsonl';
|
|
73
106
|
const UI_EVENTS_FILE = 'ui-events.jsonl';
|
|
74
|
-
const
|
|
107
|
+
const HOST_ENVIRONMENT_FILE = 'host.json';
|
|
108
|
+
const FLEET_FILE = 'fleet.json';
|
|
109
|
+
/** The longest device model or adapter key retained, which bounds values this plugin does not choose alone. */
|
|
110
|
+
const MAX_DEVICE_MODEL_LENGTH = 64;
|
|
111
|
+
/** How many HomeKit services one fleet entry names, which is above what any single device is represented by. */
|
|
112
|
+
const MAX_FLEET_SERVICES = 12;
|
|
75
113
|
/** How much of an FFmpeg path or version banner is kept, which is more than either needs. */
|
|
76
114
|
const MAX_FFMPEG_IDENTITY_LENGTH = 256;
|
|
77
115
|
const DEBUG_AUTHORIZATION_MS = 72 * 60 * 60 * 1_000;
|
|
@@ -153,6 +191,15 @@ const SDK_EVENT_KEYS = new Set([
|
|
|
153
191
|
'transport-error',
|
|
154
192
|
]);
|
|
155
193
|
const EN_MESSAGES = JSON.parse(readFileSync(new URL('../i18n/runtime/en.json', import.meta.url), 'utf8'));
|
|
194
|
+
/**
|
|
195
|
+
* The reporter's answer as a support archive carries it: every device, or how many they named.
|
|
196
|
+
*
|
|
197
|
+
* The serials themselves stay on the host. They identify one household's hardware, and no evidence class in an
|
|
198
|
+
* archive carries a device identity.
|
|
199
|
+
*/
|
|
200
|
+
function countedAffectedDevices(affected) {
|
|
201
|
+
return affected === 'all' ? 'all' : new Set(affected).size;
|
|
202
|
+
}
|
|
156
203
|
/** The bounded UI events a support session may record, and the only vocabulary the sink accepts. */
|
|
157
204
|
const DIAGNOSTICS_UI_EVENTS = [
|
|
158
205
|
'background-started',
|
|
@@ -183,9 +230,52 @@ const SUPPORT_ARCHIVE_EXCLUDED_CLASSES = [
|
|
|
183
230
|
'tokens-cookies-and-authorization',
|
|
184
231
|
'session-and-push-stores',
|
|
185
232
|
'private-and-symmetric-keys',
|
|
186
|
-
'unconstrained-
|
|
233
|
+
'unconstrained-internal-objects',
|
|
187
234
|
'camera-images-talkback-and-raw-media',
|
|
188
235
|
];
|
|
236
|
+
/**
|
|
237
|
+
* The bug form's `area` option each diagnostics profile belongs to.
|
|
238
|
+
*
|
|
239
|
+
* A dropdown prefill is honoured only on an exact match with a declared option, so these strings are the
|
|
240
|
+
* form's own. Two profiles share `HomeKit Integration`, which is one area to triage.
|
|
241
|
+
*/
|
|
242
|
+
const BUG_REPORT_AREAS = {
|
|
243
|
+
'startup-authentication': 'Login / Authentication',
|
|
244
|
+
'device-representation': 'HomeKit Integration',
|
|
245
|
+
'control-state': 'Device controls',
|
|
246
|
+
'live-media': 'Livestream / Streaming',
|
|
247
|
+
'hksv-recording': 'Recording',
|
|
248
|
+
'dashboard-ui': 'Plugin UI / Dashboard',
|
|
249
|
+
other: 'Other',
|
|
250
|
+
};
|
|
251
|
+
/** Where a report goes, and the form whose field ids the query string addresses. */
|
|
252
|
+
const BUG_REPORT_FORM = 'https://github.com/homebridge-plugins/homebridge-eufy/issues/new';
|
|
253
|
+
/**
|
|
254
|
+
* A prepared bug report for a finished support session.
|
|
255
|
+
*
|
|
256
|
+
* Addresses the committed issue form by filename, and fills the field ids that form declares. A prefill is
|
|
257
|
+
* honoured only against a named template, and only the operational class travels in a query string.
|
|
258
|
+
*
|
|
259
|
+
* The archive field is left unaddressed, because a query string cannot carry a file. Any text placed there
|
|
260
|
+
* satisfies the field's own `required` validation, so a prefill would answer it on the reporter's behalf and
|
|
261
|
+
* the form would accept a report with no archive on it.
|
|
262
|
+
*/
|
|
263
|
+
function bugReportUrl(session, missingEvidence) {
|
|
264
|
+
const url = new URL(BUG_REPORT_FORM);
|
|
265
|
+
url.searchParams.set('template', 'bug_report.yml');
|
|
266
|
+
url.searchParams.set('environment', [
|
|
267
|
+
`- **Plugin Version**: ${PLUGIN_VERSION}`,
|
|
268
|
+
`- **eufy SDK**: ${SDK_VERSION}`,
|
|
269
|
+
`- **Node.js Version**: ${process.version}`,
|
|
270
|
+
`- **Homebridge Version**: `,
|
|
271
|
+
`- **OS**: ${process.platform} ${process.arch}`,
|
|
272
|
+
`- **Support Case ID**: `,
|
|
273
|
+
`- **Diagnostics profile**: ${session.profile} (${session.reproductionMode})`,
|
|
274
|
+
`- **Missing evidence**: ${missingEvidence.length ? missingEvidence.join(', ') : 'none'}`,
|
|
275
|
+
].join('\n'));
|
|
276
|
+
url.searchParams.set('area', BUG_REPORT_AREAS[session.profile]);
|
|
277
|
+
return url.toString();
|
|
278
|
+
}
|
|
189
279
|
const DIAGNOSTICS_PROFILES = {
|
|
190
280
|
'startup-authentication': ['plugin-log', 'sdk-log'],
|
|
191
281
|
'device-representation': ['plugin-log', 'sdk-log', 'homekit-log'],
|
|
@@ -259,54 +349,135 @@ function boundedText(value, maximumLength) {
|
|
|
259
349
|
.trim();
|
|
260
350
|
return printable === '' ? undefined : printable;
|
|
261
351
|
}
|
|
262
|
-
function
|
|
263
|
-
return join(storageRoot, DIAGNOSTICS_DIRECTORY,
|
|
352
|
+
function fleetPath(storageRoot) {
|
|
353
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, FLEET_FILE);
|
|
264
354
|
}
|
|
265
355
|
/**
|
|
266
|
-
* Records
|
|
356
|
+
* Records what the account's devices are and what each becomes, replacing the record whole.
|
|
267
357
|
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
* cannot be reported as
|
|
271
|
-
*
|
|
272
|
-
* would cost the user their cameras.
|
|
358
|
+
* Written by the process that holds both the inventory and the accessory aliases, because an archive is
|
|
359
|
+
* assembled by another one. Rewritten on every complete inventory, so a device withdrawn or newly recognised
|
|
360
|
+
* cannot be reported as still present. A write that fails is dropped: an archive then declares no fleet, which
|
|
361
|
+
* is honest, whereas failing over a diagnostic file would cost the user their cameras.
|
|
273
362
|
*/
|
|
274
|
-
export function
|
|
363
|
+
export function recordFleet(storageRoot, fleet) {
|
|
275
364
|
try {
|
|
276
|
-
const path =
|
|
365
|
+
const path = fleetPath(storageRoot);
|
|
277
366
|
mkdirSync(dirname(path), { mode: 0o700, recursive: true });
|
|
278
|
-
writeFileSync(path, `${JSON.stringify({ version: 1,
|
|
367
|
+
writeFileSync(path, `${JSON.stringify({ version: 1, devices: fleet })}\n`, { mode: 0o600 });
|
|
279
368
|
}
|
|
280
369
|
catch { }
|
|
281
370
|
}
|
|
371
|
+
/** Reads the recorded fleet, keeping each entry whose own fields narrow and dropping each that does not. */
|
|
372
|
+
function readFleet(storageRoot) {
|
|
373
|
+
try {
|
|
374
|
+
const candidate = JSON.parse(readFileSync(fleetPath(storageRoot), 'utf8'));
|
|
375
|
+
if (candidate.version !== 1 || !Array.isArray(candidate.devices)) {
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
const devices = candidate.devices.flatMap((entry) => {
|
|
379
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
380
|
+
return [];
|
|
381
|
+
const row = entry;
|
|
382
|
+
const accessory = opaqueAccessoryAlias(row.accessory);
|
|
383
|
+
if (accessory === undefined || typeof row.represented !== 'boolean' || typeof row.attached !== 'boolean') {
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
const model = typeof row.model === 'string' ? boundedText(row.model, MAX_DEVICE_MODEL_LENGTH) : undefined;
|
|
387
|
+
const services = (Array.isArray(row.services) ? row.services : [])
|
|
388
|
+
.filter((service) => typeof service === 'string')
|
|
389
|
+
.slice(0, MAX_FLEET_SERVICES)
|
|
390
|
+
.flatMap((service) => {
|
|
391
|
+
const bounded = boundedText(service, MAX_DEVICE_MODEL_LENGTH);
|
|
392
|
+
return bounded === undefined ? [] : [bounded];
|
|
393
|
+
});
|
|
394
|
+
return [
|
|
395
|
+
{
|
|
396
|
+
accessory,
|
|
397
|
+
...(model === undefined ? {} : { model }),
|
|
398
|
+
represented: row.represented,
|
|
399
|
+
attached: row.attached,
|
|
400
|
+
services,
|
|
401
|
+
},
|
|
402
|
+
];
|
|
403
|
+
});
|
|
404
|
+
return devices.length === 0 ? undefined : devices.slice(0, MAX_ACCESSORY_ALIASES);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function hostEnvironmentPath(storageRoot) {
|
|
411
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, HOST_ENVIRONMENT_FILE);
|
|
412
|
+
}
|
|
413
|
+
/** The longest host version string retained, which bounds a value this plugin does not choose. */
|
|
414
|
+
const MAX_HOST_VERSION_LENGTH = 64;
|
|
282
415
|
/**
|
|
283
|
-
*
|
|
416
|
+
* Records what this run is hosted by, so a support archive states the host a failure came from.
|
|
284
417
|
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
418
|
+
* It is persisted rather than held in memory because the process that knows these facts is not the one that
|
|
419
|
+
* assembles an archive, and it is rewritten on every start so a host or binary that changed between runs
|
|
420
|
+
* cannot be reported as the one in use. Each call replaces the record whole, so a caller states everything it
|
|
421
|
+
* knows rather than accumulating into a file two writers would race for.
|
|
422
|
+
*
|
|
423
|
+
* A write that fails is dropped: the archive then declares the environment without these facts, which is
|
|
424
|
+
* honest, whereas failing startup over a diagnostic file would cost the user their cameras.
|
|
425
|
+
*/
|
|
426
|
+
export function recordHostEnvironment(storageRoot, host) {
|
|
427
|
+
try {
|
|
428
|
+
const path = hostEnvironmentPath(storageRoot);
|
|
429
|
+
mkdirSync(dirname(path), { mode: 0o700, recursive: true });
|
|
430
|
+
writeFileSync(path, `${JSON.stringify({ version: 2, ...host })}\n`, { mode: 0o600 });
|
|
431
|
+
}
|
|
432
|
+
catch { }
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Reads what this run is hosted by, keeping each fact that narrows and dropping each that does not.
|
|
436
|
+
*
|
|
437
|
+
* The facts are independent, so a malformed binary identity does not withhold a host version, and a record
|
|
438
|
+
* stating neither is no record at all.
|
|
288
439
|
*/
|
|
289
|
-
function
|
|
440
|
+
function readHostEnvironment(storageRoot) {
|
|
290
441
|
try {
|
|
291
|
-
const candidate = JSON.parse(readFileSync(
|
|
292
|
-
|
|
293
|
-
if (candidate.version !== 1 || !ffmpeg || typeof ffmpeg !== 'object' || Array.isArray(ffmpeg)) {
|
|
442
|
+
const candidate = JSON.parse(readFileSync(hostEnvironmentPath(storageRoot), 'utf8'));
|
|
443
|
+
if (candidate.version !== 2) {
|
|
294
444
|
return undefined;
|
|
295
445
|
}
|
|
296
|
-
const
|
|
297
|
-
|
|
446
|
+
const homebridge = typeof candidate.homebridge === 'string' ? boundedText(candidate.homebridge, MAX_HOST_VERSION_LENGTH) : undefined;
|
|
447
|
+
const ffmpeg = readFfmpegIdentity(candidate.ffmpeg);
|
|
448
|
+
if (homebridge === undefined && ffmpeg === undefined) {
|
|
298
449
|
return undefined;
|
|
299
450
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
: { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
|
|
451
|
+
return {
|
|
452
|
+
...(homebridge === undefined ? {} : { homebridge }),
|
|
453
|
+
...(ffmpeg === undefined ? {} : { ffmpeg }),
|
|
454
|
+
};
|
|
305
455
|
}
|
|
306
456
|
catch {
|
|
307
457
|
return undefined;
|
|
308
458
|
}
|
|
309
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* The recorded adaptation binary, or nothing where the record's own fields do not narrow.
|
|
462
|
+
*
|
|
463
|
+
* The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
|
|
464
|
+
* entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
|
|
465
|
+
* path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
|
|
466
|
+
*/
|
|
467
|
+
function readFfmpegIdentity(value) {
|
|
468
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
const recorded = value;
|
|
472
|
+
if (recorded.source !== 'bundled' && recorded.source !== 'configured') {
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
const path = typeof recorded.path === 'string' ? boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
|
|
476
|
+
const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
|
|
477
|
+
return path === undefined
|
|
478
|
+
? undefined
|
|
479
|
+
: { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
|
|
480
|
+
}
|
|
310
481
|
/**
|
|
311
482
|
* The one log evidence class a retained record belongs to.
|
|
312
483
|
*
|
|
@@ -348,13 +519,16 @@ export class GuidedDiagnostics {
|
|
|
348
519
|
this.now = now;
|
|
349
520
|
this.supportArchiveKey = supportArchiveKey;
|
|
350
521
|
}
|
|
351
|
-
async authorize(profile, reproductionMode) {
|
|
522
|
+
async authorize(profile, reproductionMode, affectedDevices) {
|
|
352
523
|
if (!isDiagnosticsProfile(profile)) {
|
|
353
524
|
throw new Error('Unknown diagnostics profile');
|
|
354
525
|
}
|
|
355
526
|
if (!isDiagnosticsReproductionMode(reproductionMode)) {
|
|
356
527
|
throw new Error('Unknown diagnostics reproduction mode');
|
|
357
528
|
}
|
|
529
|
+
if (affectedDevices !== undefined && affectedDevices !== 'all' && !Array.isArray(affectedDevices)) {
|
|
530
|
+
throw new Error('Affected devices must be "all" or a list of serials');
|
|
531
|
+
}
|
|
358
532
|
this.uiEventsClosing = true;
|
|
359
533
|
try {
|
|
360
534
|
await this.uiEventWrites;
|
|
@@ -366,6 +540,7 @@ export class GuidedDiagnostics {
|
|
|
366
540
|
reproductionMode,
|
|
367
541
|
authorizedAt: new Date(authorizedAt).toISOString(),
|
|
368
542
|
expiresAt: new Date(authorizedAt + DEBUG_AUTHORIZATION_MS).toISOString(),
|
|
543
|
+
...(affectedDevices === undefined ? {} : { affectedDevices }),
|
|
369
544
|
};
|
|
370
545
|
await this.writeSession(session);
|
|
371
546
|
this.pendingSupportArchive = undefined;
|
|
@@ -579,24 +754,67 @@ export class GuidedDiagnostics {
|
|
|
579
754
|
}
|
|
580
755
|
}
|
|
581
756
|
async collectSupportEvidence(session) {
|
|
582
|
-
const
|
|
757
|
+
const host = readHostEnvironment(this.storageRoot);
|
|
758
|
+
const fleet = readFleet(this.storageRoot);
|
|
583
759
|
const evidence = [
|
|
760
|
+
...(session.affectedDevices === undefined
|
|
761
|
+
? []
|
|
762
|
+
: [
|
|
763
|
+
{
|
|
764
|
+
evidence: 'reporter-statement',
|
|
765
|
+
privacyClass: 'diagnostic',
|
|
766
|
+
contentType: 'application/json',
|
|
767
|
+
content: `${JSON.stringify({ version: 2, affectedDevices: countedAffectedDevices(session.affectedDevices) })}\n`,
|
|
768
|
+
/**
|
|
769
|
+
* What the reporter answered, not what the plugin observed. A serial is a device identity and no
|
|
770
|
+
* evidence class carries one, so the answer travels as its scope: every device, or how many were
|
|
771
|
+
* named. Which ones is the reporter's to say in their own words.
|
|
772
|
+
*/
|
|
773
|
+
fields: [
|
|
774
|
+
{ field: 'version', privacyClass: 'operational' },
|
|
775
|
+
{ field: 'affectedDevices', privacyClass: 'operational' },
|
|
776
|
+
],
|
|
777
|
+
},
|
|
778
|
+
]),
|
|
779
|
+
...(fleet === undefined
|
|
780
|
+
? []
|
|
781
|
+
: [
|
|
782
|
+
{
|
|
783
|
+
evidence: 'fleet',
|
|
784
|
+
privacyClass: 'operational',
|
|
785
|
+
contentType: 'application/json',
|
|
786
|
+
content: `${JSON.stringify({ version: 1, devices: fleet })}\n`,
|
|
787
|
+
/**
|
|
788
|
+
* What each device is and becomes, against the alias every other record about it carries. A model
|
|
789
|
+
* and an admission outcome are product facts rather than device identity; the alias is what makes
|
|
790
|
+
* an entry resolvable to a device, and only by the owner whose console paired it with a name.
|
|
791
|
+
*/
|
|
792
|
+
fields: [
|
|
793
|
+
{ field: 'version', privacyClass: 'operational' },
|
|
794
|
+
{ field: 'devices', privacyClass: 'pseudonymous' },
|
|
795
|
+
],
|
|
796
|
+
},
|
|
797
|
+
]),
|
|
584
798
|
{
|
|
585
799
|
evidence: 'environment',
|
|
586
800
|
privacyClass: 'operational',
|
|
587
801
|
contentType: 'application/json',
|
|
588
|
-
content: `${JSON.stringify({ version:
|
|
802
|
+
content: `${JSON.stringify({ version: 3, plugin: PLUGIN_VERSION, sdk: SDK_VERSION, node: process.version, ...(host?.homebridge ? { homebridge: host.homebridge } : {}), platform: process.platform, arch: process.arch, ...(host?.ffmpeg ? { ffmpeg: host.ffmpeg } : {}) })}\n`,
|
|
589
803
|
/**
|
|
590
804
|
* The record is operational and one field is classified above it: a resolved FFmpeg path is an
|
|
591
805
|
* environment fact, but it can carry the home directory of the account Homebridge runs as, so it is
|
|
592
806
|
* declared as diagnostic rather than presented alongside the host's own architecture.
|
|
807
|
+
*
|
|
808
|
+
* The Homebridge version is operational and load-bearing: this plugin requires Homebridge 2, and a
|
|
809
|
+
* host that predates it loads the plugin and then fails in ways a media fault cannot be told from.
|
|
593
810
|
*/
|
|
594
811
|
fields: [
|
|
595
812
|
...['version', 'plugin', 'sdk', 'node', 'platform', 'arch'].map((field) => ({
|
|
596
813
|
field,
|
|
597
814
|
privacyClass: 'operational',
|
|
598
815
|
})),
|
|
599
|
-
...(
|
|
816
|
+
...(host?.homebridge ? [{ field: 'homebridge', privacyClass: 'operational' }] : []),
|
|
817
|
+
...(host?.ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
|
|
600
818
|
],
|
|
601
819
|
},
|
|
602
820
|
];
|
|
@@ -647,7 +865,11 @@ export class GuidedDiagnostics {
|
|
|
647
865
|
...(carried.retainedFrom === undefined ? {} : { retainedFrom: carried.retainedFrom }),
|
|
648
866
|
fields: [...new Set(carried.records.flatMap((record) => Object.keys(record)))].sort().map((field) => ({
|
|
649
867
|
field,
|
|
650
|
-
privacyClass: field
|
|
868
|
+
privacyClass: PSEUDONYMOUS_FIELDS.has(field)
|
|
869
|
+
? 'pseudonymous'
|
|
870
|
+
: field === 'timestamp'
|
|
871
|
+
? 'operational'
|
|
872
|
+
: 'diagnostic',
|
|
651
873
|
})),
|
|
652
874
|
});
|
|
653
875
|
}
|
|
@@ -827,12 +1049,6 @@ export class GuidedDiagnostics {
|
|
|
827
1049
|
: session.reproductionStartedAt
|
|
828
1050
|
? 'reproducing'
|
|
829
1051
|
: 'authorized';
|
|
830
|
-
const issueBody = [
|
|
831
|
-
`Support case: ${session.supportCaseId}`,
|
|
832
|
-
`Profile: ${session.profile}`,
|
|
833
|
-
`Reproduction mode: ${session.reproductionMode}`,
|
|
834
|
-
`Missing evidence: ${missingEvidence.length ? missingEvidence.join(', ') : 'none'}`,
|
|
835
|
-
].join('\n');
|
|
836
1052
|
return {
|
|
837
1053
|
status,
|
|
838
1054
|
supportCaseId: session.supportCaseId,
|
|
@@ -845,7 +1061,8 @@ export class GuidedDiagnostics {
|
|
|
845
1061
|
...(session.reproductionStartedAt ? { reproductionStartedAt: session.reproductionStartedAt } : {}),
|
|
846
1062
|
...(session.reproductionEndedAt ? { reproductionEndedAt: session.reproductionEndedAt } : {}),
|
|
847
1063
|
partialExportAvailable: Boolean(session.reproductionEndedAt),
|
|
848
|
-
|
|
1064
|
+
...(session.affectedDevices === undefined ? {} : { affectedDevices: session.affectedDevices }),
|
|
1065
|
+
issueUrl: bugReportUrl(session, missingEvidence),
|
|
849
1066
|
};
|
|
850
1067
|
}
|
|
851
1068
|
/**
|
|
@@ -1133,6 +1350,14 @@ function allowlistedLabel(value, allowed) {
|
|
|
1133
1350
|
* like any other field that fails its shape.
|
|
1134
1351
|
*/
|
|
1135
1352
|
const MAX_STARTUP_WINDOW_MS = 60_000;
|
|
1353
|
+
/** Why a level-2 wait ended without a key, which is the whole of what such an ending states. */
|
|
1354
|
+
const LEVEL2_UNAVAILABLE_REASONS = [
|
|
1355
|
+
'no-cipher-key',
|
|
1356
|
+
'derivation-failed',
|
|
1357
|
+
'not-negotiating',
|
|
1358
|
+
'session-closed',
|
|
1359
|
+
'grace-elapsed',
|
|
1360
|
+
];
|
|
1136
1361
|
/** The three phases whose only field is the action they report, which the union fixes at `start`. */
|
|
1137
1362
|
const retainedStart = (candidate) => candidate.action === 'start' ? { action: 'start' } : undefined;
|
|
1138
1363
|
/** A phase whose only field is one bounded integer, retained under the name the SDK declares for it. */
|
|
@@ -1178,7 +1403,59 @@ const LIVE_TRACE_PHASES = {
|
|
|
1178
1403
|
'sequence-restart': retainedInteger('dataType', 3),
|
|
1179
1404
|
'level2-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
|
|
1180
1405
|
'level2-ready': retainedInteger('cipherId', 65535),
|
|
1181
|
-
'
|
|
1406
|
+
'session-connect-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
|
|
1407
|
+
'session-connected': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
|
|
1408
|
+
'session-unreachable': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
|
|
1409
|
+
/**
|
|
1410
|
+
* Why a level-2 wait ended without a key, and what it had to name.
|
|
1411
|
+
*
|
|
1412
|
+
* The reason is the record: a wait that ran out states how long it waited, and one answered without waiting
|
|
1413
|
+
* states nothing more, because the negotiation is one-shot per connection. Only a reason reached under a
|
|
1414
|
+
* negotiation has a cipher to name, so both durations are optional and each is retained where it is stated.
|
|
1415
|
+
*/
|
|
1416
|
+
'level2-unavailable': (c) => {
|
|
1417
|
+
const reason = allowlistedLabel(c.reason, LEVEL2_UNAVAILABLE_REASONS);
|
|
1418
|
+
if (!reason) {
|
|
1419
|
+
return undefined;
|
|
1420
|
+
}
|
|
1421
|
+
const cipherId = boundedInteger(c.cipherId, 65535);
|
|
1422
|
+
const waitedMs = boundedInteger(c.waitedMs, MAX_STARTUP_WINDOW_MS);
|
|
1423
|
+
return {
|
|
1424
|
+
reason,
|
|
1425
|
+
...(cipherId === undefined ? {} : { cipherId }),
|
|
1426
|
+
...(waitedMs === undefined ? {} : { waitedMs }),
|
|
1427
|
+
};
|
|
1428
|
+
},
|
|
1429
|
+
/** The cipher a station asked for, beside the one whose material answered it. */
|
|
1430
|
+
'cipher-fallback': (c) => {
|
|
1431
|
+
const cipherId = boundedInteger(c.cipherId, 65535);
|
|
1432
|
+
const answeredCipherId = boundedInteger(c.answeredCipherId, 65535);
|
|
1433
|
+
return cipherId === undefined || answeredCipherId === undefined ? undefined : { cipherId, answeredCipherId };
|
|
1434
|
+
},
|
|
1435
|
+
'level2-negotiating': retainedInteger('cipherId', 65535),
|
|
1436
|
+
/**
|
|
1437
|
+
* What a call resolved a station as, which decides what its failure means.
|
|
1438
|
+
*
|
|
1439
|
+
* An attached camera's media start has no unencrypted form, so the topology is what separates a key that
|
|
1440
|
+
* never arrived from a start that had no form to be sent in. The channel is an index on a base and the
|
|
1441
|
+
* administrator is a relation to the signed-in account: neither is an identity of its own.
|
|
1442
|
+
*/
|
|
1443
|
+
/**
|
|
1444
|
+
* Which lookup channels a connection could ask for a station on, before it asked.
|
|
1445
|
+
*
|
|
1446
|
+
* A connect that had one channel failed for that channel's reason alone, and a connect that had neither
|
|
1447
|
+
* could not have succeeded. Both are otherwise indistinguishable from a station that is switched off.
|
|
1448
|
+
*/
|
|
1449
|
+
'lookup-channels': (c) => typeof c.local === 'boolean' && typeof c.cloud === 'boolean' ? { local: c.local, cloud: c.cloud } : undefined,
|
|
1450
|
+
'station-resolved': (c) => {
|
|
1451
|
+
const topology = allowlistedLabel(c.topology, ['attached', 'own']);
|
|
1452
|
+
const stationAdmin = allowlistedLabel(c.stationAdmin, ['self', 'other', 'unstated']);
|
|
1453
|
+
const channel = boundedInteger(c.channel, MAX_STATION_CHANNEL);
|
|
1454
|
+
const stationModel = typeof c.stationModel === 'string' ? boundedText(c.stationModel, MAX_DEVICE_MODEL_LENGTH) : undefined;
|
|
1455
|
+
return topology && stationAdmin && channel !== undefined
|
|
1456
|
+
? { topology, channel, stationAdmin, ...(stationModel === undefined ? {} : { stationModel }) }
|
|
1457
|
+
: undefined;
|
|
1458
|
+
},
|
|
1182
1459
|
/**
|
|
1183
1460
|
* How long a connection's path has answered nothing, which is the station stating the path is gone.
|
|
1184
1461
|
*
|
|
@@ -1186,6 +1463,19 @@ const LIVE_TRACE_PHASES = {
|
|
|
1186
1463
|
* silence longer than that belongs to a session nothing is waiting on.
|
|
1187
1464
|
*/
|
|
1188
1465
|
'path-stale': retainedInteger('silentMs', MAX_STARTUP_WINDOW_MS),
|
|
1466
|
+
/**
|
|
1467
|
+
* How long a camera's own channel delivered nothing, and whether the station was asked to serve it again.
|
|
1468
|
+
*
|
|
1469
|
+
* Both halves are the record: a station re-tasked to a sibling is repaired by the re-assert, and a pull
|
|
1470
|
+
* nothing is attached to declines it so the station is left with the camera someone is watching. A picture
|
|
1471
|
+
* that stopped advancing while no such record exists stopped for a reason the station's attention does not
|
|
1472
|
+
* reach.
|
|
1473
|
+
*/
|
|
1474
|
+
'channel-silent': (c) => {
|
|
1475
|
+
const silentMs = boundedInteger(c.silentMs, MAX_STARTUP_WINDOW_MS);
|
|
1476
|
+
const outcome = allowlistedLabel(c.outcome, ['declined', 'reasserted']);
|
|
1477
|
+
return silentMs !== undefined && outcome ? { silentMs, outcome } : undefined;
|
|
1478
|
+
},
|
|
1189
1479
|
'media-command-unsent': (c) => {
|
|
1190
1480
|
const reason = allowlistedLabel(c.reason, ['level2-key', 'address']);
|
|
1191
1481
|
return reason ? { reason } : undefined;
|
|
@@ -1219,6 +1509,18 @@ function sanitizeSdkLiveStartTrace(value) {
|
|
|
1219
1509
|
function opaquePullHandle(value) {
|
|
1220
1510
|
return typeof value === 'string' && /^(?:pull|station)-\d{1,6}(?::\d{1,3})?$/.test(value) ? value : undefined;
|
|
1221
1511
|
}
|
|
1512
|
+
/**
|
|
1513
|
+
* One support-case accessory alias, or nothing where the value is not one.
|
|
1514
|
+
*
|
|
1515
|
+
* Matched against the shape an alias has rather than against what a serial looks like, on the same rule as
|
|
1516
|
+
* {@link opaquePullHandle}: a record carries an alias or it carries no accessory at all.
|
|
1517
|
+
*/
|
|
1518
|
+
function opaqueAccessoryAlias(value) {
|
|
1519
|
+
return typeof value === 'string' &&
|
|
1520
|
+
/^accessory-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)
|
|
1521
|
+
? value
|
|
1522
|
+
: undefined;
|
|
1523
|
+
}
|
|
1222
1524
|
/**
|
|
1223
1525
|
* Adapts SDK protocol detail to bounded debug output without preserving supplied values.
|
|
1224
1526
|
*
|
|
@@ -1260,9 +1562,7 @@ export function createSdkLogger(target) {
|
|
|
1260
1562
|
const subsystem = SDK_SUBSYSTEMS.has(requestedSubsystem ?? '') ? requestedSubsystem : (aliasedSubsystem ?? 'sdk');
|
|
1261
1563
|
const details = args.slice(0, MAX_SDK_DETAILS).map((value) => {
|
|
1262
1564
|
if (value instanceof Error) {
|
|
1263
|
-
const errorType =
|
|
1264
|
-
? value.name
|
|
1265
|
-
: 'Error';
|
|
1565
|
+
const errorType = SDK_ERROR_TYPES.includes(value.name) ? value.name : 'Error';
|
|
1266
1566
|
return { errorType };
|
|
1267
1567
|
}
|
|
1268
1568
|
if (typeof value === 'string') {
|
|
@@ -1386,6 +1686,8 @@ const REASONS = new Set([
|
|
|
1386
1686
|
'source-input-unstable',
|
|
1387
1687
|
'source-stopped',
|
|
1388
1688
|
'source-unavailable',
|
|
1689
|
+
'station-key-unavailable',
|
|
1690
|
+
'station-unreachable',
|
|
1389
1691
|
'stored-download-failed',
|
|
1390
1692
|
'stored-failed',
|
|
1391
1693
|
'stored-invalid-image',
|
|
@@ -1466,6 +1768,13 @@ const ADAPTATION_EVENTS = new Set([
|
|
|
1466
1768
|
'exited-while-streaming',
|
|
1467
1769
|
'output',
|
|
1468
1770
|
]);
|
|
1771
|
+
/**
|
|
1772
|
+
* Why a process was started, on the record that reports it starting.
|
|
1773
|
+
*
|
|
1774
|
+
* A replacement carries the identity of the stream the previous process was already sending on, so what it
|
|
1775
|
+
* started mid-flight is judged against what that one left. A first adaptation has nothing behind it.
|
|
1776
|
+
*/
|
|
1777
|
+
const ADAPTATION_CAUSES = ['first', 'controller-selection', 'source-configuration'];
|
|
1469
1778
|
/**
|
|
1470
1779
|
* Which of those events is a failure, and so the ones a record is levelled `warn` for.
|
|
1471
1780
|
*
|
|
@@ -1681,12 +1990,7 @@ function sanitizeStructuredEvent(message) {
|
|
|
1681
1990
|
if (!detail || typeof detail !== 'object' || Array.isArray(detail))
|
|
1682
1991
|
continue;
|
|
1683
1992
|
const candidate = detail;
|
|
1684
|
-
const errorType = allowlistedLabel(candidate.errorType,
|
|
1685
|
-
'Error',
|
|
1686
|
-
'RangeError',
|
|
1687
|
-
'SessionExpiredError',
|
|
1688
|
-
'TypeError',
|
|
1689
|
-
]);
|
|
1993
|
+
const errorType = allowlistedLabel(candidate.errorType, SDK_ERROR_TYPES);
|
|
1690
1994
|
if (errorType) {
|
|
1691
1995
|
details.push({ errorType });
|
|
1692
1996
|
continue;
|
|
@@ -1737,6 +2041,10 @@ function sanitizeStructuredEvent(message) {
|
|
|
1737
2041
|
return { scope: 'media-notice', level: notice.level, code: value.code, messageKey: notice.messageKey };
|
|
1738
2042
|
}
|
|
1739
2043
|
if (value.scope === 'homekit') {
|
|
2044
|
+
if (value.event === 'station-claim') {
|
|
2045
|
+
const claim = sanitizeStationClaim(value);
|
|
2046
|
+
return claim ? { scope: 'homekit', level: 'debug', ...claim } : undefined;
|
|
2047
|
+
}
|
|
1740
2048
|
if (value.adapter === 'camera.streaming' && value.event === 'live-video-selected') {
|
|
1741
2049
|
const selection = sanitizeLiveVideoSelection(value);
|
|
1742
2050
|
return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
|
|
@@ -1752,6 +2060,7 @@ function sanitizeStructuredEvent(message) {
|
|
|
1752
2060
|
!HOMEKIT_OBSERVATIONS.has(value.observation)) {
|
|
1753
2061
|
return undefined;
|
|
1754
2062
|
}
|
|
2063
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
1755
2064
|
return {
|
|
1756
2065
|
scope: 'homekit',
|
|
1757
2066
|
level: 'debug',
|
|
@@ -1761,6 +2070,7 @@ function sanitizeStructuredEvent(message) {
|
|
|
1761
2070
|
...(typeof value.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(value.announcedBy)
|
|
1762
2071
|
? { announcedBy: value.announcedBy }
|
|
1763
2072
|
: {}),
|
|
2073
|
+
...(alias === undefined ? {} : { accessory: alias }),
|
|
1764
2074
|
};
|
|
1765
2075
|
}
|
|
1766
2076
|
if (value.scope === 'runtime') {
|
|
@@ -1877,17 +2187,55 @@ export function reportInvalidSnapshotCache(target) {
|
|
|
1877
2187
|
target[level](`[${code}] ${localize(target, messageKey)}`);
|
|
1878
2188
|
target.debug?.(JSON.stringify({ scope: 'media-notice', level, code, messageKey }));
|
|
1879
2189
|
}
|
|
1880
|
-
/**
|
|
1881
|
-
|
|
2190
|
+
/**
|
|
2191
|
+
* Emits one allowlisted HomeKit event trace only when host debug output is available.
|
|
2192
|
+
*
|
|
2193
|
+
* `aliasFor` resolves the trace's device identity to the support-case alias the conditions about that accessory
|
|
2194
|
+
* already carry. The identity itself never reaches the record: an unresolved one is recorded as no accessory.
|
|
2195
|
+
*/
|
|
2196
|
+
/**
|
|
2197
|
+
* Narrows one offered station arbitration decision, or nothing where its own labels do not narrow.
|
|
2198
|
+
*
|
|
2199
|
+
* Read by both halves of the path — {@link reportStationClaim} builds the record and the file sink rebuilds
|
|
2200
|
+
* it — because a record only one of them recognises is written to a console nobody is reading and dropped
|
|
2201
|
+
* before it reaches a support archive.
|
|
2202
|
+
*/
|
|
2203
|
+
function sanitizeStationClaim(value) {
|
|
2204
|
+
const action = allowlistedLabel(value.action, STATION_CLAIM_ACTIONS);
|
|
2205
|
+
const claim = allowlistedLabel(value.claim, STATION_CLAIMS);
|
|
2206
|
+
if (!action || !claim) {
|
|
2207
|
+
return undefined;
|
|
2208
|
+
}
|
|
2209
|
+
const against = allowlistedLabel(value.to ?? value.by ?? value.against, STATION_CLAIMS);
|
|
2210
|
+
return { event: 'station-claim', action, claim, ...(against === undefined ? {} : { against }) };
|
|
2211
|
+
}
|
|
2212
|
+
/**
|
|
2213
|
+
* Records one arbitration decision over a station's own session as `homekit-log` evidence.
|
|
2214
|
+
*
|
|
2215
|
+
* A still that stood aside produced no picture of its own and no failure, so without this the deferral is
|
|
2216
|
+
* indistinguishable afterwards from a capture that was never asked for. The record carries the claims and the
|
|
2217
|
+
* decision alone: a station identity is a serial, and the camera holding it is already named by the records
|
|
2218
|
+
* about that camera.
|
|
2219
|
+
*/
|
|
2220
|
+
export function reportStationClaim(target, decision) {
|
|
2221
|
+
const claim = sanitizeStationClaim(decision);
|
|
2222
|
+
if (target.debug && claim) {
|
|
2223
|
+
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...claim }));
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
export function reportHomeKitEvent(target, trace, aliasFor) {
|
|
2227
|
+
const alias = typeof trace.serial === 'string' ? aliasFor?.(trace.serial) : undefined;
|
|
2228
|
+
const offered = { ...trace, accessory: alias };
|
|
2229
|
+
const accessory = alias === undefined ? {} : { accessory: alias };
|
|
1882
2230
|
if (trace.event === 'live-video-selected') {
|
|
1883
|
-
const selection = sanitizeLiveVideoSelection(
|
|
2231
|
+
const selection = sanitizeLiveVideoSelection(offered);
|
|
1884
2232
|
if (target.debug && selection) {
|
|
1885
2233
|
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...selection }));
|
|
1886
2234
|
}
|
|
1887
2235
|
return;
|
|
1888
2236
|
}
|
|
1889
2237
|
if (HOMEKIT_LIVE_REQUEST_EVENTS.has(trace.event)) {
|
|
1890
|
-
const lifecycle = sanitizeLiveSessionTrace(
|
|
2238
|
+
const lifecycle = sanitizeLiveSessionTrace(offered);
|
|
1891
2239
|
if (target.debug && lifecycle) {
|
|
1892
2240
|
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...lifecycle }));
|
|
1893
2241
|
}
|
|
@@ -1909,6 +2257,7 @@ export function reportHomeKitEvent(target, trace) {
|
|
|
1909
2257
|
...(typeof trace.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(trace.announcedBy)
|
|
1910
2258
|
? { announcedBy: trace.announcedBy }
|
|
1911
2259
|
: {}),
|
|
2260
|
+
...accessory,
|
|
1912
2261
|
}));
|
|
1913
2262
|
}
|
|
1914
2263
|
/**
|
|
@@ -2015,11 +2364,13 @@ function sanitizeAdaptationNotice(value, level) {
|
|
|
2015
2364
|
.filter((line) => line !== undefined)
|
|
2016
2365
|
.slice(-MAX_ADAPTATION_STDERR_LINES);
|
|
2017
2366
|
const sourceFragments = nonNegativeInteger(value.sourceFragments);
|
|
2367
|
+
const cause = allowlistedLabel(value.cause, ADAPTATION_CAUSES);
|
|
2018
2368
|
return {
|
|
2019
2369
|
scope: 'ffmpeg',
|
|
2020
2370
|
level,
|
|
2021
2371
|
role: value.role,
|
|
2022
2372
|
event: value.event,
|
|
2373
|
+
...(cause === undefined ? {} : { cause }),
|
|
2023
2374
|
...(code === undefined ? {} : { code }),
|
|
2024
2375
|
...(signal === undefined ? {} : { signal }),
|
|
2025
2376
|
...(sourceFragments === undefined ? {} : { sourceFragments }),
|
|
@@ -2030,17 +2381,35 @@ function sanitizeLiveSessionTrace(value) {
|
|
|
2030
2381
|
if (value.adapter !== 'camera.streaming') {
|
|
2031
2382
|
return undefined;
|
|
2032
2383
|
}
|
|
2033
|
-
|
|
2034
|
-
|
|
2384
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
2385
|
+
const accessory = alias === undefined ? {} : { accessory: alias };
|
|
2386
|
+
if (value.event === 'live-session-released') {
|
|
2387
|
+
const release = allowlistedLabel(value.release, LIVE_SESSION_RELEASES);
|
|
2388
|
+
if (!release) {
|
|
2389
|
+
return undefined;
|
|
2390
|
+
}
|
|
2391
|
+
const reports = nonNegativeInteger(value.reports);
|
|
2392
|
+
const sinceLastReportMs = boundedInteger(value.sinceLastReportMs, MAX_STARTUP_WINDOW_MS);
|
|
2393
|
+
return {
|
|
2394
|
+
adapter: value.adapter,
|
|
2395
|
+
event: value.event,
|
|
2396
|
+
release,
|
|
2397
|
+
...(reports === undefined ? {} : { reports }),
|
|
2398
|
+
...(sinceLastReportMs === undefined ? {} : { sinceLastReportMs }),
|
|
2399
|
+
...accessory,
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
if (value.event === 'live-session-streaming') {
|
|
2403
|
+
return { adapter: value.adapter, event: value.event, ...accessory };
|
|
2035
2404
|
}
|
|
2036
2405
|
if (value.event === 'live-request-refused') {
|
|
2037
2406
|
return typeof value.reason === 'string' && REFUSAL_REASONS.has(value.reason)
|
|
2038
|
-
? { adapter: value.adapter, event: value.event, reason: value.reason }
|
|
2407
|
+
? { adapter: value.adapter, event: value.event, reason: value.reason, ...accessory }
|
|
2039
2408
|
: undefined;
|
|
2040
2409
|
}
|
|
2041
2410
|
if (value.event === 'live-request-unaccounted') {
|
|
2042
2411
|
return typeof value.afterMs === 'number' && Number.isFinite(value.afterMs)
|
|
2043
|
-
? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs) }
|
|
2412
|
+
? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs), ...accessory }
|
|
2044
2413
|
: undefined;
|
|
2045
2414
|
}
|
|
2046
2415
|
if (value.event !== 'live-session-failed' ||
|
|
@@ -2057,6 +2426,7 @@ function sanitizeLiveSessionTrace(value) {
|
|
|
2057
2426
|
outcome: value.outcome,
|
|
2058
2427
|
reason: value.reason,
|
|
2059
2428
|
stage: value.stage,
|
|
2429
|
+
...accessory,
|
|
2060
2430
|
};
|
|
2061
2431
|
}
|
|
2062
2432
|
function sanitizeLiveVideoSelection(value) {
|
|
@@ -2082,6 +2452,7 @@ function sanitizeLiveVideoSelection(value) {
|
|
|
2082
2452
|
!HOMEKIT_LIVE_ADDRESS_VERSIONS.has(addressVersion)) {
|
|
2083
2453
|
return undefined;
|
|
2084
2454
|
}
|
|
2455
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
2085
2456
|
return {
|
|
2086
2457
|
adapter: 'camera.streaming',
|
|
2087
2458
|
event: 'live-video-selected',
|
|
@@ -2093,6 +2464,7 @@ function sanitizeLiveVideoSelection(value) {
|
|
|
2093
2464
|
fps,
|
|
2094
2465
|
mtu,
|
|
2095
2466
|
addressVersion,
|
|
2467
|
+
...(alias === undefined ? {} : { accessory: alias }),
|
|
2096
2468
|
};
|
|
2097
2469
|
}
|
|
2098
2470
|
/** Emits bounded normal-output condition transitions without stable device or account identity. */
|
|
@@ -2151,7 +2523,7 @@ export class DiagnosticConditions {
|
|
|
2151
2523
|
}
|
|
2152
2524
|
const uniqueDeviceIds = condition.active ? [...new Set(affectedDeviceIds)].sort() : [];
|
|
2153
2525
|
const accessoryAliases = uniqueDeviceIds
|
|
2154
|
-
.map((identity) => this.
|
|
2526
|
+
.map((identity) => this.aliasFor(identity))
|
|
2155
2527
|
.filter((alias) => alias !== undefined)
|
|
2156
2528
|
.sort();
|
|
2157
2529
|
this.write(condition.code, condition.active, condition.reason, definition[0], definition[1], condition.active ? 'warn' : 'info', {
|
|
@@ -2168,7 +2540,13 @@ export class DiagnosticConditions {
|
|
|
2168
2540
|
})
|
|
2169
2541
|
.filter((named) => named !== undefined));
|
|
2170
2542
|
}
|
|
2171
|
-
|
|
2543
|
+
/**
|
|
2544
|
+
* The support-case alias for one device identity, minted on first use.
|
|
2545
|
+
*
|
|
2546
|
+
* The one place an alias is decided, so every record about a device — a condition, a media trace — carries
|
|
2547
|
+
* the same one, and the owner's console line is what resolves it to a name.
|
|
2548
|
+
*/
|
|
2549
|
+
aliasFor(identity) {
|
|
2172
2550
|
let alias = this.aliases.get(identity);
|
|
2173
2551
|
if (!alias && this.aliases.size < MAX_ACCESSORY_ALIASES) {
|
|
2174
2552
|
alias = `accessory-${randomUUID()}`;
|