@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.3 → 5.0.0-beta.30
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/config.schema.json +48 -0
- package/dist/account/temporary-authentication.js +3 -1
- package/dist/account/temporary-authentication.js.map +1 -1
- package/dist/configuration.js +44 -2
- package/dist/configuration.js.map +1 -1
- package/dist/device/member-evidence.js +10 -3
- package/dist/device/member-evidence.js.map +1 -1
- package/dist/device/snapshot.js +1 -1
- package/dist/device/snapshot.js.map +1 -1
- package/dist/diagnostics.js +694 -83
- package/dist/diagnostics.js.map +1 -1
- package/dist/homekit/adapters/camera-streaming.js +56 -27
- package/dist/homekit/adapters/camera-streaming.js.map +1 -1
- package/dist/homekit/adapters/lock.js +45 -16
- package/dist/homekit/adapters/lock.js.map +1 -1
- package/dist/homekit/adapters/motion.js +1 -1
- package/dist/homekit/adapters/motion.js.map +1 -1
- package/dist/homekit/adapters/package-sensor.js +63 -0
- package/dist/homekit/adapters/package-sensor.js.map +1 -0
- package/dist/homekit/adapters/registry.js +2 -0
- package/dist/homekit/adapters/registry.js.map +1 -1
- package/dist/homekit/adapters/security-system.js +101 -45
- package/dist/homekit/adapters/security-system.js.map +1 -1
- package/dist/homekit/coverage-matrix.js +1 -3
- package/dist/homekit/coverage-matrix.js.map +1 -1
- package/dist/homekit/reconciler.js +21 -5
- package/dist/homekit/reconciler.js.map +1 -1
- package/dist/media/last-successful-image.js +5 -3
- package/dist/media/last-successful-image.js.map +1 -1
- package/dist/media/live-stream.js +200 -84
- package/dist/media/live-stream.js.map +1 -1
- package/dist/media/recording.js +11 -5
- package/dist/media/recording.js.map +1 -1
- package/dist/media/snapshot.js +21 -8
- package/dist/media/snapshot.js.map +1 -1
- package/dist/media/station-live-sessions.js +47 -43
- package/dist/media/station-live-sessions.js.map +1 -1
- package/dist/platform.js +27 -7
- package/dist/platform.js.map +1 -1
- package/dist/runtime/channel.js +116 -4
- package/dist/runtime/channel.js.map +1 -1
- package/dist/runtime/device-settings.js +88 -0
- package/dist/runtime/device-settings.js.map +1 -0
- package/dist/runtime/owner.js +12 -0
- package/dist/runtime/owner.js.map +1 -1
- package/dist/runtime/sdk-client.js +19 -4
- package/dist/runtime/sdk-client.js.map +1 -1
- package/dist/ui/dashboard.js +32 -14
- package/dist/ui/dashboard.js.map +1 -1
- package/dist/ui/runtime-channel-client.js +64 -1
- package/dist/ui/runtime-channel-client.js.map +1 -1
- package/dist/ui/server.js +64 -5
- package/dist/ui/server.js.map +1 -1
- package/homebridge-ui/public/app.css +359 -88
- package/homebridge-ui/public/assets/icons/battery_0.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_1.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_2.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_3.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_4.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_5.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_6.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_full.svg +1 -0
- package/homebridge-ui/public/assets/icons/contact_support.svg +1 -0
- package/homebridge-ui/public/assets/icons/delete.svg +1 -0
- package/homebridge-ui/public/assets/icons/download.svg +1 -0
- package/homebridge-ui/public/assets/icons/extension_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/info.svg +1 -0
- package/homebridge-ui/public/assets/icons/key_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_arrow_left.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_arrow_right.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_double_arrow_left.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_double_arrow_right.svg +1 -0
- package/homebridge-ui/public/assets/icons/movie_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/refresh.svg +1 -0
- package/homebridge-ui/public/assets/icons/satellite_alt.svg +1 -0
- package/homebridge-ui/public/assets/icons/search_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/sensors_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/settings_photo_camera.svg +1 -0
- package/homebridge-ui/public/assets/icons/solar_power.svg +1 -0
- package/homebridge-ui/public/assets/icons/videocam_off.svg +1 -0
- package/homebridge-ui/public/i18n/en.json +119 -78
- package/homebridge-ui/public/i18n/fr.json +119 -78
- package/homebridge-ui/public/index.html +115 -106
- package/homebridge-ui/public/js/app.js +350 -358
- package/homebridge-ui/public/js/dashboard.js +139 -10
- package/homebridge-ui/public/js/profile-wizard.js +17 -54
- package/i18n/runtime/en.json +8 -3
- package/package.json +9 -6
package/dist/diagnostics.js
CHANGED
|
@@ -33,6 +33,47 @@ 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
|
+
/**
|
|
39
|
+
* The SDK event kinds a HomeKit adapter consumes, which are the kinds an unrouted-event record may name.
|
|
40
|
+
*
|
|
41
|
+
* Checked against the SDK's event union at build time, so a kind the SDK renames or removes fails the build.
|
|
42
|
+
*/
|
|
43
|
+
const UNROUTED_EVENT_KINDS = [
|
|
44
|
+
'alarm',
|
|
45
|
+
'armingModeChanged',
|
|
46
|
+
'batteryAlert',
|
|
47
|
+
'cameraEnabledChanged',
|
|
48
|
+
'contactState',
|
|
49
|
+
'cryingDetected',
|
|
50
|
+
'dogDetected',
|
|
51
|
+
'doorbellPress',
|
|
52
|
+
'lockState',
|
|
53
|
+
'motion',
|
|
54
|
+
'packageDelivered',
|
|
55
|
+
'packageStranded',
|
|
56
|
+
'packageTaken',
|
|
57
|
+
'personDetected',
|
|
58
|
+
'petDetection',
|
|
59
|
+
'smartLightState',
|
|
60
|
+
'soundDetected',
|
|
61
|
+
'strangerDetected',
|
|
62
|
+
'vehicleDetected',
|
|
63
|
+
];
|
|
64
|
+
/** What an unrouted event's serial named: none, another device's station, or a serial no adapter holds. */
|
|
65
|
+
const UNROUTED_EVENT_TARGETS = ['none', 'station', 'unattached'];
|
|
66
|
+
/** The claims that contend for a station's one live channel, and the decisions taken over it. */
|
|
67
|
+
const STATION_CLAIMS = ['live', 'recording', 'snapshot'];
|
|
68
|
+
const STATION_CLAIM_ACTIONS = ['held', 'yielded', 'refused', 'released'];
|
|
69
|
+
/**
|
|
70
|
+
* The widest camera index a station may name as the one it is serving.
|
|
71
|
+
*
|
|
72
|
+
* A base fans a bounded number of cameras over one session, and an index beyond that is a value this plugin
|
|
73
|
+
* did not expect rather than a channel. The index carries no identity: which camera holds it is stated by the
|
|
74
|
+
* records about that camera.
|
|
75
|
+
*/
|
|
76
|
+
const MAX_STATION_CHANNEL = 63;
|
|
36
77
|
/** The bounded reasons a stream request is refused before it reaches the source. */
|
|
37
78
|
const REFUSAL_REASONS = new Set(['disabled', 'at-capacity', 'cancelled', 'prepare-failed']);
|
|
38
79
|
/**
|
|
@@ -47,6 +88,27 @@ const HOMEKIT_LIVE_REQUEST_EVENTS = new Set([
|
|
|
47
88
|
'live-session-released',
|
|
48
89
|
'live-session-streaming',
|
|
49
90
|
]);
|
|
91
|
+
/**
|
|
92
|
+
* The error identities an SDK record may name, which is all a record carries of one.
|
|
93
|
+
*
|
|
94
|
+
* A message is never among them, so an identity absent here is recorded as `Error` and reads as any other
|
|
95
|
+
* failure.
|
|
96
|
+
*/
|
|
97
|
+
const SDK_ERROR_TYPES = [
|
|
98
|
+
'DeviceChannelUnresolvedError',
|
|
99
|
+
'Error',
|
|
100
|
+
'RangeError',
|
|
101
|
+
'SessionExpiredError',
|
|
102
|
+
'StationKeyUnavailableError',
|
|
103
|
+
'StationUnreachableError',
|
|
104
|
+
'TypeError',
|
|
105
|
+
];
|
|
106
|
+
/**
|
|
107
|
+
* The retained fields that stand for a device rather than describing one, which is the privacy class a manifest
|
|
108
|
+
* declares for them. Each holds a per-case alias: stable within one archive, and resolvable to a device only by
|
|
109
|
+
* the owner whose console paired it with a name.
|
|
110
|
+
*/
|
|
111
|
+
const PSEUDONYMOUS_FIELDS = new Set(['accessory', 'accessoryAliases']);
|
|
50
112
|
const MAX_SDK_DETAILS = 16;
|
|
51
113
|
const MAX_LOG_RECORD_BYTES = 64 * 1024;
|
|
52
114
|
const MAX_CURRENT_LOG_BYTES = 50 * 1024 * 1024;
|
|
@@ -71,7 +133,12 @@ const DIAGNOSTICS_DIRECTORY = 'diagnostics';
|
|
|
71
133
|
const GUIDED_SESSION_FILE = 'session.json';
|
|
72
134
|
const REPRODUCTION_MARKERS_FILE = 'reproduction-markers.jsonl';
|
|
73
135
|
const UI_EVENTS_FILE = 'ui-events.jsonl';
|
|
74
|
-
const
|
|
136
|
+
const HOST_ENVIRONMENT_FILE = 'host.json';
|
|
137
|
+
const FLEET_FILE = 'fleet.json';
|
|
138
|
+
/** The longest device model or adapter key retained, which bounds values this plugin does not choose alone. */
|
|
139
|
+
const MAX_DEVICE_MODEL_LENGTH = 64;
|
|
140
|
+
/** How many HomeKit services one fleet entry names, which is above what any single device is represented by. */
|
|
141
|
+
const MAX_FLEET_SERVICES = 12;
|
|
75
142
|
/** How much of an FFmpeg path or version banner is kept, which is more than either needs. */
|
|
76
143
|
const MAX_FFMPEG_IDENTITY_LENGTH = 256;
|
|
77
144
|
const DEBUG_AUTHORIZATION_MS = 72 * 60 * 60 * 1_000;
|
|
@@ -145,6 +212,7 @@ const SDK_EVENT_KEYS = new Set([
|
|
|
145
212
|
'observation-invalid',
|
|
146
213
|
'protocol-command',
|
|
147
214
|
'protocol-unhandled',
|
|
215
|
+
'realtime-readiness',
|
|
148
216
|
'session-connecting',
|
|
149
217
|
'session-idle',
|
|
150
218
|
'session-resumed',
|
|
@@ -153,6 +221,15 @@ const SDK_EVENT_KEYS = new Set([
|
|
|
153
221
|
'transport-error',
|
|
154
222
|
]);
|
|
155
223
|
const EN_MESSAGES = JSON.parse(readFileSync(new URL('../i18n/runtime/en.json', import.meta.url), 'utf8'));
|
|
224
|
+
/**
|
|
225
|
+
* The reporter's answer as a support archive carries it: every device, or how many they named.
|
|
226
|
+
*
|
|
227
|
+
* The serials themselves stay on the host. They identify one household's hardware, and no evidence class in an
|
|
228
|
+
* archive carries a device identity.
|
|
229
|
+
*/
|
|
230
|
+
function countedAffectedDevices(affected) {
|
|
231
|
+
return affected === 'all' ? 'all' : new Set(affected).size;
|
|
232
|
+
}
|
|
156
233
|
/** The bounded UI events a support session may record, and the only vocabulary the sink accepts. */
|
|
157
234
|
const DIAGNOSTICS_UI_EVENTS = [
|
|
158
235
|
'background-started',
|
|
@@ -183,9 +260,58 @@ const SUPPORT_ARCHIVE_EXCLUDED_CLASSES = [
|
|
|
183
260
|
'tokens-cookies-and-authorization',
|
|
184
261
|
'session-and-push-stores',
|
|
185
262
|
'private-and-symmetric-keys',
|
|
186
|
-
'unconstrained-
|
|
263
|
+
'unconstrained-internal-objects',
|
|
187
264
|
'camera-images-talkback-and-raw-media',
|
|
188
265
|
];
|
|
266
|
+
/**
|
|
267
|
+
* The bug form's `area` option each diagnostics profile belongs to.
|
|
268
|
+
*
|
|
269
|
+
* A dropdown prefill is honoured only on an exact match with a declared option, so these strings are the
|
|
270
|
+
* form's own. Two profiles share `HomeKit Integration`, which is one area to triage.
|
|
271
|
+
*/
|
|
272
|
+
const BUG_REPORT_AREAS = {
|
|
273
|
+
'startup-authentication': 'Login / Authentication',
|
|
274
|
+
'device-representation': 'HomeKit Integration',
|
|
275
|
+
'control-state': 'Device controls',
|
|
276
|
+
'live-media': 'Livestream / Streaming',
|
|
277
|
+
'hksv-recording': 'Recording',
|
|
278
|
+
'dashboard-ui': 'Plugin UI / Dashboard',
|
|
279
|
+
other: 'Other',
|
|
280
|
+
};
|
|
281
|
+
/** Where a report goes, and the form whose field ids the query string addresses. */
|
|
282
|
+
const BUG_REPORT_FORM = 'https://github.com/homebridge-plugins/homebridge-eufy/issues/new';
|
|
283
|
+
/**
|
|
284
|
+
* A prepared bug report for a finished support session.
|
|
285
|
+
*
|
|
286
|
+
* Addresses the committed issue form by filename, and fills the field ids that form declares. A prefill is
|
|
287
|
+
* honoured only against a named template, and only the operational class travels in a query string.
|
|
288
|
+
*
|
|
289
|
+
* Every field this process can answer is answered, because a blank one is charged to the reporter: the form's
|
|
290
|
+
* own triage asks for what the environment block leaves empty, so a line left for the reporter to complete
|
|
291
|
+
* turns a report made the intended way into an incomplete one. The host version is the host's own public
|
|
292
|
+
* version and empty where no run has recorded it yet. The support case id is not among them — it sits above
|
|
293
|
+
* the operational class, and the archive's own filename carries it to the report that attaches one.
|
|
294
|
+
*
|
|
295
|
+
* The archive field is left unaddressed, because a query string cannot carry a file. Any text placed there
|
|
296
|
+
* satisfies the field's own `required` validation, so a prefill would answer it on the reporter's behalf and
|
|
297
|
+
* the form would accept a report with no archive on it.
|
|
298
|
+
*/
|
|
299
|
+
function bugReportUrl(session, missingEvidence, host) {
|
|
300
|
+
const url = new URL(BUG_REPORT_FORM);
|
|
301
|
+
url.searchParams.set('template', 'bug_report.yml');
|
|
302
|
+
url.searchParams.set('title', `[Diagnostics] ${BUG_REPORT_AREAS[session.profile]}: `);
|
|
303
|
+
url.searchParams.set('environment', [
|
|
304
|
+
`- **Plugin Version**: ${PLUGIN_VERSION}`,
|
|
305
|
+
`- **eufy SDK**: ${SDK_VERSION}`,
|
|
306
|
+
`- **Node.js Version**: ${process.version}`,
|
|
307
|
+
`- **Homebridge Version**: ${host?.homebridge ?? ''}`,
|
|
308
|
+
`- **OS**: ${process.platform} ${process.arch}`,
|
|
309
|
+
`- **Diagnostics profile**: ${session.profile}`,
|
|
310
|
+
`- **Missing evidence**: ${missingEvidence.length ? missingEvidence.join(', ') : 'none'}`,
|
|
311
|
+
].join('\n'));
|
|
312
|
+
url.searchParams.set('area', BUG_REPORT_AREAS[session.profile]);
|
|
313
|
+
return url.toString();
|
|
314
|
+
}
|
|
189
315
|
const DIAGNOSTICS_PROFILES = {
|
|
190
316
|
'startup-authentication': ['plugin-log', 'sdk-log'],
|
|
191
317
|
'device-representation': ['plugin-log', 'sdk-log', 'homekit-log'],
|
|
@@ -193,7 +319,7 @@ const DIAGNOSTICS_PROFILES = {
|
|
|
193
319
|
'live-media': ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
|
|
194
320
|
'hksv-recording': ['plugin-log', 'sdk-log', 'ffmpeg-log'],
|
|
195
321
|
'dashboard-ui': ['plugin-log', 'ui-log'],
|
|
196
|
-
other: ['plugin-log', 'sdk-log', 'homekit-log'],
|
|
322
|
+
other: ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
|
|
197
323
|
};
|
|
198
324
|
/** Narrows external profile input against the diagnostics-owned profile registry. */
|
|
199
325
|
export function isDiagnosticsProfile(value) {
|
|
@@ -243,6 +369,22 @@ function activeDiagnosticsSession(storageRoot, now) {
|
|
|
243
369
|
const session = readDiagnosticsSession(storageRoot);
|
|
244
370
|
return session && Date.parse(session.expiresAt) > now ? session : undefined;
|
|
245
371
|
}
|
|
372
|
+
/**
|
|
373
|
+
* A session as it stands at `now`: a capture still running when its authorization ends is finished at that end.
|
|
374
|
+
*
|
|
375
|
+
* A finished capture can be reviewed and exported until the archive retention has elapsed past the end of its
|
|
376
|
+
* authorization, so a capture that ran out its 72 hours keeps what it captured.
|
|
377
|
+
*/
|
|
378
|
+
function settledDiagnosticsSession(session, now) {
|
|
379
|
+
const expiresAt = Date.parse(session.expiresAt);
|
|
380
|
+
const settled = session.reproductionStartedAt && !session.reproductionEndedAt && expiresAt <= now
|
|
381
|
+
? { ...session, reproductionEndedAt: session.expiresAt }
|
|
382
|
+
: session;
|
|
383
|
+
return {
|
|
384
|
+
session: settled,
|
|
385
|
+
exportable: Boolean(settled.reproductionEndedAt) && now < expiresAt + SUPPORT_ARCHIVE_RETENTION_MS,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
246
388
|
/**
|
|
247
389
|
* Collapses one supplied string to bounded printable text, or nothing where none of it survives.
|
|
248
390
|
*
|
|
@@ -259,54 +401,135 @@ function boundedText(value, maximumLength) {
|
|
|
259
401
|
.trim();
|
|
260
402
|
return printable === '' ? undefined : printable;
|
|
261
403
|
}
|
|
262
|
-
function
|
|
263
|
-
return join(storageRoot, DIAGNOSTICS_DIRECTORY,
|
|
404
|
+
function fleetPath(storageRoot) {
|
|
405
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, FLEET_FILE);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Records what the account's devices are and what each becomes, replacing the record whole.
|
|
409
|
+
*
|
|
410
|
+
* Written by the process that holds both the inventory and the accessory aliases, because an archive is
|
|
411
|
+
* assembled by another one. Rewritten on every complete inventory, so a device withdrawn or newly recognised
|
|
412
|
+
* cannot be reported as still present. A write that fails is dropped: an archive then declares no fleet, which
|
|
413
|
+
* is honest, whereas failing over a diagnostic file would cost the user their cameras.
|
|
414
|
+
*/
|
|
415
|
+
export function recordFleet(storageRoot, fleet) {
|
|
416
|
+
try {
|
|
417
|
+
const path = fleetPath(storageRoot);
|
|
418
|
+
mkdirSync(dirname(path), { mode: 0o700, recursive: true });
|
|
419
|
+
writeFileSync(path, `${JSON.stringify({ version: 1, devices: fleet })}\n`, { mode: 0o600 });
|
|
420
|
+
}
|
|
421
|
+
catch { }
|
|
422
|
+
}
|
|
423
|
+
/** Reads the recorded fleet, keeping each entry whose own fields narrow and dropping each that does not. */
|
|
424
|
+
function readFleet(storageRoot) {
|
|
425
|
+
try {
|
|
426
|
+
const candidate = JSON.parse(readFileSync(fleetPath(storageRoot), 'utf8'));
|
|
427
|
+
if (candidate.version !== 1 || !Array.isArray(candidate.devices)) {
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
const devices = candidate.devices.flatMap((entry) => {
|
|
431
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
432
|
+
return [];
|
|
433
|
+
const row = entry;
|
|
434
|
+
const accessory = opaqueAccessoryAlias(row.accessory);
|
|
435
|
+
if (accessory === undefined || typeof row.represented !== 'boolean' || typeof row.attached !== 'boolean') {
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
const model = typeof row.model === 'string' ? boundedText(row.model, MAX_DEVICE_MODEL_LENGTH) : undefined;
|
|
439
|
+
const services = (Array.isArray(row.services) ? row.services : [])
|
|
440
|
+
.filter((service) => typeof service === 'string')
|
|
441
|
+
.slice(0, MAX_FLEET_SERVICES)
|
|
442
|
+
.flatMap((service) => {
|
|
443
|
+
const bounded = boundedText(service, MAX_DEVICE_MODEL_LENGTH);
|
|
444
|
+
return bounded === undefined ? [] : [bounded];
|
|
445
|
+
});
|
|
446
|
+
return [
|
|
447
|
+
{
|
|
448
|
+
accessory,
|
|
449
|
+
...(model === undefined ? {} : { model }),
|
|
450
|
+
represented: row.represented,
|
|
451
|
+
attached: row.attached,
|
|
452
|
+
services,
|
|
453
|
+
},
|
|
454
|
+
];
|
|
455
|
+
});
|
|
456
|
+
return devices.length === 0 ? undefined : devices.slice(0, MAX_ACCESSORY_ALIASES);
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function hostEnvironmentPath(storageRoot) {
|
|
463
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, HOST_ENVIRONMENT_FILE);
|
|
264
464
|
}
|
|
465
|
+
/** The longest host version string retained, which bounds a value this plugin does not choose. */
|
|
466
|
+
const MAX_HOST_VERSION_LENGTH = 64;
|
|
265
467
|
/**
|
|
266
|
-
* Records
|
|
468
|
+
* Records what this run is hosted by, so a support archive states the host a failure came from.
|
|
267
469
|
*
|
|
268
|
-
* It is persisted rather than held in memory because the process that
|
|
269
|
-
* assembles an archive, and it is rewritten on every start so a
|
|
270
|
-
* cannot be reported as the one in use.
|
|
271
|
-
*
|
|
272
|
-
*
|
|
470
|
+
* It is persisted rather than held in memory because the process that knows these facts is not the one that
|
|
471
|
+
* assembles an archive, and it is rewritten on every start so a host or binary that changed between runs
|
|
472
|
+
* cannot be reported as the one in use. Each call replaces the record whole, so a caller states everything it
|
|
473
|
+
* knows rather than accumulating into a file two writers would race for.
|
|
474
|
+
*
|
|
475
|
+
* A write that fails is dropped: the archive then declares the environment without these facts, which is
|
|
476
|
+
* honest, whereas failing startup over a diagnostic file would cost the user their cameras.
|
|
273
477
|
*/
|
|
274
|
-
export function
|
|
478
|
+
export function recordHostEnvironment(storageRoot, host) {
|
|
275
479
|
try {
|
|
276
|
-
const path =
|
|
480
|
+
const path = hostEnvironmentPath(storageRoot);
|
|
277
481
|
mkdirSync(dirname(path), { mode: 0o700, recursive: true });
|
|
278
|
-
writeFileSync(path, `${JSON.stringify({ version:
|
|
482
|
+
writeFileSync(path, `${JSON.stringify({ version: 2, ...host })}\n`, { mode: 0o600 });
|
|
279
483
|
}
|
|
280
484
|
catch { }
|
|
281
485
|
}
|
|
282
486
|
/**
|
|
283
|
-
* Reads
|
|
487
|
+
* Reads what this run is hosted by, keeping each fact that narrows and dropping each that does not.
|
|
284
488
|
*
|
|
285
|
-
* The
|
|
286
|
-
*
|
|
287
|
-
* path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
|
|
489
|
+
* The facts are independent, so a malformed binary identity does not withhold a host version, and a record
|
|
490
|
+
* stating neither is no record at all.
|
|
288
491
|
*/
|
|
289
|
-
function
|
|
492
|
+
function readHostEnvironment(storageRoot) {
|
|
290
493
|
try {
|
|
291
|
-
const candidate = JSON.parse(readFileSync(
|
|
292
|
-
|
|
293
|
-
if (candidate.version !== 1 || !ffmpeg || typeof ffmpeg !== 'object' || Array.isArray(ffmpeg)) {
|
|
494
|
+
const candidate = JSON.parse(readFileSync(hostEnvironmentPath(storageRoot), 'utf8'));
|
|
495
|
+
if (candidate.version !== 2) {
|
|
294
496
|
return undefined;
|
|
295
497
|
}
|
|
296
|
-
const
|
|
297
|
-
|
|
498
|
+
const homebridge = typeof candidate.homebridge === 'string' ? boundedText(candidate.homebridge, MAX_HOST_VERSION_LENGTH) : undefined;
|
|
499
|
+
const ffmpeg = readFfmpegIdentity(candidate.ffmpeg);
|
|
500
|
+
if (homebridge === undefined && ffmpeg === undefined) {
|
|
298
501
|
return undefined;
|
|
299
502
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
: { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
|
|
503
|
+
return {
|
|
504
|
+
...(homebridge === undefined ? {} : { homebridge }),
|
|
505
|
+
...(ffmpeg === undefined ? {} : { ffmpeg }),
|
|
506
|
+
};
|
|
305
507
|
}
|
|
306
508
|
catch {
|
|
307
509
|
return undefined;
|
|
308
510
|
}
|
|
309
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* The recorded adaptation binary, or nothing where the record's own fields do not narrow.
|
|
514
|
+
*
|
|
515
|
+
* The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
|
|
516
|
+
* entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
|
|
517
|
+
* path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
|
|
518
|
+
*/
|
|
519
|
+
function readFfmpegIdentity(value) {
|
|
520
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
const recorded = value;
|
|
524
|
+
if (recorded.source !== 'bundled' && recorded.source !== 'configured') {
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
const path = typeof recorded.path === 'string' ? boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
|
|
528
|
+
const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
|
|
529
|
+
return path === undefined
|
|
530
|
+
? undefined
|
|
531
|
+
: { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
|
|
532
|
+
}
|
|
310
533
|
/**
|
|
311
534
|
* The one log evidence class a retained record belongs to.
|
|
312
535
|
*
|
|
@@ -348,13 +571,16 @@ export class GuidedDiagnostics {
|
|
|
348
571
|
this.now = now;
|
|
349
572
|
this.supportArchiveKey = supportArchiveKey;
|
|
350
573
|
}
|
|
351
|
-
async authorize(profile, reproductionMode) {
|
|
574
|
+
async authorize(profile, reproductionMode, affectedDevices) {
|
|
352
575
|
if (!isDiagnosticsProfile(profile)) {
|
|
353
576
|
throw new Error('Unknown diagnostics profile');
|
|
354
577
|
}
|
|
355
578
|
if (!isDiagnosticsReproductionMode(reproductionMode)) {
|
|
356
579
|
throw new Error('Unknown diagnostics reproduction mode');
|
|
357
580
|
}
|
|
581
|
+
if (affectedDevices !== undefined && affectedDevices !== 'all' && !Array.isArray(affectedDevices)) {
|
|
582
|
+
throw new Error('Affected devices must be "all" or a list of serials');
|
|
583
|
+
}
|
|
358
584
|
this.uiEventsClosing = true;
|
|
359
585
|
try {
|
|
360
586
|
await this.uiEventWrites;
|
|
@@ -366,6 +592,7 @@ export class GuidedDiagnostics {
|
|
|
366
592
|
reproductionMode,
|
|
367
593
|
authorizedAt: new Date(authorizedAt).toISOString(),
|
|
368
594
|
expiresAt: new Date(authorizedAt + DEBUG_AUTHORIZATION_MS).toISOString(),
|
|
595
|
+
...(affectedDevices === undefined ? {} : { affectedDevices }),
|
|
369
596
|
};
|
|
370
597
|
await this.writeSession(session);
|
|
371
598
|
this.pendingSupportArchive = undefined;
|
|
@@ -396,6 +623,22 @@ export class GuidedDiagnostics {
|
|
|
396
623
|
await this.ensureMarker(session, 'reproduction-started', session.reproductionStartedAt);
|
|
397
624
|
return this.project(session);
|
|
398
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* Deletes the persisted session without an archive, which also ends the verbose retention it authorized. UI events
|
|
628
|
+
* already queued are written first and later ones are refused until it is gone, so none lands after the delete.
|
|
629
|
+
*/
|
|
630
|
+
async cancel() {
|
|
631
|
+
this.uiEventsClosing = true;
|
|
632
|
+
try {
|
|
633
|
+
await this.uiEventWrites;
|
|
634
|
+
this.pendingSupportArchive = undefined;
|
|
635
|
+
await rm(diagnosticsSessionPath(this.storageRoot), { force: true });
|
|
636
|
+
return await this.status();
|
|
637
|
+
}
|
|
638
|
+
finally {
|
|
639
|
+
this.uiEventsClosing = false;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
399
642
|
async endReproduction() {
|
|
400
643
|
let session = this.requireAuthorized();
|
|
401
644
|
if (!session.reproductionStartedAt) {
|
|
@@ -476,10 +719,16 @@ export class GuidedDiagnostics {
|
|
|
476
719
|
/** Prepares the exact manifest and evidence snapshot that one subsequent export may encrypt. */
|
|
477
720
|
async reviewSupportArchive() {
|
|
478
721
|
await this.uiEventWrites;
|
|
479
|
-
const
|
|
722
|
+
const persisted = readDiagnosticsSession(this.storageRoot);
|
|
723
|
+
const { session, exportable } = persisted
|
|
724
|
+
? settledDiagnosticsSession(persisted, this.now())
|
|
725
|
+
: { session: undefined, exportable: false };
|
|
480
726
|
if (!session?.reproductionStartedAt || !session.reproductionEndedAt) {
|
|
481
727
|
throw new Error('A completed reproduction is required before archive review');
|
|
482
728
|
}
|
|
729
|
+
if (!exportable) {
|
|
730
|
+
throw new Error('Diagnostics authorization is inactive or expired');
|
|
731
|
+
}
|
|
483
732
|
const collected = await this.collectSupportEvidence(session);
|
|
484
733
|
const reproductionStartedAt = Date.parse(session.reproductionStartedAt);
|
|
485
734
|
const selected = DIAGNOSTICS_PROFILES[session.profile];
|
|
@@ -522,7 +771,13 @@ export class GuidedDiagnostics {
|
|
|
522
771
|
this.pendingSupportArchive = { reviewId, manifest, evidence: collected };
|
|
523
772
|
return { reviewId, manifest };
|
|
524
773
|
}
|
|
525
|
-
/**
|
|
774
|
+
/**
|
|
775
|
+
* Consumes one reviewed snapshot and returns an encrypted envelope without writing plaintext or an archive to disk.
|
|
776
|
+
*
|
|
777
|
+
* The session outlives the export, because nothing here proves the envelope reached the reporter: a fresh review
|
|
778
|
+
* exports it again until the reporter leaves it with {@link cancel}, another capture replaces it, or the archive
|
|
779
|
+
* retention ends it.
|
|
780
|
+
*/
|
|
526
781
|
async exportSupportArchive(reviewId) {
|
|
527
782
|
const pending = this.pendingSupportArchive;
|
|
528
783
|
if (!pending || pending.reviewId !== reviewId) {
|
|
@@ -568,35 +823,79 @@ export class GuidedDiagnostics {
|
|
|
568
823
|
authTag: cipher.getAuthTag().toString('base64'),
|
|
569
824
|
ciphertext: ciphertext.toString('base64'),
|
|
570
825
|
};
|
|
571
|
-
|
|
826
|
+
const exported = {
|
|
572
827
|
filename: `homebridge-eufy-${pending.manifest.supportCaseId}.eufysupport.gz`,
|
|
573
828
|
mediaType: 'application/gzip',
|
|
574
829
|
archive: await gzip(Buffer.from(`${JSON.stringify(envelope)}\n`, 'utf8')),
|
|
575
830
|
};
|
|
831
|
+
return exported;
|
|
576
832
|
}
|
|
577
833
|
finally {
|
|
578
834
|
contentKey.fill(0);
|
|
579
835
|
}
|
|
580
836
|
}
|
|
581
837
|
async collectSupportEvidence(session) {
|
|
582
|
-
const
|
|
838
|
+
const host = readHostEnvironment(this.storageRoot);
|
|
839
|
+
const fleet = readFleet(this.storageRoot);
|
|
583
840
|
const evidence = [
|
|
841
|
+
...(session.affectedDevices === undefined
|
|
842
|
+
? []
|
|
843
|
+
: [
|
|
844
|
+
{
|
|
845
|
+
evidence: 'reporter-statement',
|
|
846
|
+
privacyClass: 'diagnostic',
|
|
847
|
+
contentType: 'application/json',
|
|
848
|
+
content: `${JSON.stringify({ version: 2, affectedDevices: countedAffectedDevices(session.affectedDevices) })}\n`,
|
|
849
|
+
/**
|
|
850
|
+
* What the reporter answered, not what the plugin observed. A serial is a device identity and no
|
|
851
|
+
* evidence class carries one, so the answer travels as its scope: every device, or how many were
|
|
852
|
+
* named. Which ones is the reporter's to say in their own words.
|
|
853
|
+
*/
|
|
854
|
+
fields: [
|
|
855
|
+
{ field: 'version', privacyClass: 'operational' },
|
|
856
|
+
{ field: 'affectedDevices', privacyClass: 'operational' },
|
|
857
|
+
],
|
|
858
|
+
},
|
|
859
|
+
]),
|
|
860
|
+
...(fleet === undefined
|
|
861
|
+
? []
|
|
862
|
+
: [
|
|
863
|
+
{
|
|
864
|
+
evidence: 'fleet',
|
|
865
|
+
privacyClass: 'operational',
|
|
866
|
+
contentType: 'application/json',
|
|
867
|
+
content: `${JSON.stringify({ version: 1, devices: fleet })}\n`,
|
|
868
|
+
/**
|
|
869
|
+
* What each device is and becomes, against the alias every other record about it carries. A model
|
|
870
|
+
* and an admission outcome are product facts rather than device identity; the alias is what makes
|
|
871
|
+
* an entry resolvable to a device, and only by the owner whose console paired it with a name.
|
|
872
|
+
*/
|
|
873
|
+
fields: [
|
|
874
|
+
{ field: 'version', privacyClass: 'operational' },
|
|
875
|
+
{ field: 'devices', privacyClass: 'pseudonymous' },
|
|
876
|
+
],
|
|
877
|
+
},
|
|
878
|
+
]),
|
|
584
879
|
{
|
|
585
880
|
evidence: 'environment',
|
|
586
881
|
privacyClass: 'operational',
|
|
587
882
|
contentType: 'application/json',
|
|
588
|
-
content: `${JSON.stringify({ version:
|
|
883
|
+
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
884
|
/**
|
|
590
885
|
* The record is operational and one field is classified above it: a resolved FFmpeg path is an
|
|
591
886
|
* environment fact, but it can carry the home directory of the account Homebridge runs as, so it is
|
|
592
887
|
* declared as diagnostic rather than presented alongside the host's own architecture.
|
|
888
|
+
*
|
|
889
|
+
* The Homebridge version is operational and load-bearing: this plugin requires Homebridge 2, and a
|
|
890
|
+
* host that predates it loads the plugin and then fails in ways a media fault cannot be told from.
|
|
593
891
|
*/
|
|
594
892
|
fields: [
|
|
595
893
|
...['version', 'plugin', 'sdk', 'node', 'platform', 'arch'].map((field) => ({
|
|
596
894
|
field,
|
|
597
895
|
privacyClass: 'operational',
|
|
598
896
|
})),
|
|
599
|
-
...(
|
|
897
|
+
...(host?.homebridge ? [{ field: 'homebridge', privacyClass: 'operational' }] : []),
|
|
898
|
+
...(host?.ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
|
|
600
899
|
],
|
|
601
900
|
},
|
|
602
901
|
];
|
|
@@ -647,7 +946,11 @@ export class GuidedDiagnostics {
|
|
|
647
946
|
...(carried.retainedFrom === undefined ? {} : { retainedFrom: carried.retainedFrom }),
|
|
648
947
|
fields: [...new Set(carried.records.flatMap((record) => Object.keys(record)))].sort().map((field) => ({
|
|
649
948
|
field,
|
|
650
|
-
privacyClass: field
|
|
949
|
+
privacyClass: PSEUDONYMOUS_FIELDS.has(field)
|
|
950
|
+
? 'pseudonymous'
|
|
951
|
+
: field === 'timestamp'
|
|
952
|
+
? 'operational'
|
|
953
|
+
: 'diagnostic',
|
|
651
954
|
})),
|
|
652
955
|
});
|
|
653
956
|
}
|
|
@@ -810,16 +1113,17 @@ export class GuidedDiagnostics {
|
|
|
810
1113
|
throw new Error('Diagnostics authorization is inactive or expired');
|
|
811
1114
|
return session;
|
|
812
1115
|
}
|
|
813
|
-
async project(
|
|
814
|
-
if (!
|
|
1116
|
+
async project(persisted) {
|
|
1117
|
+
if (!persisted) {
|
|
815
1118
|
return { status: 'inactive', selectedEvidence: [], missingEvidence: [], partialExportAvailable: false };
|
|
816
1119
|
}
|
|
1120
|
+
const { session, exportable } = settledDiagnosticsSession(persisted, this.now());
|
|
817
1121
|
const selectedEvidence = DIAGNOSTICS_PROFILES[session.profile];
|
|
818
1122
|
const collected = session.reproductionStartedAt && session.reproductionEndedAt
|
|
819
1123
|
? await this.collectSupportEvidence(session)
|
|
820
1124
|
: undefined;
|
|
821
1125
|
const missingEvidence = collected ? missingEvidenceClasses(selectedEvidence, collected) : [];
|
|
822
|
-
const expired = Date.parse(session.expiresAt) <= this.now();
|
|
1126
|
+
const expired = Date.parse(session.expiresAt) <= this.now() && !exportable;
|
|
823
1127
|
const status = expired
|
|
824
1128
|
? 'expired'
|
|
825
1129
|
: session.reproductionEndedAt
|
|
@@ -827,12 +1131,6 @@ export class GuidedDiagnostics {
|
|
|
827
1131
|
: session.reproductionStartedAt
|
|
828
1132
|
? 'reproducing'
|
|
829
1133
|
: '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
1134
|
return {
|
|
837
1135
|
status,
|
|
838
1136
|
supportCaseId: session.supportCaseId,
|
|
@@ -844,8 +1142,9 @@ export class GuidedDiagnostics {
|
|
|
844
1142
|
expiresAt: session.expiresAt,
|
|
845
1143
|
...(session.reproductionStartedAt ? { reproductionStartedAt: session.reproductionStartedAt } : {}),
|
|
846
1144
|
...(session.reproductionEndedAt ? { reproductionEndedAt: session.reproductionEndedAt } : {}),
|
|
847
|
-
partialExportAvailable:
|
|
848
|
-
|
|
1145
|
+
partialExportAvailable: exportable,
|
|
1146
|
+
...(session.affectedDevices === undefined ? {} : { affectedDevices: session.affectedDevices }),
|
|
1147
|
+
issueUrl: bugReportUrl(session, missingEvidence, readHostEnvironment(this.storageRoot)),
|
|
849
1148
|
};
|
|
850
1149
|
}
|
|
851
1150
|
/**
|
|
@@ -1133,6 +1432,14 @@ function allowlistedLabel(value, allowed) {
|
|
|
1133
1432
|
* like any other field that fails its shape.
|
|
1134
1433
|
*/
|
|
1135
1434
|
const MAX_STARTUP_WINDOW_MS = 60_000;
|
|
1435
|
+
/** Why a level-2 wait ended without a key, which is the whole of what such an ending states. */
|
|
1436
|
+
const LEVEL2_UNAVAILABLE_REASONS = [
|
|
1437
|
+
'no-cipher-key',
|
|
1438
|
+
'derivation-failed',
|
|
1439
|
+
'not-negotiating',
|
|
1440
|
+
'session-closed',
|
|
1441
|
+
'grace-elapsed',
|
|
1442
|
+
];
|
|
1136
1443
|
/** The three phases whose only field is the action they report, which the union fixes at `start`. */
|
|
1137
1444
|
const retainedStart = (candidate) => candidate.action === 'start' ? { action: 'start' } : undefined;
|
|
1138
1445
|
/** A phase whose only field is one bounded integer, retained under the name the SDK declares for it. */
|
|
@@ -1178,7 +1485,73 @@ const LIVE_TRACE_PHASES = {
|
|
|
1178
1485
|
'sequence-restart': retainedInteger('dataType', 3),
|
|
1179
1486
|
'level2-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
|
|
1180
1487
|
'level2-ready': retainedInteger('cipherId', 65535),
|
|
1181
|
-
'
|
|
1488
|
+
'session-connect-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
|
|
1489
|
+
'session-connected': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
|
|
1490
|
+
'session-unreachable': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
|
|
1491
|
+
/**
|
|
1492
|
+
* Why a level-2 wait ended without a key, and what it had to name.
|
|
1493
|
+
*
|
|
1494
|
+
* The reason is the record: a wait that ran out states how long it waited, and one answered without waiting
|
|
1495
|
+
* states nothing more, because the negotiation is one-shot per connection. Only a reason reached under a
|
|
1496
|
+
* negotiation has a cipher to name, so both durations are optional and each is retained where it is stated.
|
|
1497
|
+
*/
|
|
1498
|
+
'level2-unavailable': (c) => {
|
|
1499
|
+
const reason = allowlistedLabel(c.reason, LEVEL2_UNAVAILABLE_REASONS);
|
|
1500
|
+
if (!reason) {
|
|
1501
|
+
return undefined;
|
|
1502
|
+
}
|
|
1503
|
+
const cipherId = boundedInteger(c.cipherId, 65535);
|
|
1504
|
+
const waitedMs = boundedInteger(c.waitedMs, MAX_STARTUP_WINDOW_MS);
|
|
1505
|
+
return {
|
|
1506
|
+
reason,
|
|
1507
|
+
...(cipherId === undefined ? {} : { cipherId }),
|
|
1508
|
+
...(waitedMs === undefined ? {} : { waitedMs }),
|
|
1509
|
+
};
|
|
1510
|
+
},
|
|
1511
|
+
/** The cipher a station asked for, beside the one whose material answered it. */
|
|
1512
|
+
'cipher-fallback': (c) => {
|
|
1513
|
+
const cipherId = boundedInteger(c.cipherId, 65535);
|
|
1514
|
+
const answeredCipherId = boundedInteger(c.answeredCipherId, 65535);
|
|
1515
|
+
return cipherId === undefined || answeredCipherId === undefined ? undefined : { cipherId, answeredCipherId };
|
|
1516
|
+
},
|
|
1517
|
+
'level2-negotiating': retainedInteger('cipherId', 65535),
|
|
1518
|
+
/**
|
|
1519
|
+
* What a call resolved a station as, which decides what its failure means.
|
|
1520
|
+
*
|
|
1521
|
+
* An attached camera's media start has no unencrypted form, so the topology is what separates a key that
|
|
1522
|
+
* never arrived from a start that had no form to be sent in. The channel is an index on a base and the
|
|
1523
|
+
* administrator is a relation to the signed-in account: neither is an identity of its own.
|
|
1524
|
+
*/
|
|
1525
|
+
/**
|
|
1526
|
+
* Which lookup channels a connection could ask for a station on, before it asked.
|
|
1527
|
+
*
|
|
1528
|
+
* A connect that had one channel failed for that channel's reason alone, and a connect that had neither
|
|
1529
|
+
* could not have succeeded. Both are otherwise indistinguishable from a station that is switched off.
|
|
1530
|
+
* `cloudMissing` names what the cloud channel lacked when it was unavailable.
|
|
1531
|
+
*/
|
|
1532
|
+
'lookup-channels': (c) => {
|
|
1533
|
+
if (typeof c.local !== 'boolean' || typeof c.cloud !== 'boolean')
|
|
1534
|
+
return undefined;
|
|
1535
|
+
const cloudMissing = allowlistedLabel(c.cloudMissing, ['dsk-key', 'cloud-addresses']);
|
|
1536
|
+
return { local: c.local, cloud: c.cloud, ...(cloudMissing === undefined ? {} : { cloudMissing }) };
|
|
1537
|
+
},
|
|
1538
|
+
'station-resolved': (c) => {
|
|
1539
|
+
const topology = allowlistedLabel(c.topology, ['attached', 'own']);
|
|
1540
|
+
const stationAdmin = allowlistedLabel(c.stationAdmin, ['self', 'other', 'unstated']);
|
|
1541
|
+
const channel = boundedInteger(c.channel, MAX_STATION_CHANNEL);
|
|
1542
|
+
const stationModel = typeof c.stationModel === 'string' ? boundedText(c.stationModel, MAX_DEVICE_MODEL_LENGTH) : undefined;
|
|
1543
|
+
return topology && stationAdmin && channel !== undefined
|
|
1544
|
+
? { topology, channel, stationAdmin, ...(stationModel === undefined ? {} : { stationModel }) }
|
|
1545
|
+
: undefined;
|
|
1546
|
+
},
|
|
1547
|
+
/**
|
|
1548
|
+
* Why a device could not be addressed on its station: its record states no channel, or another attached
|
|
1549
|
+
* device states the same one. The call was refused before anything was sent.
|
|
1550
|
+
*/
|
|
1551
|
+
'station-channel-unresolved': (c) => {
|
|
1552
|
+
const issue = allowlistedLabel(c.issue, ['missing', 'shared']);
|
|
1553
|
+
return issue ? { issue } : undefined;
|
|
1554
|
+
},
|
|
1182
1555
|
/**
|
|
1183
1556
|
* How long a connection's path has answered nothing, which is the station stating the path is gone.
|
|
1184
1557
|
*
|
|
@@ -1186,6 +1559,19 @@ const LIVE_TRACE_PHASES = {
|
|
|
1186
1559
|
* silence longer than that belongs to a session nothing is waiting on.
|
|
1187
1560
|
*/
|
|
1188
1561
|
'path-stale': retainedInteger('silentMs', MAX_STARTUP_WINDOW_MS),
|
|
1562
|
+
/**
|
|
1563
|
+
* How long a camera's own channel delivered nothing, and whether the station was asked to serve it again.
|
|
1564
|
+
*
|
|
1565
|
+
* Both halves are the record: a station re-tasked to a sibling is repaired by the re-assert, and a pull
|
|
1566
|
+
* nothing is attached to declines it so the station is left with the camera someone is watching. A picture
|
|
1567
|
+
* that stopped advancing while no such record exists stopped for a reason the station's attention does not
|
|
1568
|
+
* reach.
|
|
1569
|
+
*/
|
|
1570
|
+
'channel-silent': (c) => {
|
|
1571
|
+
const silentMs = boundedInteger(c.silentMs, MAX_STARTUP_WINDOW_MS);
|
|
1572
|
+
const outcome = allowlistedLabel(c.outcome, ['declined', 'reasserted']);
|
|
1573
|
+
return silentMs !== undefined && outcome ? { silentMs, outcome } : undefined;
|
|
1574
|
+
},
|
|
1189
1575
|
'media-command-unsent': (c) => {
|
|
1190
1576
|
const reason = allowlistedLabel(c.reason, ['level2-key', 'address']);
|
|
1191
1577
|
return reason ? { reason } : undefined;
|
|
@@ -1219,6 +1605,45 @@ function sanitizeSdkLiveStartTrace(value) {
|
|
|
1219
1605
|
function opaquePullHandle(value) {
|
|
1220
1606
|
return typeof value === 'string' && /^(?:pull|station)-\d{1,6}(?::\d{1,3})?$/.test(value) ? value : undefined;
|
|
1221
1607
|
}
|
|
1608
|
+
/**
|
|
1609
|
+
* One support-case accessory alias, or nothing where the value is not one.
|
|
1610
|
+
*
|
|
1611
|
+
* Matched against the shape an alias has rather than against what a serial looks like, on the same rule as
|
|
1612
|
+
* {@link opaquePullHandle}: a record carries an alias or it carries no accessory at all.
|
|
1613
|
+
*/
|
|
1614
|
+
function opaqueAccessoryAlias(value) {
|
|
1615
|
+
return typeof value === 'string' &&
|
|
1616
|
+
/^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)
|
|
1617
|
+
? value
|
|
1618
|
+
: undefined;
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Narrows one realtime startup outcome to its state and push start counts, or nothing where the state is unknown.
|
|
1622
|
+
*
|
|
1623
|
+
* Read by both halves of the path — {@link reportRealtimeReadiness} builds the record and the file sink rebuilds it.
|
|
1624
|
+
*/
|
|
1625
|
+
function sanitizeRealtimeReadiness(value) {
|
|
1626
|
+
const state = allowlistedLabel(value.state, ['ready', 'partial', 'disabled', 'superseded', 'timed-out']);
|
|
1627
|
+
if (!state)
|
|
1628
|
+
return undefined;
|
|
1629
|
+
const counts = Object.fromEntries(['required', 'ready', 'failed', 'pending'].flatMap((key) => {
|
|
1630
|
+
const count = nonNegativeInteger(value[key]);
|
|
1631
|
+
return count === undefined ? [] : [[key, count]];
|
|
1632
|
+
}));
|
|
1633
|
+
return { event: 'realtime-readiness', state, ...counts };
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Records how the SDK's realtime startup settled, with the push plane's start counts, as `sdk-log` evidence.
|
|
1637
|
+
*
|
|
1638
|
+
* Carries the state and the counts alone, which separate a push plane that failed or never settled from one that
|
|
1639
|
+
* started.
|
|
1640
|
+
*/
|
|
1641
|
+
export function reportRealtimeReadiness(target, readiness) {
|
|
1642
|
+
const record = sanitizeRealtimeReadiness({ ...readiness.push, state: readiness.state });
|
|
1643
|
+
if (target?.debug && record) {
|
|
1644
|
+
target.debug(JSON.stringify({ scope: 'sdk', level: 'debug', subsystem: 'push', ...record }));
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1222
1647
|
/**
|
|
1223
1648
|
* Adapts SDK protocol detail to bounded debug output without preserving supplied values.
|
|
1224
1649
|
*
|
|
@@ -1260,9 +1685,7 @@ export function createSdkLogger(target) {
|
|
|
1260
1685
|
const subsystem = SDK_SUBSYSTEMS.has(requestedSubsystem ?? '') ? requestedSubsystem : (aliasedSubsystem ?? 'sdk');
|
|
1261
1686
|
const details = args.slice(0, MAX_SDK_DETAILS).map((value) => {
|
|
1262
1687
|
if (value instanceof Error) {
|
|
1263
|
-
const errorType =
|
|
1264
|
-
? value.name
|
|
1265
|
-
: 'Error';
|
|
1688
|
+
const errorType = SDK_ERROR_TYPES.includes(value.name) ? value.name : 'Error';
|
|
1266
1689
|
return { errorType };
|
|
1267
1690
|
}
|
|
1268
1691
|
if (typeof value === 'string') {
|
|
@@ -1363,6 +1786,7 @@ const REASONS = new Set([
|
|
|
1363
1786
|
'live-failed',
|
|
1364
1787
|
'live-no-keyframe',
|
|
1365
1788
|
'live-source-failed',
|
|
1789
|
+
'live-station-unreachable',
|
|
1366
1790
|
'live-unavailable',
|
|
1367
1791
|
'live-undecodable-burst',
|
|
1368
1792
|
'malformed',
|
|
@@ -1386,6 +1810,8 @@ const REASONS = new Set([
|
|
|
1386
1810
|
'source-input-unstable',
|
|
1387
1811
|
'source-stopped',
|
|
1388
1812
|
'source-unavailable',
|
|
1813
|
+
'station-key-unavailable',
|
|
1814
|
+
'station-unreachable',
|
|
1389
1815
|
'stored-download-failed',
|
|
1390
1816
|
'stored-failed',
|
|
1391
1817
|
'stored-invalid-image',
|
|
@@ -1396,6 +1822,7 @@ const REASONS = new Set([
|
|
|
1396
1822
|
'unsupported',
|
|
1397
1823
|
'unsupported-selection',
|
|
1398
1824
|
'device-audio-failed',
|
|
1825
|
+
'no-controller-audio',
|
|
1399
1826
|
]);
|
|
1400
1827
|
/** Every runtime state, plus the withdrawal a recovered condition reports. */
|
|
1401
1828
|
const RUNTIME_CONDITION_REASONS = new Set([...RUNTIME_STATES, 'recovered']);
|
|
@@ -1404,6 +1831,7 @@ const HOMEKIT_EVENT_ROUTES = {
|
|
|
1404
1831
|
'battery.status': new Set(['battery-alert', 'battery-level']),
|
|
1405
1832
|
'contact.sensor': new Set(['contact-state']),
|
|
1406
1833
|
'doorbell.press': new Set(['doorbell-press']),
|
|
1834
|
+
'doorbell.package': new Set(['package-presence']),
|
|
1407
1835
|
'lock.mechanism': new Set(['lock-state']),
|
|
1408
1836
|
'motion.sensor': new Set(['motion-detection']),
|
|
1409
1837
|
'arming.security-system': new Set(['arming-mode-changed', 'security-system-alarm']),
|
|
@@ -1442,6 +1870,8 @@ const MAX_LIVE_VIDEO_FRAME_RATE = 120;
|
|
|
1442
1870
|
*/
|
|
1443
1871
|
const MIN_LIVE_VIDEO_MTU = 0;
|
|
1444
1872
|
const MAX_LIVE_VIDEO_MTU = 65_535;
|
|
1873
|
+
/** The bit rate ceiling a controller negotiated, in kbps, bounded to the unsigned 16-bit field that carries it. */
|
|
1874
|
+
const MAX_LIVE_VIDEO_BIT_RATE = 65_535;
|
|
1445
1875
|
/** The two address families HomeKit negotiates a destination in. */
|
|
1446
1876
|
const HOMEKIT_LIVE_ADDRESS_VERSIONS = new Set(['ipv4', 'ipv6']);
|
|
1447
1877
|
const HOMEKIT_LIVE_SESSION_STAGES = new Set([
|
|
@@ -1450,6 +1880,9 @@ const HOMEKIT_LIVE_SESSION_STAGES = new Set([
|
|
|
1450
1880
|
'first-adapted-output',
|
|
1451
1881
|
'controller-rtcp',
|
|
1452
1882
|
]);
|
|
1883
|
+
/** The SDK's own stage and reason for a live warm-up that ended without a keyframe. */
|
|
1884
|
+
const SDK_LIVE_START_STAGES = ['awaiting-first-frame', 'audio-only', 'awaiting-keyframe'];
|
|
1885
|
+
const SDK_LIVE_START_REASONS = ['warm-timeout', 'source-error', 'source-ended'];
|
|
1453
1886
|
/**
|
|
1454
1887
|
* Which adaptation process an FFmpeg record came from.
|
|
1455
1888
|
*
|
|
@@ -1466,6 +1899,13 @@ const ADAPTATION_EVENTS = new Set([
|
|
|
1466
1899
|
'exited-while-streaming',
|
|
1467
1900
|
'output',
|
|
1468
1901
|
]);
|
|
1902
|
+
/**
|
|
1903
|
+
* Why a process was started, on the record that reports it starting.
|
|
1904
|
+
*
|
|
1905
|
+
* A replacement carries the identity of the stream the previous process was already sending on, so what it
|
|
1906
|
+
* started mid-flight is judged against what that one left. A first adaptation has nothing behind it.
|
|
1907
|
+
*/
|
|
1908
|
+
const ADAPTATION_CAUSES = ['first', 'controller-selection', 'source-configuration'];
|
|
1469
1909
|
/**
|
|
1470
1910
|
* Which of those events is a failure, and so the ones a record is levelled `warn` for.
|
|
1471
1911
|
*
|
|
@@ -1602,7 +2042,7 @@ const RUNTIME_CONDITIONS = {
|
|
|
1602
2042
|
},
|
|
1603
2043
|
};
|
|
1604
2044
|
const HOMEKIT_CONDITIONS = {
|
|
1605
|
-
'recognized-device-not-represented': ['log.homekit.recognizedNotRepresented', 'log.action.
|
|
2045
|
+
'recognized-device-not-represented': ['log.homekit.recognizedNotRepresented', 'log.action.notSupportedYet'],
|
|
1606
2046
|
'battery-capability-unavailable': ['log.homekit.batteryCapabilityUnavailable', 'log.action.waitBattery'],
|
|
1607
2047
|
'invalid-battery-observation': ['log.homekit.invalidBatteryObservation', 'log.action.waitBatteryObservation'],
|
|
1608
2048
|
'battery-temperature-alert': ['log.homekit.batteryTemperatureAlert', 'log.action.allowBatteryCooling'],
|
|
@@ -1615,7 +2055,7 @@ const HOMEKIT_CONDITIONS = {
|
|
|
1615
2055
|
'smart-light-operation-failed': ['log.homekit.lightOperationFailed', 'log.action.retryLight'],
|
|
1616
2056
|
'smart-light-reconciliation-expired': ['log.homekit.lightReconciliationExpired', 'log.action.checkPhysicalLight'],
|
|
1617
2057
|
'arming-capability-unavailable': ['log.homekit.armingCapabilityUnavailable', 'log.action.waitArming'],
|
|
1618
|
-
'unsupported-arming-mode': ['log.homekit.unsupportedArmingMode', 'log.action.
|
|
2058
|
+
'unsupported-arming-mode': ['log.homekit.unsupportedArmingMode', 'log.action.mapArmingMode'],
|
|
1619
2059
|
'arming-operation-failed': ['log.homekit.armingOperationFailed', 'log.action.retryArming'],
|
|
1620
2060
|
'arming-reconciliation-expired': ['log.homekit.armingReconciliationExpired', 'log.action.checkPhysicalArmingMode'],
|
|
1621
2061
|
'lock-capability-unavailable': ['log.homekit.lockCapabilityUnavailable', 'log.action.waitLock'],
|
|
@@ -1649,6 +2089,64 @@ const HOMEKIT_CONDITIONS = {
|
|
|
1649
2089
|
'camera-talkback-failed': ['log.homekit.cameraTalkbackFailed', 'log.action.retryTalkback'],
|
|
1650
2090
|
'camera-talkback-capability-unavailable': ['log.homekit.cameraTalkbackUnavailable', 'log.action.waitTalkback'],
|
|
1651
2091
|
};
|
|
2092
|
+
/**
|
|
2093
|
+
* The action a HomeKit condition names for a reason whose remedy is not the condition's own, keyed `code:reason`.
|
|
2094
|
+
*
|
|
2095
|
+
* A camera adapter is missing only where no FFmpeg path resolved, an adaptation fails to spawn only where the path
|
|
2096
|
+
* cannot be run, and a live adaptation that exits before any output is the FFmpeg build refusing this host, so all
|
|
2097
|
+
* three are fixed in the plugin's settings rather than waited out. A control that timed out
|
|
2098
|
+
* will likely time out again, so it points at the Eufy app instead of a retry. A station whose session did not
|
|
2099
|
+
* connect is fixed at that station, not at the camera a viewer opened.
|
|
2100
|
+
*/
|
|
2101
|
+
const HOMEKIT_REASON_ACTIONS = {
|
|
2102
|
+
'camera-streaming-capability-unavailable:adapter-missing': 'log.action.setFfmpegPath',
|
|
2103
|
+
'camera-recording-unavailable:adapter-missing': 'log.action.setFfmpegPath',
|
|
2104
|
+
'camera-live-session-failed:adaptation-spawn-failed': 'log.action.checkFfmpegPath',
|
|
2105
|
+
'camera-live-session-failed:adaptation-exited-before-output': 'log.action.setFfmpegPath',
|
|
2106
|
+
'lock-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2107
|
+
'arming-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2108
|
+
'smart-light-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2109
|
+
'camera-control-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2110
|
+
'camera-live-session-failed:station-unreachable': 'log.action.checkStation',
|
|
2111
|
+
'camera-snapshot-unavailable:live-station-unreachable': 'log.action.checkStation',
|
|
2112
|
+
'camera-talkback-failed:no-controller-audio': 'log.action.trySmallVideoPackets',
|
|
2113
|
+
};
|
|
2114
|
+
/** Projects a value onto an active condition, or nothing where the catalog does not write that code with those keys. */
|
|
2115
|
+
export function knownCondition(value) {
|
|
2116
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
2117
|
+
return undefined;
|
|
2118
|
+
const { code, summaryKey, actionKey, serials } = value;
|
|
2119
|
+
if (typeof code !== 'string')
|
|
2120
|
+
return undefined;
|
|
2121
|
+
const runtime = Object.values(RUNTIME_CONDITIONS).find((condition) => condition.code === code);
|
|
2122
|
+
const homeKit = Object.hasOwn(HOMEKIT_CONDITIONS, code)
|
|
2123
|
+
? HOMEKIT_CONDITIONS[code]
|
|
2124
|
+
: undefined;
|
|
2125
|
+
const [summary, ...actions] = runtime
|
|
2126
|
+
? [runtime.summaryKey, runtime.actionKey]
|
|
2127
|
+
: homeKit
|
|
2128
|
+
? [
|
|
2129
|
+
...homeKit,
|
|
2130
|
+
...Object.entries(HOMEKIT_REASON_ACTIONS)
|
|
2131
|
+
.filter(([key]) => key.startsWith(`${code}:`))
|
|
2132
|
+
.map(([, action]) => action),
|
|
2133
|
+
]
|
|
2134
|
+
: [];
|
|
2135
|
+
const action = allowlistedLabel(actionKey, actions);
|
|
2136
|
+
if (summary === undefined || summaryKey !== summary || action === undefined)
|
|
2137
|
+
return undefined;
|
|
2138
|
+
const named = Array.isArray(serials) ? serials.filter((serial) => typeof serial === 'string') : [];
|
|
2139
|
+
return { code, summaryKey: summary, actionKey: action, ...(named.length === 0 ? {} : { serials: named }) };
|
|
2140
|
+
}
|
|
2141
|
+
/** HomeKit conditions that state a gap nothing in the user's setup closes, so an active one is not a warning. */
|
|
2142
|
+
const INFORMATIONAL_HOMEKIT_CONDITIONS = new Set(['recognized-device-not-represented']);
|
|
2143
|
+
/** The level and action one HomeKit condition is written with, decided once for the console and the record. */
|
|
2144
|
+
function homeKitConditionPresentation(code, active, reason) {
|
|
2145
|
+
return {
|
|
2146
|
+
level: active && !INFORMATIONAL_HOMEKIT_CONDITIONS.has(code) ? 'warn' : 'info',
|
|
2147
|
+
actionKey: HOMEKIT_REASON_ACTIONS[`${code}:${reason}`] ?? HOMEKIT_CONDITIONS[code][1],
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
1652
2150
|
function sanitizeStructuredEvent(message) {
|
|
1653
2151
|
let value;
|
|
1654
2152
|
try {
|
|
@@ -1670,6 +2168,10 @@ function sanitizeStructuredEvent(message) {
|
|
|
1670
2168
|
!SDK_EVENT_KEYS.has(value.event)) {
|
|
1671
2169
|
return undefined;
|
|
1672
2170
|
}
|
|
2171
|
+
if (value.event === 'realtime-readiness') {
|
|
2172
|
+
const readiness = sanitizeRealtimeReadiness(value);
|
|
2173
|
+
return readiness ? { scope: 'sdk', level: 'debug', subsystem: 'push', ...readiness } : undefined;
|
|
2174
|
+
}
|
|
1673
2175
|
if (value.event === 'live-start-trace') {
|
|
1674
2176
|
if (level !== 'debug')
|
|
1675
2177
|
return undefined;
|
|
@@ -1681,12 +2183,7 @@ function sanitizeStructuredEvent(message) {
|
|
|
1681
2183
|
if (!detail || typeof detail !== 'object' || Array.isArray(detail))
|
|
1682
2184
|
continue;
|
|
1683
2185
|
const candidate = detail;
|
|
1684
|
-
const errorType = allowlistedLabel(candidate.errorType,
|
|
1685
|
-
'Error',
|
|
1686
|
-
'RangeError',
|
|
1687
|
-
'SessionExpiredError',
|
|
1688
|
-
'TypeError',
|
|
1689
|
-
]);
|
|
2186
|
+
const errorType = allowlistedLabel(candidate.errorType, SDK_ERROR_TYPES);
|
|
1690
2187
|
if (errorType) {
|
|
1691
2188
|
details.push({ errorType });
|
|
1692
2189
|
continue;
|
|
@@ -1737,6 +2234,14 @@ function sanitizeStructuredEvent(message) {
|
|
|
1737
2234
|
return { scope: 'media-notice', level: notice.level, code: value.code, messageKey: notice.messageKey };
|
|
1738
2235
|
}
|
|
1739
2236
|
if (value.scope === 'homekit') {
|
|
2237
|
+
if (value.event === 'event-unrouted') {
|
|
2238
|
+
const unrouted = sanitizeUnroutedEvent(value);
|
|
2239
|
+
return unrouted ? { scope: 'homekit', level: 'debug', ...unrouted } : undefined;
|
|
2240
|
+
}
|
|
2241
|
+
if (value.event === 'station-claim') {
|
|
2242
|
+
const claim = sanitizeStationClaim(value);
|
|
2243
|
+
return claim ? { scope: 'homekit', level: 'debug', ...claim } : undefined;
|
|
2244
|
+
}
|
|
1740
2245
|
if (value.adapter === 'camera.streaming' && value.event === 'live-video-selected') {
|
|
1741
2246
|
const selection = sanitizeLiveVideoSelection(value);
|
|
1742
2247
|
return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
|
|
@@ -1752,6 +2257,7 @@ function sanitizeStructuredEvent(message) {
|
|
|
1752
2257
|
!HOMEKIT_OBSERVATIONS.has(value.observation)) {
|
|
1753
2258
|
return undefined;
|
|
1754
2259
|
}
|
|
2260
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
1755
2261
|
return {
|
|
1756
2262
|
scope: 'homekit',
|
|
1757
2263
|
level: 'debug',
|
|
@@ -1761,6 +2267,7 @@ function sanitizeStructuredEvent(message) {
|
|
|
1761
2267
|
...(typeof value.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(value.announcedBy)
|
|
1762
2268
|
? { announcedBy: value.announcedBy }
|
|
1763
2269
|
: {}),
|
|
2270
|
+
...(alias === undefined ? {} : { accessory: alias }),
|
|
1764
2271
|
};
|
|
1765
2272
|
}
|
|
1766
2273
|
if (value.scope === 'runtime') {
|
|
@@ -1809,14 +2316,15 @@ function sanitizeStructuredEvent(message) {
|
|
|
1809
2316
|
.filter((alias) => typeof alias === 'string' && /^accessory-[0-9a-f-]{36}$/.test(alias))
|
|
1810
2317
|
.slice(0, MAX_ACCESSORY_ALIASES)
|
|
1811
2318
|
: [];
|
|
2319
|
+
const { level: conditionLevel, actionKey } = homeKitConditionPresentation(value.code, value.active, value.reason);
|
|
1812
2320
|
return {
|
|
1813
2321
|
scope: 'diagnostic-condition',
|
|
1814
|
-
level:
|
|
2322
|
+
level: conditionLevel,
|
|
1815
2323
|
code: value.code,
|
|
1816
2324
|
active: value.active,
|
|
1817
2325
|
reason: value.reason,
|
|
1818
2326
|
summaryKey: homeKitDefinition[0],
|
|
1819
|
-
actionKey
|
|
2327
|
+
actionKey,
|
|
1820
2328
|
...(typeof value.capability === 'string' && CAPABILITIES.has(value.capability)
|
|
1821
2329
|
? { capability: value.capability }
|
|
1822
2330
|
: {}),
|
|
@@ -1833,7 +2341,7 @@ export function reportRuntimeNotice(target, code, fields = {}) {
|
|
|
1833
2341
|
const notice = RUNTIME_NOTICES[code];
|
|
1834
2342
|
const durationMs = fields.durationMs === undefined ? undefined : Math.max(0, Math.trunc(fields.durationMs));
|
|
1835
2343
|
const messageKey = durationMs !== undefined && 'durationMessageKey' in notice ? notice.durationMessageKey : notice.messageKey;
|
|
1836
|
-
target[notice.level]?.(`[${code}] ${localize(target, messageKey, { durationMs: durationMs ?? 0 })}`);
|
|
2344
|
+
target[notice.level]?.(`[${code}] ${localize(target, messageKey, { durationMs: durationMs ?? 0, finishBy: fields.finishBy ?? '' })}`);
|
|
1837
2345
|
target.debug?.(JSON.stringify({
|
|
1838
2346
|
scope: 'runtime-notice',
|
|
1839
2347
|
level: notice.level,
|
|
@@ -1848,14 +2356,17 @@ export function reportRuntimeNotice(target, code, fields = {}) {
|
|
|
1848
2356
|
* The file is the authority: the supplied identifier only names which window the caller believes was opened,
|
|
1849
2357
|
* and a session the file does not hold as active changes nothing and reports nothing. A confirmed pickup emits
|
|
1850
2358
|
* one operational record, which is the evidence that this process began retaining the window's scopes, taken
|
|
1851
|
-
* at the moment it did rather than whenever a later record arrives
|
|
2359
|
+
* at the moment it did rather than whenever a later record arrives, and its console line names the time the
|
|
2360
|
+
* capture stops on its own.
|
|
1852
2361
|
*/
|
|
1853
2362
|
export function armDiagnosticsAuthorization(target, storageRoot, supportCaseId, now = Date.now) {
|
|
1854
|
-
|
|
1855
|
-
|
|
2363
|
+
const session = activeDiagnosticsSession(storageRoot, now());
|
|
2364
|
+
if (!isSupportCaseId(supportCaseId) || session?.supportCaseId !== supportCaseId) {
|
|
1856
2365
|
return false;
|
|
1857
2366
|
}
|
|
1858
|
-
reportRuntimeNotice(target, 'diagnostics-authorization-armed'
|
|
2367
|
+
reportRuntimeNotice(target, 'diagnostics-authorization-armed', {
|
|
2368
|
+
finishBy: new Date(session.expiresAt).toLocaleString(),
|
|
2369
|
+
});
|
|
1859
2370
|
return true;
|
|
1860
2371
|
}
|
|
1861
2372
|
/** Emits the startup notice for discarded settings awaiting acknowledgement. */
|
|
@@ -1877,17 +2388,72 @@ export function reportInvalidSnapshotCache(target) {
|
|
|
1877
2388
|
target[level](`[${code}] ${localize(target, messageKey)}`);
|
|
1878
2389
|
target.debug?.(JSON.stringify({ scope: 'media-notice', level, code, messageKey }));
|
|
1879
2390
|
}
|
|
1880
|
-
/**
|
|
1881
|
-
|
|
2391
|
+
/**
|
|
2392
|
+
* Emits one allowlisted HomeKit event trace only when host debug output is available.
|
|
2393
|
+
*
|
|
2394
|
+
* `aliasFor` resolves the trace's device identity to the support-case alias the conditions about that accessory
|
|
2395
|
+
* already carry. The identity itself never reaches the record: an unresolved one is recorded as no accessory.
|
|
2396
|
+
*/
|
|
2397
|
+
/**
|
|
2398
|
+
* Narrows one offered station arbitration decision, or nothing where its own labels do not narrow.
|
|
2399
|
+
*
|
|
2400
|
+
* Read by both halves of the path — {@link reportStationClaim} builds the record and the file sink rebuilds
|
|
2401
|
+
* it — because a record only one of them recognises is written to a console nobody is reading and dropped
|
|
2402
|
+
* before it reaches a support archive.
|
|
2403
|
+
*/
|
|
2404
|
+
function sanitizeStationClaim(value) {
|
|
2405
|
+
const action = allowlistedLabel(value.action, STATION_CLAIM_ACTIONS);
|
|
2406
|
+
const claim = allowlistedLabel(value.claim, STATION_CLAIMS);
|
|
2407
|
+
if (!action || !claim) {
|
|
2408
|
+
return undefined;
|
|
2409
|
+
}
|
|
2410
|
+
const against = allowlistedLabel(value.to ?? value.by ?? value.against, STATION_CLAIMS);
|
|
2411
|
+
return { event: 'station-claim', action, claim, ...(against === undefined ? {} : { against }) };
|
|
2412
|
+
}
|
|
2413
|
+
/**
|
|
2414
|
+
* Narrows one offered unrouted SDK event to its kind and target, or nothing where either is not allowlisted.
|
|
2415
|
+
*
|
|
2416
|
+
* Read by both halves of the path — {@link reportHomeKitEvent} builds the record and the file sink rebuilds it.
|
|
2417
|
+
*/
|
|
2418
|
+
function sanitizeUnroutedEvent(value) {
|
|
2419
|
+
const kind = allowlistedLabel(value.kind, UNROUTED_EVENT_KINDS);
|
|
2420
|
+
const target = allowlistedLabel(value.target, UNROUTED_EVENT_TARGETS);
|
|
2421
|
+
return kind && target ? { event: 'event-unrouted', kind, target } : undefined;
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Records one arbitration decision over a station's own session as `homekit-log` evidence.
|
|
2425
|
+
*
|
|
2426
|
+
* A still that stood aside produced no picture of its own and no failure, so without this the deferral is
|
|
2427
|
+
* indistinguishable afterwards from a capture that was never asked for. The record carries the claims and the
|
|
2428
|
+
* decision alone: a station identity is a serial, and the camera holding it is already named by the records
|
|
2429
|
+
* about that camera.
|
|
2430
|
+
*/
|
|
2431
|
+
export function reportStationClaim(target, decision) {
|
|
2432
|
+
const claim = sanitizeStationClaim(decision);
|
|
2433
|
+
if (target.debug && claim) {
|
|
2434
|
+
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...claim }));
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
export function reportHomeKitEvent(target, trace, aliasFor) {
|
|
2438
|
+
if (!('adapter' in trace)) {
|
|
2439
|
+
const unrouted = sanitizeUnroutedEvent(trace);
|
|
2440
|
+
if (target.debug && unrouted) {
|
|
2441
|
+
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...unrouted }));
|
|
2442
|
+
}
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
const alias = typeof trace.serial === 'string' ? aliasFor?.(trace.serial) : undefined;
|
|
2446
|
+
const offered = { ...trace, accessory: alias };
|
|
2447
|
+
const accessory = alias === undefined ? {} : { accessory: alias };
|
|
1882
2448
|
if (trace.event === 'live-video-selected') {
|
|
1883
|
-
const selection = sanitizeLiveVideoSelection(
|
|
2449
|
+
const selection = sanitizeLiveVideoSelection(offered);
|
|
1884
2450
|
if (target.debug && selection) {
|
|
1885
2451
|
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...selection }));
|
|
1886
2452
|
}
|
|
1887
2453
|
return;
|
|
1888
2454
|
}
|
|
1889
2455
|
if (HOMEKIT_LIVE_REQUEST_EVENTS.has(trace.event)) {
|
|
1890
|
-
const lifecycle = sanitizeLiveSessionTrace(
|
|
2456
|
+
const lifecycle = sanitizeLiveSessionTrace(offered);
|
|
1891
2457
|
if (target.debug && lifecycle) {
|
|
1892
2458
|
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...lifecycle }));
|
|
1893
2459
|
}
|
|
@@ -1909,6 +2475,7 @@ export function reportHomeKitEvent(target, trace) {
|
|
|
1909
2475
|
...(typeof trace.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(trace.announcedBy)
|
|
1910
2476
|
? { announcedBy: trace.announcedBy }
|
|
1911
2477
|
: {}),
|
|
2478
|
+
...accessory,
|
|
1912
2479
|
}));
|
|
1913
2480
|
}
|
|
1914
2481
|
/**
|
|
@@ -2015,11 +2582,13 @@ function sanitizeAdaptationNotice(value, level) {
|
|
|
2015
2582
|
.filter((line) => line !== undefined)
|
|
2016
2583
|
.slice(-MAX_ADAPTATION_STDERR_LINES);
|
|
2017
2584
|
const sourceFragments = nonNegativeInteger(value.sourceFragments);
|
|
2585
|
+
const cause = allowlistedLabel(value.cause, ADAPTATION_CAUSES);
|
|
2018
2586
|
return {
|
|
2019
2587
|
scope: 'ffmpeg',
|
|
2020
2588
|
level,
|
|
2021
2589
|
role: value.role,
|
|
2022
2590
|
event: value.event,
|
|
2591
|
+
...(cause === undefined ? {} : { cause }),
|
|
2023
2592
|
...(code === undefined ? {} : { code }),
|
|
2024
2593
|
...(signal === undefined ? {} : { signal }),
|
|
2025
2594
|
...(sourceFragments === undefined ? {} : { sourceFragments }),
|
|
@@ -2030,17 +2599,36 @@ function sanitizeLiveSessionTrace(value) {
|
|
|
2030
2599
|
if (value.adapter !== 'camera.streaming') {
|
|
2031
2600
|
return undefined;
|
|
2032
2601
|
}
|
|
2033
|
-
|
|
2034
|
-
|
|
2602
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
2603
|
+
const accessory = alias === undefined ? {} : { accessory: alias };
|
|
2604
|
+
if (value.event === 'live-session-released') {
|
|
2605
|
+
const release = allowlistedLabel(value.release, LIVE_SESSION_RELEASES);
|
|
2606
|
+
if (!release) {
|
|
2607
|
+
return undefined;
|
|
2608
|
+
}
|
|
2609
|
+
const reports = nonNegativeInteger(value.reports);
|
|
2610
|
+
const sinceLastReportMs = boundedInteger(value.sinceLastReportMs, MAX_STARTUP_WINDOW_MS);
|
|
2611
|
+
return {
|
|
2612
|
+
adapter: value.adapter,
|
|
2613
|
+
event: value.event,
|
|
2614
|
+
release,
|
|
2615
|
+
...(reports === undefined ? {} : { reports }),
|
|
2616
|
+
...(sinceLastReportMs === undefined ? {} : { sinceLastReportMs }),
|
|
2617
|
+
...(value.stage === 'sdk-source-acquisition' ? { stage: value.stage } : {}),
|
|
2618
|
+
...accessory,
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
if (value.event === 'live-session-streaming') {
|
|
2622
|
+
return { adapter: value.adapter, event: value.event, ...accessory };
|
|
2035
2623
|
}
|
|
2036
2624
|
if (value.event === 'live-request-refused') {
|
|
2037
2625
|
return typeof value.reason === 'string' && REFUSAL_REASONS.has(value.reason)
|
|
2038
|
-
? { adapter: value.adapter, event: value.event, reason: value.reason }
|
|
2626
|
+
? { adapter: value.adapter, event: value.event, reason: value.reason, ...accessory }
|
|
2039
2627
|
: undefined;
|
|
2040
2628
|
}
|
|
2041
2629
|
if (value.event === 'live-request-unaccounted') {
|
|
2042
2630
|
return typeof value.afterMs === 'number' && Number.isFinite(value.afterMs)
|
|
2043
|
-
? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs) }
|
|
2631
|
+
? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs), ...accessory }
|
|
2044
2632
|
: undefined;
|
|
2045
2633
|
}
|
|
2046
2634
|
if (value.event !== 'live-session-failed' ||
|
|
@@ -2051,12 +2639,17 @@ function sanitizeLiveSessionTrace(value) {
|
|
|
2051
2639
|
!HOMEKIT_LIVE_SESSION_STAGES.has(value.stage)) {
|
|
2052
2640
|
return undefined;
|
|
2053
2641
|
}
|
|
2642
|
+
const sourceStage = allowlistedLabel(value.sourceStage, SDK_LIVE_START_STAGES);
|
|
2643
|
+
const sourceReason = allowlistedLabel(value.sourceReason, SDK_LIVE_START_REASONS);
|
|
2054
2644
|
return {
|
|
2055
2645
|
adapter: value.adapter,
|
|
2056
2646
|
event: value.event,
|
|
2057
2647
|
outcome: value.outcome,
|
|
2058
2648
|
reason: value.reason,
|
|
2059
2649
|
stage: value.stage,
|
|
2650
|
+
...(sourceStage === undefined ? {} : { sourceStage }),
|
|
2651
|
+
...(sourceReason === undefined ? {} : { sourceReason }),
|
|
2652
|
+
...accessory,
|
|
2060
2653
|
};
|
|
2061
2654
|
}
|
|
2062
2655
|
function sanitizeLiveVideoSelection(value) {
|
|
@@ -2067,6 +2660,7 @@ function sanitizeLiveVideoSelection(value) {
|
|
|
2067
2660
|
const height = boundedInteger(value.height, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION);
|
|
2068
2661
|
const fps = boundedInteger(value.fps, MAX_LIVE_VIDEO_FRAME_RATE, MIN_LIVE_VIDEO_FRAME_RATE);
|
|
2069
2662
|
const mtu = boundedInteger(value.mtu, MAX_LIVE_VIDEO_MTU, MIN_LIVE_VIDEO_MTU);
|
|
2663
|
+
const maxBitRate = boundedInteger(value.maxBitRate, MAX_LIVE_VIDEO_BIT_RATE, 0);
|
|
2070
2664
|
const addressVersion = typeof value.addressVersion === 'string' ? value.addressVersion : undefined;
|
|
2071
2665
|
if (!operation ||
|
|
2072
2666
|
!HOMEKIT_LIVE_VIDEO_OPERATIONS.has(operation) ||
|
|
@@ -2082,6 +2676,7 @@ function sanitizeLiveVideoSelection(value) {
|
|
|
2082
2676
|
!HOMEKIT_LIVE_ADDRESS_VERSIONS.has(addressVersion)) {
|
|
2083
2677
|
return undefined;
|
|
2084
2678
|
}
|
|
2679
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
2085
2680
|
return {
|
|
2086
2681
|
adapter: 'camera.streaming',
|
|
2087
2682
|
event: 'live-video-selected',
|
|
@@ -2092,7 +2687,9 @@ function sanitizeLiveVideoSelection(value) {
|
|
|
2092
2687
|
height,
|
|
2093
2688
|
fps,
|
|
2094
2689
|
mtu,
|
|
2690
|
+
...(maxBitRate === undefined ? {} : { maxBitRate }),
|
|
2095
2691
|
addressVersion,
|
|
2692
|
+
...(alias === undefined ? {} : { accessory: alias }),
|
|
2096
2693
|
};
|
|
2097
2694
|
}
|
|
2098
2695
|
/** Emits bounded normal-output condition transitions without stable device or account identity. */
|
|
@@ -2151,10 +2748,11 @@ export class DiagnosticConditions {
|
|
|
2151
2748
|
}
|
|
2152
2749
|
const uniqueDeviceIds = condition.active ? [...new Set(affectedDeviceIds)].sort() : [];
|
|
2153
2750
|
const accessoryAliases = uniqueDeviceIds
|
|
2154
|
-
.map((identity) => this.
|
|
2751
|
+
.map((identity) => this.aliasFor(identity))
|
|
2155
2752
|
.filter((alias) => alias !== undefined)
|
|
2156
2753
|
.sort();
|
|
2157
|
-
|
|
2754
|
+
const { level, actionKey } = homeKitConditionPresentation(condition.code, condition.active, condition.reason);
|
|
2755
|
+
this.write(condition.code, condition.active, condition.reason, definition[0], actionKey, level, {
|
|
2158
2756
|
...(condition.capability === undefined ? {} : { capability: condition.capability }),
|
|
2159
2757
|
...(condition.member === undefined ? {} : { member: condition.member }),
|
|
2160
2758
|
affectedAccessoryCount: uniqueDeviceIds.length,
|
|
@@ -2166,9 +2764,19 @@ export class DiagnosticConditions {
|
|
|
2166
2764
|
const alias = this.aliases.get(serial);
|
|
2167
2765
|
return name === undefined ? undefined : alias === undefined ? name : `${name} (${alias})`;
|
|
2168
2766
|
})
|
|
2169
|
-
.filter((named) => named !== undefined));
|
|
2767
|
+
.filter((named) => named !== undefined), uniqueDeviceIds);
|
|
2768
|
+
}
|
|
2769
|
+
/** Every condition active now, each held from the transition that raised it to the one that clears it. */
|
|
2770
|
+
current() {
|
|
2771
|
+
return [...this.active.values()].map(({ condition }) => condition);
|
|
2170
2772
|
}
|
|
2171
|
-
|
|
2773
|
+
/**
|
|
2774
|
+
* The support-case alias for one device identity, minted on first use.
|
|
2775
|
+
*
|
|
2776
|
+
* The one place an alias is decided, so every record about a device — a condition, a media trace — carries
|
|
2777
|
+
* the same one, and the owner's console line is what resolves it to a name.
|
|
2778
|
+
*/
|
|
2779
|
+
aliasFor(identity) {
|
|
2172
2780
|
let alias = this.aliases.get(identity);
|
|
2173
2781
|
if (!alias && this.aliases.size < MAX_ACCESSORY_ALIASES) {
|
|
2174
2782
|
alias = `accessory-${randomUUID()}`;
|
|
@@ -2178,13 +2786,16 @@ export class DiagnosticConditions {
|
|
|
2178
2786
|
}
|
|
2179
2787
|
write(code, active, reason, summaryKey, actionKey, level, fields = {}, conditionKey = code,
|
|
2180
2788
|
/** Accessory names for the console line only — never retained, see {@link DiagnosticConditions}. */
|
|
2181
|
-
names) {
|
|
2182
|
-
const fingerprint = JSON.stringify({ active, reason, ...fields });
|
|
2789
|
+
names, serials = []) {
|
|
2790
|
+
const fingerprint = JSON.stringify({ active, reason, ...fields, serials });
|
|
2183
2791
|
if (active) {
|
|
2184
|
-
if (this.active.get(conditionKey) === fingerprint) {
|
|
2792
|
+
if (this.active.get(conditionKey)?.fingerprint === fingerprint) {
|
|
2185
2793
|
return;
|
|
2186
2794
|
}
|
|
2187
|
-
this.active.set(conditionKey,
|
|
2795
|
+
this.active.set(conditionKey, {
|
|
2796
|
+
fingerprint,
|
|
2797
|
+
condition: { code, summaryKey, actionKey, ...(serials.length === 0 ? {} : { serials: [...serials] }) },
|
|
2798
|
+
});
|
|
2188
2799
|
}
|
|
2189
2800
|
else if (!this.active.delete(conditionKey)) {
|
|
2190
2801
|
return;
|