@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.6 → 5.0.0-beta.8
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/dist/diagnostics.js +283 -33
- package/dist/diagnostics.js.map +1 -1
- package/dist/homekit/adapters/camera-streaming.js +50 -8
- package/dist/homekit/adapters/camera-streaming.js.map +1 -1
- package/dist/media/live-stream.js +62 -47
- package/dist/media/live-stream.js.map +1 -1
- package/dist/media/station-live-sessions.js +13 -1
- package/dist/media/station-live-sessions.js.map +1 -1
- package/dist/platform.js +23 -4
- package/dist/platform.js.map +1 -1
- package/package.json +2 -2
package/dist/diagnostics.js
CHANGED
|
@@ -35,6 +35,17 @@ export function unconfirmedWriteCondition(property) {
|
|
|
35
35
|
}
|
|
36
36
|
/** Why a live session gave its source back: a stop from outside it, or the session ending itself. */
|
|
37
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;
|
|
38
49
|
/** The bounded reasons a stream request is refused before it reaches the source. */
|
|
39
50
|
const REFUSAL_REASONS = new Set(['disabled', 'at-capacity', 'cancelled', 'prepare-failed']);
|
|
40
51
|
/**
|
|
@@ -56,7 +67,15 @@ const HOMEKIT_LIVE_REQUEST_EVENTS = new Set([
|
|
|
56
67
|
* failure. A station refusing a second camera is one of these identities, because a base serving another of its
|
|
57
68
|
* cameras is working correctly and must not read as a fault.
|
|
58
69
|
*/
|
|
59
|
-
const SDK_ERROR_TYPES = [
|
|
70
|
+
const SDK_ERROR_TYPES = [
|
|
71
|
+
'Error',
|
|
72
|
+
'RangeError',
|
|
73
|
+
'SessionExpiredError',
|
|
74
|
+
'StationBusyError',
|
|
75
|
+
'StationKeyUnavailableError',
|
|
76
|
+
'StationUnreachableError',
|
|
77
|
+
'TypeError',
|
|
78
|
+
];
|
|
60
79
|
/**
|
|
61
80
|
* The retained fields that stand for a device rather than describing one, which is the privacy class a manifest
|
|
62
81
|
* declares for them. Each holds a per-case alias: stable within one archive, and resolvable to a device only by
|
|
@@ -87,7 +106,12 @@ const DIAGNOSTICS_DIRECTORY = 'diagnostics';
|
|
|
87
106
|
const GUIDED_SESSION_FILE = 'session.json';
|
|
88
107
|
const REPRODUCTION_MARKERS_FILE = 'reproduction-markers.jsonl';
|
|
89
108
|
const UI_EVENTS_FILE = 'ui-events.jsonl';
|
|
90
|
-
const
|
|
109
|
+
const HOST_ENVIRONMENT_FILE = 'host.json';
|
|
110
|
+
const FLEET_FILE = 'fleet.json';
|
|
111
|
+
/** The longest device model or adapter key retained, which bounds values this plugin does not choose alone. */
|
|
112
|
+
const MAX_DEVICE_MODEL_LENGTH = 64;
|
|
113
|
+
/** How many HomeKit services one fleet entry names, which is above what any single device is represented by. */
|
|
114
|
+
const MAX_FLEET_SERVICES = 12;
|
|
91
115
|
/** How much of an FFmpeg path or version banner is kept, which is more than either needs. */
|
|
92
116
|
const MAX_FFMPEG_IDENTITY_LENGTH = 256;
|
|
93
117
|
const DEBUG_AUTHORIZATION_MS = 72 * 60 * 60 * 1_000;
|
|
@@ -327,54 +351,135 @@ function boundedText(value, maximumLength) {
|
|
|
327
351
|
.trim();
|
|
328
352
|
return printable === '' ? undefined : printable;
|
|
329
353
|
}
|
|
330
|
-
function
|
|
331
|
-
return join(storageRoot, DIAGNOSTICS_DIRECTORY,
|
|
354
|
+
function fleetPath(storageRoot) {
|
|
355
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, FLEET_FILE);
|
|
332
356
|
}
|
|
333
357
|
/**
|
|
334
|
-
* Records
|
|
358
|
+
* Records what the account's devices are and what each becomes, replacing the record whole.
|
|
335
359
|
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
* cannot be reported as
|
|
339
|
-
*
|
|
340
|
-
* would cost the user their cameras.
|
|
360
|
+
* Written by the process that holds both the inventory and the accessory aliases, because an archive is
|
|
361
|
+
* assembled by another one. Rewritten on every complete inventory, so a device withdrawn or newly recognised
|
|
362
|
+
* cannot be reported as still present. A write that fails is dropped: an archive then declares no fleet, which
|
|
363
|
+
* is honest, whereas failing over a diagnostic file would cost the user their cameras.
|
|
341
364
|
*/
|
|
342
|
-
export function
|
|
365
|
+
export function recordFleet(storageRoot, fleet) {
|
|
343
366
|
try {
|
|
344
|
-
const path =
|
|
367
|
+
const path = fleetPath(storageRoot);
|
|
345
368
|
mkdirSync(dirname(path), { mode: 0o700, recursive: true });
|
|
346
|
-
writeFileSync(path, `${JSON.stringify({ version: 1,
|
|
369
|
+
writeFileSync(path, `${JSON.stringify({ version: 1, devices: fleet })}\n`, { mode: 0o600 });
|
|
347
370
|
}
|
|
348
371
|
catch { }
|
|
349
372
|
}
|
|
373
|
+
/** Reads the recorded fleet, keeping each entry whose own fields narrow and dropping each that does not. */
|
|
374
|
+
function readFleet(storageRoot) {
|
|
375
|
+
try {
|
|
376
|
+
const candidate = JSON.parse(readFileSync(fleetPath(storageRoot), 'utf8'));
|
|
377
|
+
if (candidate.version !== 1 || !Array.isArray(candidate.devices)) {
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
const devices = candidate.devices.flatMap((entry) => {
|
|
381
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
382
|
+
return [];
|
|
383
|
+
const row = entry;
|
|
384
|
+
const accessory = opaqueAccessoryAlias(row.accessory);
|
|
385
|
+
if (accessory === undefined || typeof row.represented !== 'boolean' || typeof row.attached !== 'boolean') {
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
const model = typeof row.model === 'string' ? boundedText(row.model, MAX_DEVICE_MODEL_LENGTH) : undefined;
|
|
389
|
+
const services = (Array.isArray(row.services) ? row.services : [])
|
|
390
|
+
.filter((service) => typeof service === 'string')
|
|
391
|
+
.slice(0, MAX_FLEET_SERVICES)
|
|
392
|
+
.flatMap((service) => {
|
|
393
|
+
const bounded = boundedText(service, MAX_DEVICE_MODEL_LENGTH);
|
|
394
|
+
return bounded === undefined ? [] : [bounded];
|
|
395
|
+
});
|
|
396
|
+
return [
|
|
397
|
+
{
|
|
398
|
+
accessory,
|
|
399
|
+
...(model === undefined ? {} : { model }),
|
|
400
|
+
represented: row.represented,
|
|
401
|
+
attached: row.attached,
|
|
402
|
+
services,
|
|
403
|
+
},
|
|
404
|
+
];
|
|
405
|
+
});
|
|
406
|
+
return devices.length === 0 ? undefined : devices.slice(0, MAX_ACCESSORY_ALIASES);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function hostEnvironmentPath(storageRoot) {
|
|
413
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, HOST_ENVIRONMENT_FILE);
|
|
414
|
+
}
|
|
415
|
+
/** The longest host version string retained, which bounds a value this plugin does not choose. */
|
|
416
|
+
const MAX_HOST_VERSION_LENGTH = 64;
|
|
350
417
|
/**
|
|
351
|
-
*
|
|
418
|
+
* Records what this run is hosted by, so a support archive states the host a failure came from.
|
|
352
419
|
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
420
|
+
* It is persisted rather than held in memory because the process that knows these facts is not the one that
|
|
421
|
+
* assembles an archive, and it is rewritten on every start so a host or binary that changed between runs
|
|
422
|
+
* cannot be reported as the one in use. Each call replaces the record whole, so a caller states everything it
|
|
423
|
+
* knows rather than accumulating into a file two writers would race for.
|
|
424
|
+
*
|
|
425
|
+
* A write that fails is dropped: the archive then declares the environment without these facts, which is
|
|
426
|
+
* honest, whereas failing startup over a diagnostic file would cost the user their cameras.
|
|
356
427
|
*/
|
|
357
|
-
function
|
|
428
|
+
export function recordHostEnvironment(storageRoot, host) {
|
|
358
429
|
try {
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
430
|
+
const path = hostEnvironmentPath(storageRoot);
|
|
431
|
+
mkdirSync(dirname(path), { mode: 0o700, recursive: true });
|
|
432
|
+
writeFileSync(path, `${JSON.stringify({ version: 2, ...host })}\n`, { mode: 0o600 });
|
|
433
|
+
}
|
|
434
|
+
catch { }
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Reads what this run is hosted by, keeping each fact that narrows and dropping each that does not.
|
|
438
|
+
*
|
|
439
|
+
* The facts are independent, so a malformed binary identity does not withhold a host version, and a record
|
|
440
|
+
* stating neither is no record at all.
|
|
441
|
+
*/
|
|
442
|
+
function readHostEnvironment(storageRoot) {
|
|
443
|
+
try {
|
|
444
|
+
const candidate = JSON.parse(readFileSync(hostEnvironmentPath(storageRoot), 'utf8'));
|
|
445
|
+
if (candidate.version !== 2) {
|
|
362
446
|
return undefined;
|
|
363
447
|
}
|
|
364
|
-
const
|
|
365
|
-
|
|
448
|
+
const homebridge = typeof candidate.homebridge === 'string' ? boundedText(candidate.homebridge, MAX_HOST_VERSION_LENGTH) : undefined;
|
|
449
|
+
const ffmpeg = readFfmpegIdentity(candidate.ffmpeg);
|
|
450
|
+
if (homebridge === undefined && ffmpeg === undefined) {
|
|
366
451
|
return undefined;
|
|
367
452
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
: { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
|
|
453
|
+
return {
|
|
454
|
+
...(homebridge === undefined ? {} : { homebridge }),
|
|
455
|
+
...(ffmpeg === undefined ? {} : { ffmpeg }),
|
|
456
|
+
};
|
|
373
457
|
}
|
|
374
458
|
catch {
|
|
375
459
|
return undefined;
|
|
376
460
|
}
|
|
377
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* The recorded adaptation binary, or nothing where the record's own fields do not narrow.
|
|
464
|
+
*
|
|
465
|
+
* The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
|
|
466
|
+
* entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
|
|
467
|
+
* path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
|
|
468
|
+
*/
|
|
469
|
+
function readFfmpegIdentity(value) {
|
|
470
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
471
|
+
return undefined;
|
|
472
|
+
}
|
|
473
|
+
const recorded = value;
|
|
474
|
+
if (recorded.source !== 'bundled' && recorded.source !== 'configured') {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
const path = typeof recorded.path === 'string' ? boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
|
|
478
|
+
const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
|
|
479
|
+
return path === undefined
|
|
480
|
+
? undefined
|
|
481
|
+
: { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
|
|
482
|
+
}
|
|
378
483
|
/**
|
|
379
484
|
* The one log evidence class a retained record belongs to.
|
|
380
485
|
*
|
|
@@ -651,7 +756,8 @@ export class GuidedDiagnostics {
|
|
|
651
756
|
}
|
|
652
757
|
}
|
|
653
758
|
async collectSupportEvidence(session) {
|
|
654
|
-
const
|
|
759
|
+
const host = readHostEnvironment(this.storageRoot);
|
|
760
|
+
const fleet = readFleet(this.storageRoot);
|
|
655
761
|
const evidence = [
|
|
656
762
|
...(session.affectedDevices === undefined
|
|
657
763
|
? []
|
|
@@ -672,22 +778,45 @@ export class GuidedDiagnostics {
|
|
|
672
778
|
],
|
|
673
779
|
},
|
|
674
780
|
]),
|
|
781
|
+
...(fleet === undefined
|
|
782
|
+
? []
|
|
783
|
+
: [
|
|
784
|
+
{
|
|
785
|
+
evidence: 'fleet',
|
|
786
|
+
privacyClass: 'operational',
|
|
787
|
+
contentType: 'application/json',
|
|
788
|
+
content: `${JSON.stringify({ version: 1, devices: fleet })}\n`,
|
|
789
|
+
/**
|
|
790
|
+
* What each device is and becomes, against the alias every other record about it carries. A model
|
|
791
|
+
* and an admission outcome are product facts rather than device identity; the alias is what makes
|
|
792
|
+
* an entry resolvable to a device, and only by the owner whose console paired it with a name.
|
|
793
|
+
*/
|
|
794
|
+
fields: [
|
|
795
|
+
{ field: 'version', privacyClass: 'operational' },
|
|
796
|
+
{ field: 'devices', privacyClass: 'pseudonymous' },
|
|
797
|
+
],
|
|
798
|
+
},
|
|
799
|
+
]),
|
|
675
800
|
{
|
|
676
801
|
evidence: 'environment',
|
|
677
802
|
privacyClass: 'operational',
|
|
678
803
|
contentType: 'application/json',
|
|
679
|
-
content: `${JSON.stringify({ version:
|
|
804
|
+
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`,
|
|
680
805
|
/**
|
|
681
806
|
* The record is operational and one field is classified above it: a resolved FFmpeg path is an
|
|
682
807
|
* environment fact, but it can carry the home directory of the account Homebridge runs as, so it is
|
|
683
808
|
* declared as diagnostic rather than presented alongside the host's own architecture.
|
|
809
|
+
*
|
|
810
|
+
* The Homebridge version is operational and load-bearing: this plugin requires Homebridge 2, and a
|
|
811
|
+
* host that predates it loads the plugin and then fails in ways a media fault cannot be told from.
|
|
684
812
|
*/
|
|
685
813
|
fields: [
|
|
686
814
|
...['version', 'plugin', 'sdk', 'node', 'platform', 'arch'].map((field) => ({
|
|
687
815
|
field,
|
|
688
816
|
privacyClass: 'operational',
|
|
689
817
|
})),
|
|
690
|
-
...(
|
|
818
|
+
...(host?.homebridge ? [{ field: 'homebridge', privacyClass: 'operational' }] : []),
|
|
819
|
+
...(host?.ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
|
|
691
820
|
],
|
|
692
821
|
},
|
|
693
822
|
];
|
|
@@ -1223,6 +1352,14 @@ function allowlistedLabel(value, allowed) {
|
|
|
1223
1352
|
* like any other field that fails its shape.
|
|
1224
1353
|
*/
|
|
1225
1354
|
const MAX_STARTUP_WINDOW_MS = 60_000;
|
|
1355
|
+
/** Why a level-2 wait ended without a key, which is the whole of what such an ending states. */
|
|
1356
|
+
const LEVEL2_UNAVAILABLE_REASONS = [
|
|
1357
|
+
'no-cipher-key',
|
|
1358
|
+
'derivation-failed',
|
|
1359
|
+
'not-negotiating',
|
|
1360
|
+
'session-closed',
|
|
1361
|
+
'grace-elapsed',
|
|
1362
|
+
];
|
|
1226
1363
|
/** The three phases whose only field is the action they report, which the union fixes at `start`. */
|
|
1227
1364
|
const retainedStart = (candidate) => candidate.action === 'start' ? { action: 'start' } : undefined;
|
|
1228
1365
|
/** A phase whose only field is one bounded integer, retained under the name the SDK declares for it. */
|
|
@@ -1268,7 +1405,49 @@ const LIVE_TRACE_PHASES = {
|
|
|
1268
1405
|
'sequence-restart': retainedInteger('dataType', 3),
|
|
1269
1406
|
'level2-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
|
|
1270
1407
|
'level2-ready': retainedInteger('cipherId', 65535),
|
|
1271
|
-
'
|
|
1408
|
+
'session-connect-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
|
|
1409
|
+
'session-connected': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
|
|
1410
|
+
'session-unreachable': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
|
|
1411
|
+
/**
|
|
1412
|
+
* Why a level-2 wait ended without a key, and what it had to name.
|
|
1413
|
+
*
|
|
1414
|
+
* The reason is the record: a wait that ran out states how long it waited, and one answered without waiting
|
|
1415
|
+
* states nothing more, because the negotiation is one-shot per connection. Only a reason reached under a
|
|
1416
|
+
* negotiation has a cipher to name, so both durations are optional and each is retained where it is stated.
|
|
1417
|
+
*/
|
|
1418
|
+
'level2-unavailable': (c) => {
|
|
1419
|
+
const reason = allowlistedLabel(c.reason, LEVEL2_UNAVAILABLE_REASONS);
|
|
1420
|
+
if (!reason) {
|
|
1421
|
+
return undefined;
|
|
1422
|
+
}
|
|
1423
|
+
const cipherId = boundedInteger(c.cipherId, 65535);
|
|
1424
|
+
const waitedMs = boundedInteger(c.waitedMs, MAX_STARTUP_WINDOW_MS);
|
|
1425
|
+
return {
|
|
1426
|
+
reason,
|
|
1427
|
+
...(cipherId === undefined ? {} : { cipherId }),
|
|
1428
|
+
...(waitedMs === undefined ? {} : { waitedMs }),
|
|
1429
|
+
};
|
|
1430
|
+
},
|
|
1431
|
+
/** The cipher a station asked for, beside the one whose material answered it. */
|
|
1432
|
+
'cipher-fallback': (c) => {
|
|
1433
|
+
const cipherId = boundedInteger(c.cipherId, 65535);
|
|
1434
|
+
const answeredCipherId = boundedInteger(c.answeredCipherId, 65535);
|
|
1435
|
+
return cipherId === undefined || answeredCipherId === undefined ? undefined : { cipherId, answeredCipherId };
|
|
1436
|
+
},
|
|
1437
|
+
'level2-negotiating': retainedInteger('cipherId', 65535),
|
|
1438
|
+
/**
|
|
1439
|
+
* What a call resolved a station as, which decides what its failure means.
|
|
1440
|
+
*
|
|
1441
|
+
* An attached camera's media start has no unencrypted form, so the topology is what separates a key that
|
|
1442
|
+
* never arrived from a start that had no form to be sent in. The channel is an index on a base and the
|
|
1443
|
+
* administrator is a relation to the signed-in account: neither is an identity of its own.
|
|
1444
|
+
*/
|
|
1445
|
+
'station-resolved': (c) => {
|
|
1446
|
+
const topology = allowlistedLabel(c.topology, ['attached', 'own']);
|
|
1447
|
+
const stationAdmin = allowlistedLabel(c.stationAdmin, ['self', 'other', 'unstated']);
|
|
1448
|
+
const channel = boundedInteger(c.channel, MAX_STATION_CHANNEL);
|
|
1449
|
+
return topology && stationAdmin && channel !== undefined ? { topology, channel, stationAdmin } : undefined;
|
|
1450
|
+
},
|
|
1272
1451
|
/**
|
|
1273
1452
|
* How long a connection's path has answered nothing, which is the station stating the path is gone.
|
|
1274
1453
|
*
|
|
@@ -1276,6 +1455,19 @@ const LIVE_TRACE_PHASES = {
|
|
|
1276
1455
|
* silence longer than that belongs to a session nothing is waiting on.
|
|
1277
1456
|
*/
|
|
1278
1457
|
'path-stale': retainedInteger('silentMs', MAX_STARTUP_WINDOW_MS),
|
|
1458
|
+
/**
|
|
1459
|
+
* How long a camera's own channel delivered nothing, and whether the station was asked to serve it again.
|
|
1460
|
+
*
|
|
1461
|
+
* Both halves are the record: a station re-tasked to a sibling is repaired by the re-assert, and a pull
|
|
1462
|
+
* nothing is attached to declines it so the station is left with the camera someone is watching. A picture
|
|
1463
|
+
* that stopped advancing while no such record exists stopped for a reason the station's attention does not
|
|
1464
|
+
* reach.
|
|
1465
|
+
*/
|
|
1466
|
+
'channel-silent': (c) => {
|
|
1467
|
+
const silentMs = boundedInteger(c.silentMs, MAX_STARTUP_WINDOW_MS);
|
|
1468
|
+
const outcome = allowlistedLabel(c.outcome, ['declined', 'reasserted']);
|
|
1469
|
+
return silentMs !== undefined && outcome ? { silentMs, outcome } : undefined;
|
|
1470
|
+
},
|
|
1279
1471
|
'media-command-unsent': (c) => {
|
|
1280
1472
|
const reason = allowlistedLabel(c.reason, ['level2-key', 'address']);
|
|
1281
1473
|
return reason ? { reason } : undefined;
|
|
@@ -1486,6 +1678,7 @@ const REASONS = new Set([
|
|
|
1486
1678
|
'source-input-unstable',
|
|
1487
1679
|
'source-stopped',
|
|
1488
1680
|
'source-unavailable',
|
|
1681
|
+
'station-busy',
|
|
1489
1682
|
'stored-download-failed',
|
|
1490
1683
|
'stored-failed',
|
|
1491
1684
|
'stored-invalid-image',
|
|
@@ -1566,6 +1759,13 @@ const ADAPTATION_EVENTS = new Set([
|
|
|
1566
1759
|
'exited-while-streaming',
|
|
1567
1760
|
'output',
|
|
1568
1761
|
]);
|
|
1762
|
+
/**
|
|
1763
|
+
* Why a process was started, on the record that reports it starting.
|
|
1764
|
+
*
|
|
1765
|
+
* A replacement carries the identity of the stream the previous process was already sending on, so what it
|
|
1766
|
+
* started mid-flight is judged against what that one left. A first adaptation has nothing behind it.
|
|
1767
|
+
*/
|
|
1768
|
+
const ADAPTATION_CAUSES = ['first', 'controller-selection', 'source-configuration'];
|
|
1569
1769
|
/**
|
|
1570
1770
|
* Which of those events is a failure, and so the ones a record is levelled `warn` for.
|
|
1571
1771
|
*
|
|
@@ -1832,6 +2032,10 @@ function sanitizeStructuredEvent(message) {
|
|
|
1832
2032
|
return { scope: 'media-notice', level: notice.level, code: value.code, messageKey: notice.messageKey };
|
|
1833
2033
|
}
|
|
1834
2034
|
if (value.scope === 'homekit') {
|
|
2035
|
+
if (value.event === 'station-claim') {
|
|
2036
|
+
const claim = sanitizeStationClaim(value);
|
|
2037
|
+
return claim ? { scope: 'homekit', level: 'debug', ...claim } : undefined;
|
|
2038
|
+
}
|
|
1835
2039
|
if (value.adapter === 'camera.streaming' && value.event === 'live-video-selected') {
|
|
1836
2040
|
const selection = sanitizeLiveVideoSelection(value);
|
|
1837
2041
|
return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
|
|
@@ -1980,6 +2184,36 @@ export function reportInvalidSnapshotCache(target) {
|
|
|
1980
2184
|
* `aliasFor` resolves the trace's device identity to the support-case alias the conditions about that accessory
|
|
1981
2185
|
* already carry. The identity itself never reaches the record: an unresolved one is recorded as no accessory.
|
|
1982
2186
|
*/
|
|
2187
|
+
/**
|
|
2188
|
+
* Narrows one offered station arbitration decision, or nothing where its own labels do not narrow.
|
|
2189
|
+
*
|
|
2190
|
+
* Read by both halves of the path — {@link reportStationClaim} builds the record and the file sink rebuilds
|
|
2191
|
+
* it — because a record only one of them recognises is written to a console nobody is reading and dropped
|
|
2192
|
+
* before it reaches a support archive.
|
|
2193
|
+
*/
|
|
2194
|
+
function sanitizeStationClaim(value) {
|
|
2195
|
+
const action = allowlistedLabel(value.action, STATION_CLAIM_ACTIONS);
|
|
2196
|
+
const claim = allowlistedLabel(value.claim, STATION_CLAIMS);
|
|
2197
|
+
if (!action || !claim) {
|
|
2198
|
+
return undefined;
|
|
2199
|
+
}
|
|
2200
|
+
const against = allowlistedLabel(value.displaced ?? value.to ?? value.by ?? value.against, STATION_CLAIMS);
|
|
2201
|
+
return { event: 'station-claim', action, claim, ...(against === undefined ? {} : { against }) };
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Records one arbitration decision over a station's single live channel as `homekit-log` evidence.
|
|
2205
|
+
*
|
|
2206
|
+
* A station serves one camera at a time and the SDK refuses a second outright, so a refused live request is
|
|
2207
|
+
* attributable either to this plugin's own policy or to the station, and the two are indistinguishable
|
|
2208
|
+
* afterwards without this. The record carries the claims and the decision alone: a station identity is a
|
|
2209
|
+
* serial, and the camera holding it is already named by the records about that camera.
|
|
2210
|
+
*/
|
|
2211
|
+
export function reportStationClaim(target, decision) {
|
|
2212
|
+
const claim = sanitizeStationClaim(decision);
|
|
2213
|
+
if (target.debug && claim) {
|
|
2214
|
+
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...claim }));
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
1983
2217
|
export function reportHomeKitEvent(target, trace, aliasFor) {
|
|
1984
2218
|
const alias = typeof trace.serial === 'string' ? aliasFor?.(trace.serial) : undefined;
|
|
1985
2219
|
const offered = { ...trace, accessory: alias };
|
|
@@ -2121,11 +2355,13 @@ function sanitizeAdaptationNotice(value, level) {
|
|
|
2121
2355
|
.filter((line) => line !== undefined)
|
|
2122
2356
|
.slice(-MAX_ADAPTATION_STDERR_LINES);
|
|
2123
2357
|
const sourceFragments = nonNegativeInteger(value.sourceFragments);
|
|
2358
|
+
const cause = allowlistedLabel(value.cause, ADAPTATION_CAUSES);
|
|
2124
2359
|
return {
|
|
2125
2360
|
scope: 'ffmpeg',
|
|
2126
2361
|
level,
|
|
2127
2362
|
role: value.role,
|
|
2128
2363
|
event: value.event,
|
|
2364
|
+
...(cause === undefined ? {} : { cause }),
|
|
2129
2365
|
...(code === undefined ? {} : { code }),
|
|
2130
2366
|
...(signal === undefined ? {} : { signal }),
|
|
2131
2367
|
...(sourceFragments === undefined ? {} : { sourceFragments }),
|
|
@@ -2140,7 +2376,19 @@ function sanitizeLiveSessionTrace(value) {
|
|
|
2140
2376
|
const accessory = alias === undefined ? {} : { accessory: alias };
|
|
2141
2377
|
if (value.event === 'live-session-released') {
|
|
2142
2378
|
const release = allowlistedLabel(value.release, LIVE_SESSION_RELEASES);
|
|
2143
|
-
|
|
2379
|
+
if (!release) {
|
|
2380
|
+
return undefined;
|
|
2381
|
+
}
|
|
2382
|
+
const reports = nonNegativeInteger(value.reports);
|
|
2383
|
+
const sinceLastReportMs = boundedInteger(value.sinceLastReportMs, MAX_STARTUP_WINDOW_MS);
|
|
2384
|
+
return {
|
|
2385
|
+
adapter: value.adapter,
|
|
2386
|
+
event: value.event,
|
|
2387
|
+
release,
|
|
2388
|
+
...(reports === undefined ? {} : { reports }),
|
|
2389
|
+
...(sinceLastReportMs === undefined ? {} : { sinceLastReportMs }),
|
|
2390
|
+
...accessory,
|
|
2391
|
+
};
|
|
2144
2392
|
}
|
|
2145
2393
|
if (value.event === 'live-session-streaming') {
|
|
2146
2394
|
return { adapter: value.adapter, event: value.event, ...accessory };
|
|
@@ -2163,11 +2411,13 @@ function sanitizeLiveSessionTrace(value) {
|
|
|
2163
2411
|
!HOMEKIT_LIVE_SESSION_STAGES.has(value.stage)) {
|
|
2164
2412
|
return undefined;
|
|
2165
2413
|
}
|
|
2414
|
+
const servingChannel = boundedInteger(value.servingChannel, MAX_STATION_CHANNEL);
|
|
2166
2415
|
return {
|
|
2167
2416
|
adapter: value.adapter,
|
|
2168
2417
|
event: value.event,
|
|
2169
2418
|
outcome: value.outcome,
|
|
2170
2419
|
reason: value.reason,
|
|
2420
|
+
...(servingChannel === undefined ? {} : { servingChannel }),
|
|
2171
2421
|
stage: value.stage,
|
|
2172
2422
|
...accessory,
|
|
2173
2423
|
};
|