@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.4 → 5.0.0-beta.40
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/CONTRIBUTING.md +9 -17
- package/config.schema.json +60 -0
- package/dist/account/ownership.js +16 -16
- package/dist/account/ownership.js.map +1 -1
- package/dist/account/persistence.js +24 -74
- package/dist/account/persistence.js.map +1 -1
- package/dist/account/temporary-authentication.js +5 -3
- package/dist/account/temporary-authentication.js.map +1 -1
- package/dist/configuration.js +50 -13
- package/dist/configuration.js.map +1 -1
- package/dist/device/member-evidence.js +11 -4
- package/dist/device/member-evidence.js.map +1 -1
- package/dist/device/member-trust.js +14 -32
- package/dist/device/member-trust.js.map +1 -1
- package/dist/device/snapshot.js +6 -18
- package/dist/device/snapshot.js.map +1 -1
- package/dist/diagnostics.js +951 -334
- package/dist/diagnostics.js.map +1 -1
- package/dist/homekit/adapter.js +4 -1
- package/dist/homekit/adapter.js.map +1 -1
- package/dist/homekit/adapters/battery.js +3 -3
- package/dist/homekit/adapters/battery.js.map +1 -1
- package/dist/homekit/adapters/camera-controls.js +15 -37
- package/dist/homekit/adapters/camera-controls.js.map +1 -1
- package/dist/homekit/adapters/camera-streaming.js +74 -48
- package/dist/homekit/adapters/camera-streaming.js.map +1 -1
- package/dist/homekit/adapters/contact.js +5 -5
- package/dist/homekit/adapters/contact.js.map +1 -1
- package/dist/homekit/adapters/doorbell.js +3 -15
- package/dist/homekit/adapters/doorbell.js.map +1 -1
- package/dist/homekit/adapters/lock.js +72 -221
- package/dist/homekit/adapters/lock.js.map +1 -1
- package/dist/homekit/adapters/motion.js +3 -3
- 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 +106 -250
- package/dist/homekit/adapters/security-system.js.map +1 -1
- package/dist/homekit/adapters/siren.js +5 -33
- package/dist/homekit/adapters/siren.js.map +1 -1
- package/dist/homekit/adapters/smart-light.js +19 -217
- package/dist/homekit/adapters/smart-light.js.map +1 -1
- package/dist/homekit/coverage-matrix.js +8 -67
- package/dist/homekit/coverage-matrix.js.map +1 -1
- package/dist/homekit/device-control.js +249 -13
- package/dist/homekit/device-control.js.map +1 -1
- package/dist/homekit/reconciler.js +25 -9
- package/dist/homekit/reconciler.js.map +1 -1
- package/dist/media/last-successful-image.js +8 -23
- package/dist/media/last-successful-image.js.map +1 -1
- package/dist/media/live-preview.js +217 -0
- package/dist/media/live-preview.js.map +1 -0
- package/dist/media/live-stream.js +689 -160
- package/dist/media/live-stream.js.map +1 -1
- package/dist/media/recording.js +41 -118
- package/dist/media/recording.js.map +1 -1
- package/dist/media/snapshot.js +22 -17
- package/dist/media/snapshot.js.map +1 -1
- package/dist/media/station-live-sessions.js +40 -51
- package/dist/media/station-live-sessions.js.map +1 -1
- package/dist/platform.js +61 -10
- package/dist/platform.js.map +1 -1
- package/dist/runtime/channel.js +227 -40
- 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 +30 -5
- package/dist/runtime/owner.js.map +1 -1
- package/dist/runtime/sdk-client.js +21 -17
- package/dist/runtime/sdk-client.js.map +1 -1
- package/dist/runtime/tracker.js +8 -38
- package/dist/runtime/tracker.js.map +1 -1
- package/dist/storage.js +1 -1
- package/dist/storage.js.map +1 -1
- package/dist/ui/dashboard.js +36 -16
- package/dist/ui/dashboard.js.map +1 -1
- package/dist/ui/runtime-channel-client.js +155 -184
- package/dist/ui/runtime-channel-client.js.map +1 -1
- package/dist/ui/server.js +81 -38
- package/dist/ui/server.js.map +1 -1
- package/homebridge-ui/public/app.css +531 -239
- package/homebridge-ui/public/assets/icons/auto_fix_high.svg +1 -0
- 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/play_arrow.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/stop.svg +1 -0
- package/homebridge-ui/public/assets/icons/videocam_off.svg +1 -0
- package/homebridge-ui/public/i18n/en.json +160 -82
- package/homebridge-ui/public/i18n/fr.json +161 -83
- package/homebridge-ui/public/index.html +225 -96
- package/homebridge-ui/public/js/app.js +814 -435
- package/homebridge-ui/public/js/dashboard.js +148 -31
- package/i18n/runtime/en.json +10 -3
- package/package.json +10 -11
- package/homebridge-ui/public/js/legacy-settings.js +0 -37
- package/homebridge-ui/public/js/profile-wizard.js +0 -61
package/dist/diagnostics.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants, createCipheriv, createHash, publicEncrypt, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { appendFile, chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
@@ -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
|
/**
|
|
@@ -46,7 +87,29 @@ const HOMEKIT_LIVE_REQUEST_EVENTS = new Set([
|
|
|
46
87
|
'live-session-failed',
|
|
47
88
|
'live-session-released',
|
|
48
89
|
'live-session-streaming',
|
|
90
|
+
'live-talkback-started',
|
|
49
91
|
]);
|
|
92
|
+
/**
|
|
93
|
+
* The error identities an SDK record may name, which is all a record carries of one.
|
|
94
|
+
*
|
|
95
|
+
* A message is never among them, so an identity absent here is recorded as `Error` and reads as any other
|
|
96
|
+
* failure.
|
|
97
|
+
*/
|
|
98
|
+
const SDK_ERROR_TYPES = [
|
|
99
|
+
'DeviceChannelUnresolvedError',
|
|
100
|
+
'Error',
|
|
101
|
+
'RangeError',
|
|
102
|
+
'SessionExpiredError',
|
|
103
|
+
'StationKeyUnavailableError',
|
|
104
|
+
'StationUnreachableError',
|
|
105
|
+
'TypeError',
|
|
106
|
+
];
|
|
107
|
+
/**
|
|
108
|
+
* The retained fields that stand for a device rather than describing one, which is the privacy class a manifest
|
|
109
|
+
* declares for them. Each holds a per-case alias: stable within one archive, and resolvable to a device only by
|
|
110
|
+
* the owner whose console paired it with a name.
|
|
111
|
+
*/
|
|
112
|
+
const PSEUDONYMOUS_FIELDS = new Set(['accessory', 'accessoryAliases']);
|
|
50
113
|
const MAX_SDK_DETAILS = 16;
|
|
51
114
|
const MAX_LOG_RECORD_BYTES = 64 * 1024;
|
|
52
115
|
const MAX_CURRENT_LOG_BYTES = 50 * 1024 * 1024;
|
|
@@ -71,7 +134,12 @@ const DIAGNOSTICS_DIRECTORY = 'diagnostics';
|
|
|
71
134
|
const GUIDED_SESSION_FILE = 'session.json';
|
|
72
135
|
const REPRODUCTION_MARKERS_FILE = 'reproduction-markers.jsonl';
|
|
73
136
|
const UI_EVENTS_FILE = 'ui-events.jsonl';
|
|
74
|
-
const
|
|
137
|
+
const HOST_ENVIRONMENT_FILE = 'host.json';
|
|
138
|
+
const FLEET_FILE = 'fleet.json';
|
|
139
|
+
/** The longest device model or adapter key retained, which bounds values this plugin does not choose alone. */
|
|
140
|
+
const MAX_DEVICE_MODEL_LENGTH = 64;
|
|
141
|
+
/** How many HomeKit services one fleet entry names, which is above what any single device is represented by. */
|
|
142
|
+
const MAX_FLEET_SERVICES = 12;
|
|
75
143
|
/** How much of an FFmpeg path or version banner is kept, which is more than either needs. */
|
|
76
144
|
const MAX_FFMPEG_IDENTITY_LENGTH = 256;
|
|
77
145
|
const DEBUG_AUTHORIZATION_MS = 72 * 60 * 60 * 1_000;
|
|
@@ -83,6 +151,14 @@ const DEBUG_AUTHORIZATION_MS = 72 * 60 * 60 * 1_000;
|
|
|
83
151
|
* rather than joining a fixed number of segments because the plugin's own module is a directory deeper once
|
|
84
152
|
* built, and an SDK resolved through a link answers from wherever the link points.
|
|
85
153
|
*/
|
|
154
|
+
function loadedBuild(module) {
|
|
155
|
+
try {
|
|
156
|
+
return `${PLUGIN_VERSION}+${Math.trunc(statSync(module).mtimeMs)}`;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return PLUGIN_VERSION;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
86
162
|
function loadedVersion(from) {
|
|
87
163
|
if (from === undefined) {
|
|
88
164
|
return 'unknown';
|
|
@@ -124,6 +200,13 @@ function loadedSdkEntry() {
|
|
|
124
200
|
* between them: after one lands, the runtime keeps answering with the build it started on until it is replaced.
|
|
125
201
|
*/
|
|
126
202
|
export const PLUGIN_VERSION = loadedVersion(fileURLToPath(import.meta.url));
|
|
203
|
+
/**
|
|
204
|
+
* Which build of {@link PLUGIN_VERSION} this process loaded: the version and the time this module was written.
|
|
205
|
+
*
|
|
206
|
+
* Every build rewrites the module, so two builds of one version differ here while a published package, whose
|
|
207
|
+
* files all carry one fixed time, differs only by its version. An unreadable module degrades to the version alone.
|
|
208
|
+
*/
|
|
209
|
+
export const PLUGIN_BUILD = loadedBuild(fileURLToPath(import.meta.url));
|
|
127
210
|
const SDK_VERSION = loadedVersion(loadedSdkEntry());
|
|
128
211
|
const gzip = promisify(gzipCallback);
|
|
129
212
|
const gunzip = promisify(gunzipCallback);
|
|
@@ -145,6 +228,9 @@ const SDK_EVENT_KEYS = new Set([
|
|
|
145
228
|
'observation-invalid',
|
|
146
229
|
'protocol-command',
|
|
147
230
|
'protocol-unhandled',
|
|
231
|
+
'push-received',
|
|
232
|
+
'realtime-readiness',
|
|
233
|
+
'rtc-sign-refused',
|
|
148
234
|
'session-connecting',
|
|
149
235
|
'session-idle',
|
|
150
236
|
'session-resumed',
|
|
@@ -153,6 +239,15 @@ const SDK_EVENT_KEYS = new Set([
|
|
|
153
239
|
'transport-error',
|
|
154
240
|
]);
|
|
155
241
|
const EN_MESSAGES = JSON.parse(readFileSync(new URL('../i18n/runtime/en.json', import.meta.url), 'utf8'));
|
|
242
|
+
/**
|
|
243
|
+
* The reporter's answer as a support archive carries it: every device, or how many they named.
|
|
244
|
+
*
|
|
245
|
+
* The serials themselves stay on the host. They identify one household's hardware, and no evidence class in an
|
|
246
|
+
* archive carries a device identity.
|
|
247
|
+
*/
|
|
248
|
+
function countedAffectedDevices(affected) {
|
|
249
|
+
return affected === 'all' ? 'all' : new Set(affected).size;
|
|
250
|
+
}
|
|
156
251
|
/** The bounded UI events a support session may record, and the only vocabulary the sink accepts. */
|
|
157
252
|
const DIAGNOSTICS_UI_EVENTS = [
|
|
158
253
|
'background-started',
|
|
@@ -208,22 +303,31 @@ const BUG_REPORT_FORM = 'https://github.com/homebridge-plugins/homebridge-eufy/i
|
|
|
208
303
|
*
|
|
209
304
|
* Addresses the committed issue form by filename, and fills the field ids that form declares. A prefill is
|
|
210
305
|
* honoured only against a named template, and only the operational class travels in a query string.
|
|
306
|
+
*
|
|
307
|
+
* Every field this process can answer is answered, because a blank one is charged to the reporter: the form's
|
|
308
|
+
* own triage asks for what the environment block leaves empty, so a line left for the reporter to complete
|
|
309
|
+
* turns a report made the intended way into an incomplete one. The host version is the host's own public
|
|
310
|
+
* version and empty where no run has recorded it yet. The support case id is not among them — it sits above
|
|
311
|
+
* the operational class, and the archive's own filename carries it to the report that attaches one.
|
|
312
|
+
*
|
|
313
|
+
* The archive field is left unaddressed, because a query string cannot carry a file. Any text placed there
|
|
314
|
+
* satisfies the field's own `required` validation, so a prefill would answer it on the reporter's behalf and
|
|
315
|
+
* the form would accept a report with no archive on it.
|
|
211
316
|
*/
|
|
212
|
-
function bugReportUrl(session, missingEvidence) {
|
|
317
|
+
function bugReportUrl(session, missingEvidence, host) {
|
|
213
318
|
const url = new URL(BUG_REPORT_FORM);
|
|
214
319
|
url.searchParams.set('template', 'bug_report.yml');
|
|
320
|
+
url.searchParams.set('title', `[Diagnostics] ${BUG_REPORT_AREAS[session.profile]}: `);
|
|
215
321
|
url.searchParams.set('environment', [
|
|
216
322
|
`- **Plugin Version**: ${PLUGIN_VERSION}`,
|
|
217
323
|
`- **eufy SDK**: ${SDK_VERSION}`,
|
|
218
324
|
`- **Node.js Version**: ${process.version}`,
|
|
219
|
-
`- **Homebridge Version**: `,
|
|
325
|
+
`- **Homebridge Version**: ${host?.homebridge ?? ''}`,
|
|
220
326
|
`- **OS**: ${process.platform} ${process.arch}`,
|
|
221
|
-
`- **
|
|
222
|
-
`- **Diagnostics profile**: ${session.profile} (${session.reproductionMode})`,
|
|
327
|
+
`- **Diagnostics profile**: ${session.profile}`,
|
|
223
328
|
`- **Missing evidence**: ${missingEvidence.length ? missingEvidence.join(', ') : 'none'}`,
|
|
224
329
|
].join('\n'));
|
|
225
330
|
url.searchParams.set('area', BUG_REPORT_AREAS[session.profile]);
|
|
226
|
-
url.searchParams.set('diagnostics', 'Attach the homebridge-eufy-<case-id>.eufysupport.gz file the diagnostics page downloaded.');
|
|
227
331
|
return url.toString();
|
|
228
332
|
}
|
|
229
333
|
const DIAGNOSTICS_PROFILES = {
|
|
@@ -233,7 +337,7 @@ const DIAGNOSTICS_PROFILES = {
|
|
|
233
337
|
'live-media': ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
|
|
234
338
|
'hksv-recording': ['plugin-log', 'sdk-log', 'ffmpeg-log'],
|
|
235
339
|
'dashboard-ui': ['plugin-log', 'ui-log'],
|
|
236
|
-
other: ['plugin-log', 'sdk-log', 'homekit-log'],
|
|
340
|
+
other: ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
|
|
237
341
|
};
|
|
238
342
|
/** Narrows external profile input against the diagnostics-owned profile registry. */
|
|
239
343
|
export function isDiagnosticsProfile(value) {
|
|
@@ -283,6 +387,22 @@ function activeDiagnosticsSession(storageRoot, now) {
|
|
|
283
387
|
const session = readDiagnosticsSession(storageRoot);
|
|
284
388
|
return session && Date.parse(session.expiresAt) > now ? session : undefined;
|
|
285
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* A session as it stands at `now`: a capture still running when its authorization ends is finished at that end.
|
|
392
|
+
*
|
|
393
|
+
* A finished capture can be reviewed and exported until the archive retention has elapsed past the end of its
|
|
394
|
+
* authorization, so a capture that ran out its 72 hours keeps what it captured.
|
|
395
|
+
*/
|
|
396
|
+
function settledDiagnosticsSession(session, now) {
|
|
397
|
+
const expiresAt = Date.parse(session.expiresAt);
|
|
398
|
+
const settled = session.reproductionStartedAt && !session.reproductionEndedAt && expiresAt <= now
|
|
399
|
+
? { ...session, reproductionEndedAt: session.expiresAt }
|
|
400
|
+
: session;
|
|
401
|
+
return {
|
|
402
|
+
session: settled,
|
|
403
|
+
exportable: Boolean(settled.reproductionEndedAt) && now < expiresAt + SUPPORT_ARCHIVE_RETENTION_MS,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
286
406
|
/**
|
|
287
407
|
* Collapses one supplied string to bounded printable text, or nothing where none of it survives.
|
|
288
408
|
*
|
|
@@ -299,54 +419,170 @@ function boundedText(value, maximumLength) {
|
|
|
299
419
|
.trim();
|
|
300
420
|
return printable === '' ? undefined : printable;
|
|
301
421
|
}
|
|
302
|
-
|
|
303
|
-
|
|
422
|
+
/** Whether a value is a plain JSON object, which is the only shape a record crossing a process boundary has. */
|
|
423
|
+
function isRecord(value) {
|
|
424
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
425
|
+
}
|
|
426
|
+
function fleetPath(storageRoot) {
|
|
427
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, FLEET_FILE);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Records what the account's devices are and what each becomes, replacing the record whole.
|
|
431
|
+
*
|
|
432
|
+
* Written by the process that holds both the inventory and the accessory aliases, because an archive is
|
|
433
|
+
* assembled by another one. Rewritten on every complete inventory, so a device withdrawn or newly recognised
|
|
434
|
+
* cannot be reported as still present. A write that fails is dropped: an archive then declares no fleet, which
|
|
435
|
+
* is honest, whereas failing over a diagnostic file would cost the user their cameras.
|
|
436
|
+
*/
|
|
437
|
+
export function recordFleet(storageRoot, fleet) {
|
|
438
|
+
try {
|
|
439
|
+
const path = fleetPath(storageRoot);
|
|
440
|
+
mkdirSync(dirname(path), { mode: 0o700, recursive: true });
|
|
441
|
+
writeFileSync(path, `${JSON.stringify({ version: 1, devices: fleet })}\n`, { mode: 0o600 });
|
|
442
|
+
}
|
|
443
|
+
catch { }
|
|
444
|
+
}
|
|
445
|
+
/** Reads the recorded fleet, keeping each entry whose own fields narrow and dropping each that does not. */
|
|
446
|
+
function readFleet(storageRoot) {
|
|
447
|
+
try {
|
|
448
|
+
const candidate = JSON.parse(readFileSync(fleetPath(storageRoot), 'utf8'));
|
|
449
|
+
if (candidate.version !== 1 || !Array.isArray(candidate.devices)) {
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
const devices = candidate.devices.flatMap((row) => {
|
|
453
|
+
if (!isRecord(row))
|
|
454
|
+
return [];
|
|
455
|
+
const accessory = opaqueAccessoryAlias(row.accessory);
|
|
456
|
+
if (accessory === undefined || typeof row.represented !== 'boolean' || typeof row.attached !== 'boolean') {
|
|
457
|
+
return [];
|
|
458
|
+
}
|
|
459
|
+
const model = typeof row.model === 'string' ? boundedText(row.model, MAX_DEVICE_MODEL_LENGTH) : undefined;
|
|
460
|
+
const services = (Array.isArray(row.services) ? row.services : [])
|
|
461
|
+
.filter((service) => typeof service === 'string')
|
|
462
|
+
.slice(0, MAX_FLEET_SERVICES)
|
|
463
|
+
.flatMap((service) => {
|
|
464
|
+
const bounded = boundedText(service, MAX_DEVICE_MODEL_LENGTH);
|
|
465
|
+
return bounded === undefined ? [] : [bounded];
|
|
466
|
+
});
|
|
467
|
+
return [
|
|
468
|
+
{
|
|
469
|
+
accessory,
|
|
470
|
+
...(model === undefined ? {} : { model }),
|
|
471
|
+
represented: row.represented,
|
|
472
|
+
attached: row.attached,
|
|
473
|
+
services,
|
|
474
|
+
},
|
|
475
|
+
];
|
|
476
|
+
});
|
|
477
|
+
return devices.length === 0 ? undefined : devices.slice(0, MAX_ACCESSORY_ALIASES);
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
304
482
|
}
|
|
483
|
+
function hostEnvironmentPath(storageRoot) {
|
|
484
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, HOST_ENVIRONMENT_FILE);
|
|
485
|
+
}
|
|
486
|
+
/** The longest host version string retained, which bounds a value this plugin does not choose. */
|
|
487
|
+
const MAX_HOST_VERSION_LENGTH = 64;
|
|
305
488
|
/**
|
|
306
|
-
* Records
|
|
489
|
+
* Records what this run is hosted by, so a support archive states the host a failure came from.
|
|
490
|
+
*
|
|
491
|
+
* It is persisted rather than held in memory because the process that knows these facts is not the one that
|
|
492
|
+
* assembles an archive, and it is rewritten on every start so a host or binary that changed between runs
|
|
493
|
+
* cannot be reported as the one in use. Each call replaces the record whole, so a caller states everything it
|
|
494
|
+
* knows rather than accumulating into a file two writers would race for.
|
|
307
495
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* cannot be reported as the one in use. A write that fails is dropped: the archive then declares the
|
|
311
|
-
* environment without an FFmpeg identity, which is honest, whereas failing startup over a diagnostic file
|
|
312
|
-
* would cost the user their cameras.
|
|
496
|
+
* A write that fails is dropped: the archive then declares the environment without these facts, which is
|
|
497
|
+
* honest, whereas failing startup over a diagnostic file would cost the user their cameras.
|
|
313
498
|
*/
|
|
314
|
-
export function
|
|
499
|
+
export function recordHostEnvironment(storageRoot, host) {
|
|
315
500
|
try {
|
|
316
|
-
const path =
|
|
501
|
+
const path = hostEnvironmentPath(storageRoot);
|
|
317
502
|
mkdirSync(dirname(path), { mode: 0o700, recursive: true });
|
|
318
|
-
writeFileSync(path, `${JSON.stringify({ version:
|
|
503
|
+
writeFileSync(path, `${JSON.stringify({ version: 2, ...host })}\n`, { mode: 0o600 });
|
|
319
504
|
}
|
|
320
505
|
catch { }
|
|
321
506
|
}
|
|
322
507
|
/**
|
|
323
|
-
* Reads
|
|
508
|
+
* Reads what this run is hosted by, keeping each fact that narrows and dropping each that does not.
|
|
324
509
|
*
|
|
325
|
-
* The
|
|
326
|
-
*
|
|
327
|
-
* path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
|
|
510
|
+
* The facts are independent, so a malformed binary identity does not withhold a host version, and a record
|
|
511
|
+
* stating neither is no record at all.
|
|
328
512
|
*/
|
|
329
|
-
function
|
|
513
|
+
function readHostEnvironment(storageRoot) {
|
|
330
514
|
try {
|
|
331
|
-
const candidate = JSON.parse(readFileSync(
|
|
332
|
-
|
|
333
|
-
if (candidate.version !== 1 || !ffmpeg || typeof ffmpeg !== 'object' || Array.isArray(ffmpeg)) {
|
|
515
|
+
const candidate = JSON.parse(readFileSync(hostEnvironmentPath(storageRoot), 'utf8'));
|
|
516
|
+
if (candidate.version !== 2) {
|
|
334
517
|
return undefined;
|
|
335
518
|
}
|
|
336
|
-
const
|
|
337
|
-
|
|
519
|
+
const homebridge = typeof candidate.homebridge === 'string' ? boundedText(candidate.homebridge, MAX_HOST_VERSION_LENGTH) : undefined;
|
|
520
|
+
const ffmpeg = readFfmpegIdentity(candidate.ffmpeg);
|
|
521
|
+
if (homebridge === undefined && ffmpeg === undefined) {
|
|
338
522
|
return undefined;
|
|
339
523
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
: { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
|
|
524
|
+
return {
|
|
525
|
+
...(homebridge === undefined ? {} : { homebridge }),
|
|
526
|
+
...(ffmpeg === undefined ? {} : { ffmpeg }),
|
|
527
|
+
};
|
|
345
528
|
}
|
|
346
529
|
catch {
|
|
347
530
|
return undefined;
|
|
348
531
|
}
|
|
349
532
|
}
|
|
533
|
+
/**
|
|
534
|
+
* The recorded adaptation binary, or nothing where the record's own fields do not narrow.
|
|
535
|
+
*
|
|
536
|
+
* The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
|
|
537
|
+
* entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
|
|
538
|
+
* path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
|
|
539
|
+
*/
|
|
540
|
+
function readFfmpegIdentity(recorded) {
|
|
541
|
+
if (!isRecord(recorded)) {
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
if (recorded.source !== 'bundled' && recorded.source !== 'configured') {
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
const path = typeof recorded.path === 'string' ? boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
|
|
548
|
+
const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
|
|
549
|
+
const encoders = isRecord(recorded.encoders) ? readEncoderQualification(recorded.encoders) : undefined;
|
|
550
|
+
return path === undefined
|
|
551
|
+
? undefined
|
|
552
|
+
: {
|
|
553
|
+
path,
|
|
554
|
+
source: recorded.source,
|
|
555
|
+
...(version === undefined ? {} : { version }),
|
|
556
|
+
...(encoders === undefined ? {} : { encoders }),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* The recorded encoder qualification, keeping only encoder names, `profile@level` selections and outcomes this
|
|
561
|
+
* build names, so the record is bounded by those three closed sets, and a `bytes` outcome's two integers.
|
|
562
|
+
*/
|
|
563
|
+
function readEncoderQualification(recorded) {
|
|
564
|
+
return Object.fromEntries(LIVE_VIDEO_ENCODERS.filter((name) => Object.hasOwn(recorded, name)).map((name) => {
|
|
565
|
+
const outcomes = isRecord(recorded[name]) ? Object.entries(recorded[name]) : [];
|
|
566
|
+
return [
|
|
567
|
+
name,
|
|
568
|
+
Object.fromEntries(outcomes.flatMap(([selection, value]) => {
|
|
569
|
+
const [profile, level] = selection.split('@');
|
|
570
|
+
const outcome = Array.isArray(value) &&
|
|
571
|
+
value.length === 3 &&
|
|
572
|
+
value[0] === 'bytes' &&
|
|
573
|
+
value.slice(1).every(Number.isSafeInteger)
|
|
574
|
+
? value
|
|
575
|
+
: allowlistedLabel(value, QUALIFICATION_OUTCOMES);
|
|
576
|
+
return allowlistedLabel(profile, HOMEKIT_LIVE_VIDEO_PROFILES) &&
|
|
577
|
+
allowlistedLabel(level, HOMEKIT_LIVE_VIDEO_LEVELS) &&
|
|
578
|
+
selection === `${profile}@${level}` &&
|
|
579
|
+
outcome
|
|
580
|
+
? [[selection, outcome]]
|
|
581
|
+
: [];
|
|
582
|
+
})),
|
|
583
|
+
];
|
|
584
|
+
}));
|
|
585
|
+
}
|
|
350
586
|
/**
|
|
351
587
|
* The one log evidence class a retained record belongs to.
|
|
352
588
|
*
|
|
@@ -440,6 +676,22 @@ export class GuidedDiagnostics {
|
|
|
440
676
|
await this.ensureMarker(session, 'reproduction-started', session.reproductionStartedAt);
|
|
441
677
|
return this.project(session);
|
|
442
678
|
}
|
|
679
|
+
/**
|
|
680
|
+
* Deletes the persisted session without an archive, which also ends the verbose retention it authorized. UI events
|
|
681
|
+
* already queued are written first and later ones are refused until it is gone, so none lands after the delete.
|
|
682
|
+
*/
|
|
683
|
+
async cancel() {
|
|
684
|
+
this.uiEventsClosing = true;
|
|
685
|
+
try {
|
|
686
|
+
await this.uiEventWrites;
|
|
687
|
+
this.pendingSupportArchive = undefined;
|
|
688
|
+
await rm(diagnosticsSessionPath(this.storageRoot), { force: true });
|
|
689
|
+
return await this.status();
|
|
690
|
+
}
|
|
691
|
+
finally {
|
|
692
|
+
this.uiEventsClosing = false;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
443
695
|
async endReproduction() {
|
|
444
696
|
let session = this.requireAuthorized();
|
|
445
697
|
if (!session.reproductionStartedAt) {
|
|
@@ -520,10 +772,16 @@ export class GuidedDiagnostics {
|
|
|
520
772
|
/** Prepares the exact manifest and evidence snapshot that one subsequent export may encrypt. */
|
|
521
773
|
async reviewSupportArchive() {
|
|
522
774
|
await this.uiEventWrites;
|
|
523
|
-
const
|
|
775
|
+
const persisted = readDiagnosticsSession(this.storageRoot);
|
|
776
|
+
const { session, exportable } = persisted
|
|
777
|
+
? settledDiagnosticsSession(persisted, this.now())
|
|
778
|
+
: { session: undefined, exportable: false };
|
|
524
779
|
if (!session?.reproductionStartedAt || !session.reproductionEndedAt) {
|
|
525
780
|
throw new Error('A completed reproduction is required before archive review');
|
|
526
781
|
}
|
|
782
|
+
if (!exportable) {
|
|
783
|
+
throw new Error('Diagnostics authorization is inactive or expired');
|
|
784
|
+
}
|
|
527
785
|
const collected = await this.collectSupportEvidence(session);
|
|
528
786
|
const reproductionStartedAt = Date.parse(session.reproductionStartedAt);
|
|
529
787
|
const selected = DIAGNOSTICS_PROFILES[session.profile];
|
|
@@ -566,7 +824,13 @@ export class GuidedDiagnostics {
|
|
|
566
824
|
this.pendingSupportArchive = { reviewId, manifest, evidence: collected };
|
|
567
825
|
return { reviewId, manifest };
|
|
568
826
|
}
|
|
569
|
-
/**
|
|
827
|
+
/**
|
|
828
|
+
* Consumes one reviewed snapshot and returns an encrypted envelope without writing plaintext or an archive to disk.
|
|
829
|
+
*
|
|
830
|
+
* The session outlives the export, because nothing here proves the envelope reached the reporter: a fresh review
|
|
831
|
+
* exports it again until the reporter leaves it with {@link cancel}, another capture replaces it, or the archive
|
|
832
|
+
* retention ends it.
|
|
833
|
+
*/
|
|
570
834
|
async exportSupportArchive(reviewId) {
|
|
571
835
|
const pending = this.pendingSupportArchive;
|
|
572
836
|
if (!pending || pending.reviewId !== reviewId) {
|
|
@@ -612,18 +876,20 @@ export class GuidedDiagnostics {
|
|
|
612
876
|
authTag: cipher.getAuthTag().toString('base64'),
|
|
613
877
|
ciphertext: ciphertext.toString('base64'),
|
|
614
878
|
};
|
|
615
|
-
|
|
879
|
+
const exported = {
|
|
616
880
|
filename: `homebridge-eufy-${pending.manifest.supportCaseId}.eufysupport.gz`,
|
|
617
881
|
mediaType: 'application/gzip',
|
|
618
882
|
archive: await gzip(Buffer.from(`${JSON.stringify(envelope)}\n`, 'utf8')),
|
|
619
883
|
};
|
|
884
|
+
return exported;
|
|
620
885
|
}
|
|
621
886
|
finally {
|
|
622
887
|
contentKey.fill(0);
|
|
623
888
|
}
|
|
624
889
|
}
|
|
625
890
|
async collectSupportEvidence(session) {
|
|
626
|
-
const
|
|
891
|
+
const host = readHostEnvironment(this.storageRoot);
|
|
892
|
+
const fleet = readFleet(this.storageRoot);
|
|
627
893
|
const evidence = [
|
|
628
894
|
...(session.affectedDevices === undefined
|
|
629
895
|
? []
|
|
@@ -632,15 +898,34 @@ export class GuidedDiagnostics {
|
|
|
632
898
|
evidence: 'reporter-statement',
|
|
633
899
|
privacyClass: 'diagnostic',
|
|
634
900
|
contentType: 'application/json',
|
|
635
|
-
content: `${JSON.stringify({ version:
|
|
901
|
+
content: `${JSON.stringify({ version: 2, affectedDevices: countedAffectedDevices(session.affectedDevices) })}\n`,
|
|
636
902
|
/**
|
|
637
|
-
* What the reporter answered, not what the plugin observed. A serial
|
|
638
|
-
*
|
|
639
|
-
* the
|
|
903
|
+
* What the reporter answered, not what the plugin observed. A serial is a device identity and no
|
|
904
|
+
* evidence class carries one, so the answer travels as its scope: every device, or how many were
|
|
905
|
+
* named. Which ones is the reporter's to say in their own words.
|
|
640
906
|
*/
|
|
641
907
|
fields: [
|
|
642
908
|
{ field: 'version', privacyClass: 'operational' },
|
|
643
|
-
{ field: 'affectedDevices', privacyClass: '
|
|
909
|
+
{ field: 'affectedDevices', privacyClass: 'operational' },
|
|
910
|
+
],
|
|
911
|
+
},
|
|
912
|
+
]),
|
|
913
|
+
...(fleet === undefined
|
|
914
|
+
? []
|
|
915
|
+
: [
|
|
916
|
+
{
|
|
917
|
+
evidence: 'fleet',
|
|
918
|
+
privacyClass: 'operational',
|
|
919
|
+
contentType: 'application/json',
|
|
920
|
+
content: `${JSON.stringify({ version: 1, devices: fleet })}\n`,
|
|
921
|
+
/**
|
|
922
|
+
* What each device is and becomes, against the alias every other record about it carries. A model
|
|
923
|
+
* and an admission outcome are product facts rather than device identity; the alias is what makes
|
|
924
|
+
* an entry resolvable to a device, and only by the owner whose console paired it with a name.
|
|
925
|
+
*/
|
|
926
|
+
fields: [
|
|
927
|
+
{ field: 'version', privacyClass: 'operational' },
|
|
928
|
+
{ field: 'devices', privacyClass: 'pseudonymous' },
|
|
644
929
|
],
|
|
645
930
|
},
|
|
646
931
|
]),
|
|
@@ -648,18 +933,22 @@ export class GuidedDiagnostics {
|
|
|
648
933
|
evidence: 'environment',
|
|
649
934
|
privacyClass: 'operational',
|
|
650
935
|
contentType: 'application/json',
|
|
651
|
-
content: `${JSON.stringify({ version:
|
|
936
|
+
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`,
|
|
652
937
|
/**
|
|
653
938
|
* The record is operational and one field is classified above it: a resolved FFmpeg path is an
|
|
654
939
|
* environment fact, but it can carry the home directory of the account Homebridge runs as, so it is
|
|
655
940
|
* declared as diagnostic rather than presented alongside the host's own architecture.
|
|
941
|
+
*
|
|
942
|
+
* The Homebridge version is operational and load-bearing: this plugin requires Homebridge 2, and a
|
|
943
|
+
* host that predates it loads the plugin and then fails in ways a media fault cannot be told from.
|
|
656
944
|
*/
|
|
657
945
|
fields: [
|
|
658
946
|
...['version', 'plugin', 'sdk', 'node', 'platform', 'arch'].map((field) => ({
|
|
659
947
|
field,
|
|
660
948
|
privacyClass: 'operational',
|
|
661
949
|
})),
|
|
662
|
-
...(
|
|
950
|
+
...(host?.homebridge ? [{ field: 'homebridge', privacyClass: 'operational' }] : []),
|
|
951
|
+
...(host?.ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
|
|
663
952
|
],
|
|
664
953
|
},
|
|
665
954
|
];
|
|
@@ -710,7 +999,11 @@ export class GuidedDiagnostics {
|
|
|
710
999
|
...(carried.retainedFrom === undefined ? {} : { retainedFrom: carried.retainedFrom }),
|
|
711
1000
|
fields: [...new Set(carried.records.flatMap((record) => Object.keys(record)))].sort().map((field) => ({
|
|
712
1001
|
field,
|
|
713
|
-
privacyClass: field
|
|
1002
|
+
privacyClass: PSEUDONYMOUS_FIELDS.has(field)
|
|
1003
|
+
? 'pseudonymous'
|
|
1004
|
+
: field === 'timestamp'
|
|
1005
|
+
? 'operational'
|
|
1006
|
+
: 'diagnostic',
|
|
714
1007
|
})),
|
|
715
1008
|
});
|
|
716
1009
|
}
|
|
@@ -873,16 +1166,17 @@ export class GuidedDiagnostics {
|
|
|
873
1166
|
throw new Error('Diagnostics authorization is inactive or expired');
|
|
874
1167
|
return session;
|
|
875
1168
|
}
|
|
876
|
-
async project(
|
|
877
|
-
if (!
|
|
1169
|
+
async project(persisted) {
|
|
1170
|
+
if (!persisted) {
|
|
878
1171
|
return { status: 'inactive', selectedEvidence: [], missingEvidence: [], partialExportAvailable: false };
|
|
879
1172
|
}
|
|
1173
|
+
const { session, exportable } = settledDiagnosticsSession(persisted, this.now());
|
|
880
1174
|
const selectedEvidence = DIAGNOSTICS_PROFILES[session.profile];
|
|
881
1175
|
const collected = session.reproductionStartedAt && session.reproductionEndedAt
|
|
882
1176
|
? await this.collectSupportEvidence(session)
|
|
883
1177
|
: undefined;
|
|
884
1178
|
const missingEvidence = collected ? missingEvidenceClasses(selectedEvidence, collected) : [];
|
|
885
|
-
const expired = Date.parse(session.expiresAt) <= this.now();
|
|
1179
|
+
const expired = Date.parse(session.expiresAt) <= this.now() && !exportable;
|
|
886
1180
|
const status = expired
|
|
887
1181
|
? 'expired'
|
|
888
1182
|
: session.reproductionEndedAt
|
|
@@ -901,9 +1195,9 @@ export class GuidedDiagnostics {
|
|
|
901
1195
|
expiresAt: session.expiresAt,
|
|
902
1196
|
...(session.reproductionStartedAt ? { reproductionStartedAt: session.reproductionStartedAt } : {}),
|
|
903
1197
|
...(session.reproductionEndedAt ? { reproductionEndedAt: session.reproductionEndedAt } : {}),
|
|
904
|
-
partialExportAvailable:
|
|
1198
|
+
partialExportAvailable: exportable,
|
|
905
1199
|
...(session.affectedDevices === undefined ? {} : { affectedDevices: session.affectedDevices }),
|
|
906
|
-
issueUrl: bugReportUrl(session, missingEvidence),
|
|
1200
|
+
issueUrl: bugReportUrl(session, missingEvidence, readHostEnvironment(this.storageRoot)),
|
|
907
1201
|
};
|
|
908
1202
|
}
|
|
909
1203
|
/**
|
|
@@ -944,8 +1238,8 @@ export class GuidedDiagnostics {
|
|
|
944
1238
|
await this.replaceOwnedFile(join(directory, REPRODUCTION_MARKERS_FILE), `${retained}${marker}`);
|
|
945
1239
|
}
|
|
946
1240
|
}
|
|
947
|
-
function formatMessage(
|
|
948
|
-
const template =
|
|
1241
|
+
function formatMessage(key, parameters = {}) {
|
|
1242
|
+
const template = EN_MESSAGES[key] ?? key;
|
|
949
1243
|
return template
|
|
950
1244
|
.replace(/\{([a-zA-Z0-9]+)\}/g, (placeholder, name) => parameters[name] === undefined ? placeholder : String(parameters[name]))
|
|
951
1245
|
.replace(/\s+/g, ' ')
|
|
@@ -953,7 +1247,7 @@ function formatMessage(catalog, key, parameters = {}) {
|
|
|
953
1247
|
}
|
|
954
1248
|
function localize(target, key, parameters = {}) {
|
|
955
1249
|
const translator = target.localize;
|
|
956
|
-
return translator?.(key, parameters) ?? formatMessage(
|
|
1250
|
+
return translator?.(key, parameters) ?? formatMessage(key, parameters);
|
|
957
1251
|
}
|
|
958
1252
|
class JsonLineLog {
|
|
959
1253
|
onError;
|
|
@@ -970,11 +1264,8 @@ class JsonLineLog {
|
|
|
970
1264
|
this.directory = join(storageRoot, LOG_DIRECTORY);
|
|
971
1265
|
this.path = join(this.directory, LOG_FILE);
|
|
972
1266
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
if (!payload) {
|
|
976
|
-
return;
|
|
977
|
-
}
|
|
1267
|
+
/** Queues one record the sanitizer already rebuilt. */
|
|
1268
|
+
write(payload) {
|
|
978
1269
|
const record = this.serialize(payload);
|
|
979
1270
|
const bytes = Buffer.byteLength(record);
|
|
980
1271
|
if (bytes > MAX_LOG_RECORD_BYTES || this.queuedBytes + bytes > MAX_QUEUED_LOG_BYTES) {
|
|
@@ -982,21 +1273,12 @@ class JsonLineLog {
|
|
|
982
1273
|
this.droppedRecords += 1;
|
|
983
1274
|
return;
|
|
984
1275
|
}
|
|
985
|
-
const
|
|
986
|
-
const droppedAt = this.droppedAt;
|
|
987
|
-
this.droppedRecords = 0;
|
|
988
|
-
this.droppedAt = undefined;
|
|
1276
|
+
const dropped = this.drainDropped();
|
|
989
1277
|
this.queuedBytes += bytes;
|
|
990
1278
|
this.pending = this.pending
|
|
991
1279
|
.then(async () => {
|
|
992
|
-
if (
|
|
993
|
-
await this.append(
|
|
994
|
-
scope: 'diagnostics',
|
|
995
|
-
level: 'warn',
|
|
996
|
-
event: 'records-dropped',
|
|
997
|
-
droppedRecords,
|
|
998
|
-
}, droppedAt));
|
|
999
|
-
}
|
|
1280
|
+
if (dropped)
|
|
1281
|
+
await this.append(dropped);
|
|
1000
1282
|
await this.append(record);
|
|
1001
1283
|
})
|
|
1002
1284
|
.catch(() => this.onError())
|
|
@@ -1005,22 +1287,21 @@ class JsonLineLog {
|
|
|
1005
1287
|
});
|
|
1006
1288
|
}
|
|
1007
1289
|
flush() {
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
this.droppedAt = undefined;
|
|
1012
|
-
if (droppedRecords > 0) {
|
|
1013
|
-
this.pending = this.pending
|
|
1014
|
-
.then(() => this.append(this.serialize({
|
|
1015
|
-
scope: 'diagnostics',
|
|
1016
|
-
level: 'warn',
|
|
1017
|
-
event: 'records-dropped',
|
|
1018
|
-
droppedRecords,
|
|
1019
|
-
}, droppedAt)))
|
|
1020
|
-
.catch(() => this.onError());
|
|
1290
|
+
const dropped = this.drainDropped();
|
|
1291
|
+
if (dropped) {
|
|
1292
|
+
this.pending = this.pending.then(() => this.append(dropped)).catch(() => this.onError());
|
|
1021
1293
|
}
|
|
1022
1294
|
return this.pending;
|
|
1023
1295
|
}
|
|
1296
|
+
/** The `records-dropped` record for what was refused since the last one, resetting the tally, or nothing. */
|
|
1297
|
+
drainDropped() {
|
|
1298
|
+
const { droppedRecords, droppedAt } = this;
|
|
1299
|
+
this.droppedRecords = 0;
|
|
1300
|
+
this.droppedAt = undefined;
|
|
1301
|
+
return droppedRecords === 0
|
|
1302
|
+
? undefined
|
|
1303
|
+
: this.serialize({ scope: 'diagnostics', level: 'warn', event: 'records-dropped', droppedRecords }, droppedAt);
|
|
1304
|
+
}
|
|
1024
1305
|
serialize(payload, timestamp = new Date(this.now()).toISOString()) {
|
|
1025
1306
|
return `${JSON.stringify({ ...payload, timestamp })}\n`;
|
|
1026
1307
|
}
|
|
@@ -1079,12 +1360,12 @@ class JsonLineLog {
|
|
|
1079
1360
|
* Routes human messages to Homebridge and structured events to the plugin JSONL log.
|
|
1080
1361
|
* File writes preserve accepted order; records beyond the bounded pending queue are counted and reported.
|
|
1081
1362
|
*/
|
|
1082
|
-
export function createDiagnosticLogger(target, storageRoot,
|
|
1363
|
+
export function createDiagnosticLogger(target, storageRoot, now = Date.now) {
|
|
1083
1364
|
let fileFailureReported = false;
|
|
1084
1365
|
const reportFileFailure = () => {
|
|
1085
1366
|
if (!fileFailureReported) {
|
|
1086
1367
|
fileFailureReported = true;
|
|
1087
|
-
target.warn(`[diagnostic-log-write-failed] ${formatMessage(
|
|
1368
|
+
target.warn(`[diagnostic-log-write-failed] ${formatMessage('log.diagnosticFileFailed')}`);
|
|
1088
1369
|
}
|
|
1089
1370
|
};
|
|
1090
1371
|
const file = storageRoot ? new JsonLineLog(storageRoot, reportFileFailure, now) : undefined;
|
|
@@ -1098,7 +1379,7 @@ export function createDiagnosticLogger(target, storageRoot, catalog = EN_MESSAGE
|
|
|
1098
1379
|
if (verbose && (!session || !DIAGNOSTICS_PROFILES[session.profile].includes(evidenceClassOf(event)))) {
|
|
1099
1380
|
return;
|
|
1100
1381
|
}
|
|
1101
|
-
file.write(
|
|
1382
|
+
file.write(event);
|
|
1102
1383
|
}
|
|
1103
1384
|
: undefined;
|
|
1104
1385
|
return {
|
|
@@ -1106,13 +1387,15 @@ export function createDiagnosticLogger(target, storageRoot, catalog = EN_MESSAGE
|
|
|
1106
1387
|
error: (message) => target.error(message),
|
|
1107
1388
|
flush: () => file?.flush() ?? Promise.resolve(),
|
|
1108
1389
|
info: (message) => target.info(message),
|
|
1109
|
-
localize: (key, parameters) => formatMessage(
|
|
1390
|
+
localize: (key, parameters) => formatMessage(key, parameters),
|
|
1110
1391
|
warn: (message) => target.warn(message),
|
|
1111
1392
|
};
|
|
1112
1393
|
}
|
|
1113
1394
|
/** The class an SDK message belongs to, or nothing when its text matches none of them. */
|
|
1114
1395
|
function classifySdkEvent(message) {
|
|
1115
1396
|
const normalized = message.toLowerCase();
|
|
1397
|
+
if (normalized.includes('rtc sign for') && normalized.includes('refused'))
|
|
1398
|
+
return 'rtc-sign-refused';
|
|
1116
1399
|
if (normalized.includes('restored persisted session'))
|
|
1117
1400
|
return 'session-restored';
|
|
1118
1401
|
if (normalized.includes('retrying') || normalized.includes('reconnecting'))
|
|
@@ -1191,6 +1474,14 @@ function allowlistedLabel(value, allowed) {
|
|
|
1191
1474
|
* like any other field that fails its shape.
|
|
1192
1475
|
*/
|
|
1193
1476
|
const MAX_STARTUP_WINDOW_MS = 60_000;
|
|
1477
|
+
/** Why a level-2 wait ended without a key, which is the whole of what such an ending states. */
|
|
1478
|
+
const LEVEL2_UNAVAILABLE_REASONS = [
|
|
1479
|
+
'no-cipher-key',
|
|
1480
|
+
'derivation-failed',
|
|
1481
|
+
'not-negotiating',
|
|
1482
|
+
'session-closed',
|
|
1483
|
+
'grace-elapsed',
|
|
1484
|
+
];
|
|
1194
1485
|
/** The three phases whose only field is the action they report, which the union fixes at `start`. */
|
|
1195
1486
|
const retainedStart = (candidate) => candidate.action === 'start' ? { action: 'start' } : undefined;
|
|
1196
1487
|
/** A phase whose only field is one bounded integer, retained under the name the SDK declares for it. */
|
|
@@ -1236,7 +1527,74 @@ const LIVE_TRACE_PHASES = {
|
|
|
1236
1527
|
'sequence-restart': retainedInteger('dataType', 3),
|
|
1237
1528
|
'level2-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
|
|
1238
1529
|
'level2-ready': retainedInteger('cipherId', 65535),
|
|
1239
|
-
'level2-
|
|
1530
|
+
'level2-set-payload-only': retainedInteger('cipherId', 65535),
|
|
1531
|
+
'session-connect-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
|
|
1532
|
+
'session-connected': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
|
|
1533
|
+
'session-unreachable': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
|
|
1534
|
+
/**
|
|
1535
|
+
* Why a level-2 wait ended without a key, and what it had to name.
|
|
1536
|
+
*
|
|
1537
|
+
* The reason is the record: a wait that ran out states how long it waited, and one answered without waiting
|
|
1538
|
+
* states nothing more, because the negotiation is one-shot per connection. Only a reason reached under a
|
|
1539
|
+
* negotiation has a cipher to name, so both durations are optional and each is retained where it is stated.
|
|
1540
|
+
*/
|
|
1541
|
+
'level2-unavailable': (c) => {
|
|
1542
|
+
const reason = allowlistedLabel(c.reason, LEVEL2_UNAVAILABLE_REASONS);
|
|
1543
|
+
if (!reason) {
|
|
1544
|
+
return undefined;
|
|
1545
|
+
}
|
|
1546
|
+
const cipherId = boundedInteger(c.cipherId, 65535);
|
|
1547
|
+
const waitedMs = boundedInteger(c.waitedMs, MAX_STARTUP_WINDOW_MS);
|
|
1548
|
+
return {
|
|
1549
|
+
reason,
|
|
1550
|
+
...(cipherId === undefined ? {} : { cipherId }),
|
|
1551
|
+
...(waitedMs === undefined ? {} : { waitedMs }),
|
|
1552
|
+
};
|
|
1553
|
+
},
|
|
1554
|
+
/** The cipher a station asked for, beside the one whose material answered it. */
|
|
1555
|
+
'cipher-fallback': (c) => {
|
|
1556
|
+
const cipherId = boundedInteger(c.cipherId, 65535);
|
|
1557
|
+
const answeredCipherId = boundedInteger(c.answeredCipherId, 65535);
|
|
1558
|
+
return cipherId === undefined || answeredCipherId === undefined ? undefined : { cipherId, answeredCipherId };
|
|
1559
|
+
},
|
|
1560
|
+
'level2-negotiating': retainedInteger('cipherId', 65535),
|
|
1561
|
+
/**
|
|
1562
|
+
* What a call resolved a station as, which decides what its failure means.
|
|
1563
|
+
*
|
|
1564
|
+
* An attached camera's media start has no unencrypted form, so the topology is what separates a key that
|
|
1565
|
+
* never arrived from a start that had no form to be sent in. The channel is an index on a base and the
|
|
1566
|
+
* administrator is a relation to the signed-in account: neither is an identity of its own.
|
|
1567
|
+
*/
|
|
1568
|
+
/**
|
|
1569
|
+
* Which lookup channels a connection could ask for a station on, before it asked.
|
|
1570
|
+
*
|
|
1571
|
+
* A connect that had one channel failed for that channel's reason alone, and a connect that had neither
|
|
1572
|
+
* could not have succeeded. Both are otherwise indistinguishable from a station that is switched off.
|
|
1573
|
+
* `cloudMissing` names what the cloud channel lacked when it was unavailable.
|
|
1574
|
+
*/
|
|
1575
|
+
'lookup-channels': (c) => {
|
|
1576
|
+
if (typeof c.local !== 'boolean' || typeof c.cloud !== 'boolean')
|
|
1577
|
+
return undefined;
|
|
1578
|
+
const cloudMissing = allowlistedLabel(c.cloudMissing, ['dsk-key', 'cloud-addresses']);
|
|
1579
|
+
return { local: c.local, cloud: c.cloud, ...(cloudMissing === undefined ? {} : { cloudMissing }) };
|
|
1580
|
+
},
|
|
1581
|
+
'station-resolved': (c) => {
|
|
1582
|
+
const topology = allowlistedLabel(c.topology, ['attached', 'own']);
|
|
1583
|
+
const stationAdmin = allowlistedLabel(c.stationAdmin, ['self', 'other', 'unstated']);
|
|
1584
|
+
const channel = boundedInteger(c.channel, MAX_STATION_CHANNEL);
|
|
1585
|
+
const stationModel = typeof c.stationModel === 'string' ? boundedText(c.stationModel, MAX_DEVICE_MODEL_LENGTH) : undefined;
|
|
1586
|
+
return topology && stationAdmin && channel !== undefined
|
|
1587
|
+
? { topology, channel, stationAdmin, ...(stationModel === undefined ? {} : { stationModel }) }
|
|
1588
|
+
: undefined;
|
|
1589
|
+
},
|
|
1590
|
+
/**
|
|
1591
|
+
* Why a device could not be addressed on its station: its record states no channel, or another attached
|
|
1592
|
+
* device states the same one. The call was refused before anything was sent.
|
|
1593
|
+
*/
|
|
1594
|
+
'station-channel-unresolved': (c) => {
|
|
1595
|
+
const issue = allowlistedLabel(c.issue, ['missing', 'shared']);
|
|
1596
|
+
return issue ? { issue } : undefined;
|
|
1597
|
+
},
|
|
1240
1598
|
/**
|
|
1241
1599
|
* How long a connection's path has answered nothing, which is the station stating the path is gone.
|
|
1242
1600
|
*
|
|
@@ -1244,20 +1602,51 @@ const LIVE_TRACE_PHASES = {
|
|
|
1244
1602
|
* silence longer than that belongs to a session nothing is waiting on.
|
|
1245
1603
|
*/
|
|
1246
1604
|
'path-stale': retainedInteger('silentMs', MAX_STARTUP_WINDOW_MS),
|
|
1605
|
+
/**
|
|
1606
|
+
* How long a camera's own channel delivered nothing, and whether the station was asked to serve it again.
|
|
1607
|
+
*
|
|
1608
|
+
* Both halves are the record: a station re-tasked to a sibling is repaired by the re-assert, and a pull
|
|
1609
|
+
* nothing is attached to declines it so the station is left with the camera someone is watching. A picture
|
|
1610
|
+
* that stopped advancing while no such record exists stopped for a reason the station's attention does not
|
|
1611
|
+
* reach.
|
|
1612
|
+
*/
|
|
1613
|
+
'channel-silent': (c) => {
|
|
1614
|
+
const silentMs = boundedInteger(c.silentMs, MAX_STARTUP_WINDOW_MS);
|
|
1615
|
+
const outcome = allowlistedLabel(c.outcome, ['declined', 'reasserted']);
|
|
1616
|
+
return silentMs !== undefined && outcome ? { silentMs, outcome } : undefined;
|
|
1617
|
+
},
|
|
1247
1618
|
'media-command-unsent': (c) => {
|
|
1248
1619
|
const reason = allowlistedLabel(c.reason, ['level2-key', 'address']);
|
|
1249
1620
|
return reason ? { reason } : undefined;
|
|
1250
1621
|
},
|
|
1622
|
+
/** `errorName` is an upstream class name, so it stays out of the archive; reason and stage say enough. */
|
|
1623
|
+
'start-failed': (c) => {
|
|
1624
|
+
const reason = allowlistedLabel(c.reason, ['warm-timeout', 'source-error', 'source-ended']);
|
|
1625
|
+
const stage = allowlistedLabel(c.stage, ['awaiting-first-frame', 'audio-only', 'awaiting-keyframe']);
|
|
1626
|
+
const attempts = boundedInteger(c.attempts, 1000);
|
|
1627
|
+
return reason && stage && attempts !== undefined ? { reason, stage, attempts } : undefined;
|
|
1628
|
+
},
|
|
1251
1629
|
warming: (c) => {
|
|
1252
1630
|
const retryMs = boundedInteger(c.retryMs, MAX_STARTUP_WINDOW_MS);
|
|
1253
1631
|
const deadlineMs = boundedInteger(c.deadlineMs, MAX_STARTUP_WINDOW_MS);
|
|
1254
1632
|
return retryMs === undefined || deadlineMs === undefined ? undefined : { retryMs, deadlineMs };
|
|
1255
1633
|
},
|
|
1256
1634
|
};
|
|
1257
|
-
|
|
1258
|
-
|
|
1635
|
+
/**
|
|
1636
|
+
* Narrows one push the SDK received to its wire event type, whether it knew the device, and how many events it
|
|
1637
|
+
* decoded: a push that decoded into none is the code a device sends that the library does not map yet.
|
|
1638
|
+
*/
|
|
1639
|
+
function sanitizeSdkPush(value) {
|
|
1640
|
+
const eventType = boundedInteger(value.eventType, 0xffff);
|
|
1641
|
+
const device = allowlistedLabel(value.device, ['known', 'unknown', 'none']);
|
|
1642
|
+
const events = boundedInteger(value.events, 64);
|
|
1643
|
+
return eventType === undefined || !device || events === undefined
|
|
1644
|
+
? undefined
|
|
1645
|
+
: { scope: 'sdk', level: 'debug', subsystem: 'push', event: 'push-received', eventType, device, events };
|
|
1646
|
+
}
|
|
1647
|
+
function sanitizeSdkLiveStartTrace(candidate) {
|
|
1648
|
+
if (!isRecord(candidate))
|
|
1259
1649
|
return undefined;
|
|
1260
|
-
const candidate = value;
|
|
1261
1650
|
const phase = String(candidate.phase);
|
|
1262
1651
|
if (!Object.hasOwn(LIVE_TRACE_PHASES, phase))
|
|
1263
1652
|
return undefined;
|
|
@@ -1277,6 +1666,51 @@ function sanitizeSdkLiveStartTrace(value) {
|
|
|
1277
1666
|
function opaquePullHandle(value) {
|
|
1278
1667
|
return typeof value === 'string' && /^(?:pull|station)-\d{1,6}(?::\d{1,3})?$/.test(value) ? value : undefined;
|
|
1279
1668
|
}
|
|
1669
|
+
/**
|
|
1670
|
+
* One support-case accessory alias, or nothing where the value is not one.
|
|
1671
|
+
*
|
|
1672
|
+
* Matched against the shape an alias has rather than against what a serial looks like, on the same rule as
|
|
1673
|
+
* {@link opaquePullHandle}: a record carries an alias or it carries no accessory at all.
|
|
1674
|
+
*/
|
|
1675
|
+
function opaqueAccessoryAlias(value) {
|
|
1676
|
+
return typeof value === 'string' &&
|
|
1677
|
+
/^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)
|
|
1678
|
+
? value
|
|
1679
|
+
: undefined;
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* Narrows one realtime startup outcome to its state, the push plane's start counts, and the mqtt and wired P2P
|
|
1683
|
+
* planes' counts nested under their names, or nothing where the state is unknown.
|
|
1684
|
+
*
|
|
1685
|
+
* Read by both halves of the path — {@link reportRealtimeReadiness} builds the record and the file sink rebuilds it.
|
|
1686
|
+
*/
|
|
1687
|
+
function sanitizeRealtimeReadiness(value) {
|
|
1688
|
+
const state = allowlistedLabel(value.state, ['ready', 'partial', 'disabled', 'superseded', 'timed-out']);
|
|
1689
|
+
if (!state)
|
|
1690
|
+
return undefined;
|
|
1691
|
+
const counts = (plane) => Object.fromEntries(['required', 'ready', 'failed', 'pending'].flatMap((key) => {
|
|
1692
|
+
const count = isRecord(plane) ? nonNegativeInteger(plane[key]) : undefined;
|
|
1693
|
+
return count === undefined ? [] : [[key, count]];
|
|
1694
|
+
}));
|
|
1695
|
+
const planes = Object.fromEntries(['mqtt', 'wiredP2p'].flatMap((plane) => {
|
|
1696
|
+
const planeCounts = counts(value[plane]);
|
|
1697
|
+
return Object.keys(planeCounts).length ? [[plane, planeCounts]] : [];
|
|
1698
|
+
}));
|
|
1699
|
+
return { event: 'realtime-readiness', state, ...counts(value), ...planes };
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Records how the SDK's realtime startup settled, with each plane's start counts, as `sdk-log` evidence.
|
|
1703
|
+
*
|
|
1704
|
+
* Carries the state and the counts alone, which name the plane — push, mqtt or wired P2P — that failed or never
|
|
1705
|
+
* settled behind a `partial` start.
|
|
1706
|
+
*/
|
|
1707
|
+
export function reportRealtimeReadiness(target, readiness) {
|
|
1708
|
+
const { state, push, mqtt, wiredP2p } = readiness;
|
|
1709
|
+
const record = sanitizeRealtimeReadiness({ ...push, state, mqtt, wiredP2p });
|
|
1710
|
+
if (target?.debug && record) {
|
|
1711
|
+
target.debug(JSON.stringify({ scope: 'sdk', level: 'debug', subsystem: 'push', ...record }));
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1280
1714
|
/**
|
|
1281
1715
|
* Adapts SDK protocol detail to bounded debug output without preserving supplied values.
|
|
1282
1716
|
*
|
|
@@ -1298,6 +1732,9 @@ export function createSdkLogger(target) {
|
|
|
1298
1732
|
if (trace)
|
|
1299
1733
|
return { scope: 'sdk', subsystem: 'p2p', event: 'live-start-trace', ...trace };
|
|
1300
1734
|
}
|
|
1735
|
+
const push = /^\[push\] in: eventType=(\d+) device=(\w+) events=(\d+)$/.exec(message);
|
|
1736
|
+
if (push)
|
|
1737
|
+
return sanitizeSdkPush({ eventType: Number(push[1]), device: push[2], events: Number(push[3]) });
|
|
1301
1738
|
const requestedSubsystem = /^\[([a-z0-9-]+)(?:\s+[^\]]+)?\]/i.exec(message)?.[1]?.toLowerCase();
|
|
1302
1739
|
if (requestedSubsystem === 'ffmpeg') {
|
|
1303
1740
|
const stderr = message.slice(message.indexOf(']') + 1).split(/\r?\n/);
|
|
@@ -1318,9 +1755,7 @@ export function createSdkLogger(target) {
|
|
|
1318
1755
|
const subsystem = SDK_SUBSYSTEMS.has(requestedSubsystem ?? '') ? requestedSubsystem : (aliasedSubsystem ?? 'sdk');
|
|
1319
1756
|
const details = args.slice(0, MAX_SDK_DETAILS).map((value) => {
|
|
1320
1757
|
if (value instanceof Error) {
|
|
1321
|
-
const errorType =
|
|
1322
|
-
? value.name
|
|
1323
|
-
: 'Error';
|
|
1758
|
+
const errorType = SDK_ERROR_TYPES.includes(value.name) ? value.name : 'Error';
|
|
1324
1759
|
return { errorType };
|
|
1325
1760
|
}
|
|
1326
1761
|
if (typeof value === 'string') {
|
|
@@ -1421,6 +1856,7 @@ const REASONS = new Set([
|
|
|
1421
1856
|
'live-failed',
|
|
1422
1857
|
'live-no-keyframe',
|
|
1423
1858
|
'live-source-failed',
|
|
1859
|
+
'live-station-unreachable',
|
|
1424
1860
|
'live-unavailable',
|
|
1425
1861
|
'live-undecodable-burst',
|
|
1426
1862
|
'malformed',
|
|
@@ -1444,6 +1880,9 @@ const REASONS = new Set([
|
|
|
1444
1880
|
'source-input-unstable',
|
|
1445
1881
|
'source-stopped',
|
|
1446
1882
|
'source-unavailable',
|
|
1883
|
+
'source-warm-timeout',
|
|
1884
|
+
'station-key-unavailable',
|
|
1885
|
+
'station-unreachable',
|
|
1447
1886
|
'stored-download-failed',
|
|
1448
1887
|
'stored-failed',
|
|
1449
1888
|
'stored-invalid-image',
|
|
@@ -1454,6 +1893,7 @@ const REASONS = new Set([
|
|
|
1454
1893
|
'unsupported',
|
|
1455
1894
|
'unsupported-selection',
|
|
1456
1895
|
'device-audio-failed',
|
|
1896
|
+
'no-controller-audio',
|
|
1457
1897
|
]);
|
|
1458
1898
|
/** Every runtime state, plus the withdrawal a recovered condition reports. */
|
|
1459
1899
|
const RUNTIME_CONDITION_REASONS = new Set([...RUNTIME_STATES, 'recovered']);
|
|
@@ -1462,6 +1902,7 @@ const HOMEKIT_EVENT_ROUTES = {
|
|
|
1462
1902
|
'battery.status': new Set(['battery-alert', 'battery-level']),
|
|
1463
1903
|
'contact.sensor': new Set(['contact-state']),
|
|
1464
1904
|
'doorbell.press': new Set(['doorbell-press']),
|
|
1905
|
+
'doorbell.package': new Set(['package-presence']),
|
|
1465
1906
|
'lock.mechanism': new Set(['lock-state']),
|
|
1466
1907
|
'motion.sensor': new Set(['motion-detection']),
|
|
1467
1908
|
'arming.security-system': new Set(['arming-mode-changed', 'security-system-alarm']),
|
|
@@ -1469,7 +1910,7 @@ const HOMEKIT_EVENT_ROUTES = {
|
|
|
1469
1910
|
'camera.streaming': new Set(['camera-enabled-changed']),
|
|
1470
1911
|
'camera.controls': new Set(['camera-enabled-changed']),
|
|
1471
1912
|
};
|
|
1472
|
-
const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid']);
|
|
1913
|
+
const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid', 'ignored']);
|
|
1473
1914
|
/**
|
|
1474
1915
|
* What announced a change, for an adapter that follows more than one announcement for the same state.
|
|
1475
1916
|
*
|
|
@@ -1477,9 +1918,9 @@ const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid']);
|
|
|
1477
1918
|
* field is omitted where an adapter states no announcement and refused where it states one absent from here.
|
|
1478
1919
|
*/
|
|
1479
1920
|
const HOMEKIT_ANNOUNCEMENTS = new Set(['write', 'poll']);
|
|
1480
|
-
const HOMEKIT_LIVE_VIDEO_OPERATIONS =
|
|
1481
|
-
const HOMEKIT_LIVE_VIDEO_PROFILES =
|
|
1482
|
-
const HOMEKIT_LIVE_VIDEO_LEVELS =
|
|
1921
|
+
const HOMEKIT_LIVE_VIDEO_OPERATIONS = ['start', 'reconfigure'];
|
|
1922
|
+
const HOMEKIT_LIVE_VIDEO_PROFILES = ['baseline', 'main', 'high'];
|
|
1923
|
+
const HOMEKIT_LIVE_VIDEO_LEVELS = ['3.1', '3.2', '4.0'];
|
|
1483
1924
|
/**
|
|
1484
1925
|
* The inclusive bounds a controller-selected live geometry and frame rate are retained within.
|
|
1485
1926
|
*
|
|
@@ -1500,30 +1941,86 @@ const MAX_LIVE_VIDEO_FRAME_RATE = 120;
|
|
|
1500
1941
|
*/
|
|
1501
1942
|
const MIN_LIVE_VIDEO_MTU = 0;
|
|
1502
1943
|
const MAX_LIVE_VIDEO_MTU = 65_535;
|
|
1944
|
+
/** The bit rate ceiling a controller negotiated, in kbps, bounded to the unsigned 16-bit field that carries it. */
|
|
1945
|
+
const MAX_LIVE_VIDEO_BIT_RATE = 65_535;
|
|
1503
1946
|
/** The two address families HomeKit negotiates a destination in. */
|
|
1504
|
-
const HOMEKIT_LIVE_ADDRESS_VERSIONS =
|
|
1505
|
-
const HOMEKIT_LIVE_SESSION_STAGES =
|
|
1947
|
+
const HOMEKIT_LIVE_ADDRESS_VERSIONS = ['ipv4', 'ipv6'];
|
|
1948
|
+
const HOMEKIT_LIVE_SESSION_STAGES = [
|
|
1506
1949
|
'sdk-source-acquisition',
|
|
1507
1950
|
'first-source-keyframe',
|
|
1508
1951
|
'first-adapted-output',
|
|
1509
1952
|
'controller-rtcp',
|
|
1510
|
-
]
|
|
1953
|
+
];
|
|
1954
|
+
/** The SDK's own stage and reason for a live warm-up that ended without a keyframe. */
|
|
1955
|
+
const SDK_LIVE_START_STAGES = ['awaiting-first-frame', 'audio-only', 'awaiting-keyframe'];
|
|
1956
|
+
const SDK_LIVE_START_REASONS = ['warm-timeout', 'source-error', 'source-ended'];
|
|
1511
1957
|
/**
|
|
1512
1958
|
* Which adaptation process an FFmpeg record came from.
|
|
1513
1959
|
*
|
|
1514
1960
|
* A superset of what the media domain can report, because the SDK runs FFmpeg of its own for snapshot
|
|
1515
1961
|
* decoding and WebRTC containers and forwards its output here under the same evidence class.
|
|
1516
1962
|
*/
|
|
1517
|
-
const ADAPTATION_ROLES = new Set([
|
|
1963
|
+
const ADAPTATION_ROLES = new Set([
|
|
1964
|
+
'live-video',
|
|
1965
|
+
'live-audio',
|
|
1966
|
+
'return-audio',
|
|
1967
|
+
'recording',
|
|
1968
|
+
'preview-encode',
|
|
1969
|
+
'preview-decode',
|
|
1970
|
+
'preview-raw',
|
|
1971
|
+
'sdk',
|
|
1972
|
+
]);
|
|
1518
1973
|
/** What that process did. `output` is a process reported for what it wrote rather than for how it ended. */
|
|
1519
1974
|
const ADAPTATION_EVENTS = new Set([
|
|
1520
1975
|
'started',
|
|
1976
|
+
'encoder-disqualified',
|
|
1521
1977
|
'spawn-failed',
|
|
1522
1978
|
'input-failed',
|
|
1523
1979
|
'exited-before-output',
|
|
1524
1980
|
'exited-while-streaming',
|
|
1525
1981
|
'output',
|
|
1526
1982
|
]);
|
|
1983
|
+
/** How a settings-page live preview ended. */
|
|
1984
|
+
const PREVIEW_OUTCOMES = ['picture', 'unplayable', 'no-video', 'stopped'];
|
|
1985
|
+
/** The iPhone asks a live preview codes for, and the live video settings it may try. */
|
|
1986
|
+
const PREVIEW_ASKS = ['wifi', 'cellular'];
|
|
1987
|
+
const PREVIEW_SETTINGS = [
|
|
1988
|
+
'smallVideoPackets',
|
|
1989
|
+
'liveVideoPassthrough',
|
|
1990
|
+
'hardwareVideoEncoder',
|
|
1991
|
+
'ignoreHomeBitRate',
|
|
1992
|
+
];
|
|
1993
|
+
/**
|
|
1994
|
+
* Why a process was started, on the record that reports it starting.
|
|
1995
|
+
*
|
|
1996
|
+
* A replacement carries the identity of the stream the previous process was already sending on, so what it
|
|
1997
|
+
* started mid-flight is judged against what that one left. A first adaptation has nothing behind it.
|
|
1998
|
+
*/
|
|
1999
|
+
const ADAPTATION_CAUSES = ['first', 'controller-selection', 'source-configuration'];
|
|
2000
|
+
/** The encoders a live video process may code with, `copy` being none. */
|
|
2001
|
+
const LIVE_VIDEO_ENCODERS = [
|
|
2002
|
+
'copy',
|
|
2003
|
+
'libx264',
|
|
2004
|
+
'h264_videotoolbox',
|
|
2005
|
+
'h264_qsv',
|
|
2006
|
+
'h264_nvenc',
|
|
2007
|
+
'h264_amf',
|
|
2008
|
+
'h264_vaapi',
|
|
2009
|
+
'h264_v4l2m2m',
|
|
2010
|
+
];
|
|
2011
|
+
/** What qualifying a hardware encoder for one selection found, but `bytes`, which is read with its two integers. */
|
|
2012
|
+
const QUALIFICATION_OUTCOMES = [
|
|
2013
|
+
'qualified',
|
|
2014
|
+
'budget',
|
|
2015
|
+
'encode',
|
|
2016
|
+
'trace',
|
|
2017
|
+
'profile',
|
|
2018
|
+
'level',
|
|
2019
|
+
'geometry',
|
|
2020
|
+
'frames',
|
|
2021
|
+
];
|
|
2022
|
+
/** The codec a live video source delivers. */
|
|
2023
|
+
const LIVE_VIDEO_SOURCES = ['h264', 'h265', 'av1'];
|
|
1527
2024
|
/**
|
|
1528
2025
|
* Which of those events is a failure, and so the ones a record is levelled `warn` for.
|
|
1529
2026
|
*
|
|
@@ -1533,6 +2030,7 @@ const ADAPTATION_EVENTS = new Set([
|
|
|
1533
2030
|
* log for every session that succeeded.
|
|
1534
2031
|
*/
|
|
1535
2032
|
const ADAPTATION_FAILURE_EVENTS = new Set([
|
|
2033
|
+
'encoder-disqualified',
|
|
1536
2034
|
'spawn-failed',
|
|
1537
2035
|
'input-failed',
|
|
1538
2036
|
'exited-before-output',
|
|
@@ -1564,74 +2062,32 @@ const ADAPTATION_SIGNALS = new Set([
|
|
|
1564
2062
|
const MAX_ADAPTATION_STDERR_LINES = 8;
|
|
1565
2063
|
const MAX_ADAPTATION_STDERR_LINE_LENGTH = 200;
|
|
1566
2064
|
/**
|
|
1567
|
-
* The
|
|
2065
|
+
* The fixed-shape operational notices, each `[level, messageKey, durationMessageKey?]`.
|
|
1568
2066
|
*
|
|
1569
2067
|
* Operational rather than verbose, so each is `plugin-log` evidence that every profile declares.
|
|
1570
2068
|
*/
|
|
1571
|
-
const MEDIA_NOTICES = {
|
|
1572
|
-
'camera-snapshot-cache-invalid': {
|
|
1573
|
-
level: 'warn',
|
|
1574
|
-
messageKey: 'log.snapshotCacheInvalid',
|
|
1575
|
-
},
|
|
1576
|
-
};
|
|
1577
2069
|
const RUNTIME_NOTICES = {
|
|
1578
|
-
'status-publication-failed':
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
'
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
'
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
'
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
'ownership-acquisition-failed': {
|
|
1600
|
-
level: 'error',
|
|
1601
|
-
messageKey: 'log.notice.ownershipAcquisitionFailed',
|
|
1602
|
-
},
|
|
1603
|
-
'ownership-release-incomplete': {
|
|
1604
|
-
level: 'warn',
|
|
1605
|
-
messageKey: 'log.notice.ownershipReleaseIncomplete',
|
|
1606
|
-
},
|
|
1607
|
-
'registry-subscriber-failed': {
|
|
1608
|
-
level: 'warn',
|
|
1609
|
-
messageKey: 'log.notice.registrySubscriberFailed',
|
|
1610
|
-
},
|
|
1611
|
-
'event-subscriber-failed': {
|
|
1612
|
-
level: 'warn',
|
|
1613
|
-
messageKey: 'log.notice.eventSubscriberFailed',
|
|
1614
|
-
},
|
|
1615
|
-
'state-subscriber-failed': {
|
|
1616
|
-
level: 'warn',
|
|
1617
|
-
messageKey: 'log.notice.stateSubscriberFailed',
|
|
1618
|
-
},
|
|
1619
|
-
'channel-serve-failed': {
|
|
1620
|
-
level: 'warn',
|
|
1621
|
-
messageKey: 'log.notice.channelServeFailed',
|
|
1622
|
-
},
|
|
1623
|
-
'diagnostics-authorization-armed': {
|
|
1624
|
-
level: 'info',
|
|
1625
|
-
messageKey: 'log.notice.diagnosticsAuthorizationArmed',
|
|
1626
|
-
},
|
|
1627
|
-
'stand-down-not-rearmed': {
|
|
1628
|
-
level: 'warn',
|
|
1629
|
-
messageKey: 'log.notice.standDownNotRearmed',
|
|
1630
|
-
},
|
|
1631
|
-
'stand-down-account-replaced': {
|
|
1632
|
-
level: 'warn',
|
|
1633
|
-
messageKey: 'log.notice.standDownAccountReplaced',
|
|
1634
|
-
},
|
|
2070
|
+
'status-publication-failed': ['warn', 'log.notice.statusPublicationFailed'],
|
|
2071
|
+
'ownership-release-not-finalized': ['error', 'log.notice.ownershipReleaseNotFinalized'],
|
|
2072
|
+
'ownership-release-failed': ['error', 'log.notice.ownershipReleaseFailed'],
|
|
2073
|
+
'shutdown-failed': ['error', 'log.notice.shutdownFailed'],
|
|
2074
|
+
'shutdown-timeout': ['warn', 'log.notice.shutdownTimeout', 'log.notice.shutdownTimeoutWithDuration'],
|
|
2075
|
+
'ownership-acquisition-failed': ['error', 'log.notice.ownershipAcquisitionFailed'],
|
|
2076
|
+
'ownership-release-incomplete': ['warn', 'log.notice.ownershipReleaseIncomplete'],
|
|
2077
|
+
'registry-subscriber-failed': ['warn', 'log.notice.registrySubscriberFailed'],
|
|
2078
|
+
'event-subscriber-failed': ['warn', 'log.notice.eventSubscriberFailed'],
|
|
2079
|
+
'state-subscriber-failed': ['warn', 'log.notice.stateSubscriberFailed'],
|
|
2080
|
+
'channel-serve-failed': ['warn', 'log.notice.channelServeFailed'],
|
|
2081
|
+
'diagnostics-authorization-armed': ['info', 'log.notice.diagnosticsAuthorizationArmed'],
|
|
2082
|
+
'stand-down-not-rearmed': ['warn', 'log.notice.standDownNotRearmed'],
|
|
2083
|
+
'stand-down-account-replaced': ['warn', 'log.notice.standDownAccountReplaced'],
|
|
2084
|
+
'camera-snapshot-cache-invalid': ['warn', 'log.snapshotCacheInvalid'],
|
|
2085
|
+
'discarded-v4-settings-unacknowledged': ['warn', 'log.discardedSettings'],
|
|
2086
|
+
};
|
|
2087
|
+
/** The scope a notice is recorded under where it is not `runtime-notice`. */
|
|
2088
|
+
const NOTICE_SCOPES = {
|
|
2089
|
+
'camera-snapshot-cache-invalid': 'media-notice',
|
|
2090
|
+
'discarded-v4-settings-unacknowledged': 'configuration-notice',
|
|
1635
2091
|
};
|
|
1636
2092
|
const RUNTIME_CONDITIONS = {
|
|
1637
2093
|
degraded: {
|
|
@@ -1660,7 +2116,7 @@ const RUNTIME_CONDITIONS = {
|
|
|
1660
2116
|
},
|
|
1661
2117
|
};
|
|
1662
2118
|
const HOMEKIT_CONDITIONS = {
|
|
1663
|
-
'recognized-device-not-represented': ['log.homekit.recognizedNotRepresented', 'log.action.
|
|
2119
|
+
'recognized-device-not-represented': ['log.homekit.recognizedNotRepresented', 'log.action.notSupportedYet'],
|
|
1664
2120
|
'battery-capability-unavailable': ['log.homekit.batteryCapabilityUnavailable', 'log.action.waitBattery'],
|
|
1665
2121
|
'invalid-battery-observation': ['log.homekit.invalidBatteryObservation', 'log.action.waitBatteryObservation'],
|
|
1666
2122
|
'battery-temperature-alert': ['log.homekit.batteryTemperatureAlert', 'log.action.allowBatteryCooling'],
|
|
@@ -1673,7 +2129,7 @@ const HOMEKIT_CONDITIONS = {
|
|
|
1673
2129
|
'smart-light-operation-failed': ['log.homekit.lightOperationFailed', 'log.action.retryLight'],
|
|
1674
2130
|
'smart-light-reconciliation-expired': ['log.homekit.lightReconciliationExpired', 'log.action.checkPhysicalLight'],
|
|
1675
2131
|
'arming-capability-unavailable': ['log.homekit.armingCapabilityUnavailable', 'log.action.waitArming'],
|
|
1676
|
-
'unsupported-arming-mode': ['log.homekit.unsupportedArmingMode', 'log.action.
|
|
2132
|
+
'unsupported-arming-mode': ['log.homekit.unsupportedArmingMode', 'log.action.mapArmingMode'],
|
|
1677
2133
|
'arming-operation-failed': ['log.homekit.armingOperationFailed', 'log.action.retryArming'],
|
|
1678
2134
|
'arming-reconciliation-expired': ['log.homekit.armingReconciliationExpired', 'log.action.checkPhysicalArmingMode'],
|
|
1679
2135
|
'lock-capability-unavailable': ['log.homekit.lockCapabilityUnavailable', 'log.action.waitLock'],
|
|
@@ -1707,17 +2163,75 @@ const HOMEKIT_CONDITIONS = {
|
|
|
1707
2163
|
'camera-talkback-failed': ['log.homekit.cameraTalkbackFailed', 'log.action.retryTalkback'],
|
|
1708
2164
|
'camera-talkback-capability-unavailable': ['log.homekit.cameraTalkbackUnavailable', 'log.action.waitTalkback'],
|
|
1709
2165
|
};
|
|
2166
|
+
/**
|
|
2167
|
+
* The action a HomeKit condition names for a reason whose remedy is not the condition's own, keyed `code:reason`.
|
|
2168
|
+
*
|
|
2169
|
+
* A camera adapter is missing only where no FFmpeg path resolved, an adaptation fails to spawn only where the path
|
|
2170
|
+
* cannot be run, and a live adaptation that exits before any output is the FFmpeg build refusing this host, so all
|
|
2171
|
+
* three are fixed in the plugin's settings rather than waited out. A control that timed out
|
|
2172
|
+
* will likely time out again, so it points at the Eufy app instead of a retry. A station whose session did not
|
|
2173
|
+
* connect is fixed at that station, not at the camera a viewer opened.
|
|
2174
|
+
*/
|
|
2175
|
+
const HOMEKIT_REASON_ACTIONS = {
|
|
2176
|
+
'camera-streaming-capability-unavailable:adapter-missing': 'log.action.setFfmpegPath',
|
|
2177
|
+
'camera-recording-unavailable:adapter-missing': 'log.action.setFfmpegPath',
|
|
2178
|
+
'camera-live-session-failed:adaptation-spawn-failed': 'log.action.checkFfmpegPath',
|
|
2179
|
+
'camera-live-session-failed:adaptation-exited-before-output': 'log.action.setFfmpegPath',
|
|
2180
|
+
'lock-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2181
|
+
'arming-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2182
|
+
'smart-light-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2183
|
+
'camera-control-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2184
|
+
'camera-live-session-failed:station-unreachable': 'log.action.checkStation',
|
|
2185
|
+
'camera-live-session-failed:source-warm-timeout': 'log.action.retryLiveViewNoPicture',
|
|
2186
|
+
'camera-snapshot-unavailable:live-station-unreachable': 'log.action.checkStation',
|
|
2187
|
+
'camera-talkback-failed:no-controller-audio': 'log.action.trySmallVideoPackets',
|
|
2188
|
+
};
|
|
2189
|
+
/** Projects a value onto an active condition, or nothing where the catalog does not write that code with those keys. */
|
|
2190
|
+
export function knownCondition(value) {
|
|
2191
|
+
if (!isRecord(value))
|
|
2192
|
+
return undefined;
|
|
2193
|
+
const { code, summaryKey, actionKey, serials } = value;
|
|
2194
|
+
if (typeof code !== 'string')
|
|
2195
|
+
return undefined;
|
|
2196
|
+
const runtime = Object.values(RUNTIME_CONDITIONS).find((condition) => condition.code === code);
|
|
2197
|
+
const homeKit = Object.hasOwn(HOMEKIT_CONDITIONS, code)
|
|
2198
|
+
? HOMEKIT_CONDITIONS[code]
|
|
2199
|
+
: undefined;
|
|
2200
|
+
const [summary, ...actions] = runtime
|
|
2201
|
+
? [runtime.summaryKey, runtime.actionKey]
|
|
2202
|
+
: homeKit
|
|
2203
|
+
? [
|
|
2204
|
+
...homeKit,
|
|
2205
|
+
...Object.entries(HOMEKIT_REASON_ACTIONS)
|
|
2206
|
+
.filter(([key]) => key.startsWith(`${code}:`))
|
|
2207
|
+
.map(([, action]) => action),
|
|
2208
|
+
]
|
|
2209
|
+
: [];
|
|
2210
|
+
const action = allowlistedLabel(actionKey, actions);
|
|
2211
|
+
if (summary === undefined || summaryKey !== summary || action === undefined)
|
|
2212
|
+
return undefined;
|
|
2213
|
+
const named = Array.isArray(serials) ? serials.filter((serial) => typeof serial === 'string') : [];
|
|
2214
|
+
return { code, summaryKey: summary, actionKey: action, ...(named.length === 0 ? {} : { serials: named }) };
|
|
2215
|
+
}
|
|
2216
|
+
/** HomeKit conditions that state a gap nothing in the user's setup closes, so an active one is not a warning. */
|
|
2217
|
+
const INFORMATIONAL_HOMEKIT_CONDITIONS = new Set(['recognized-device-not-represented']);
|
|
2218
|
+
/** The level and action one HomeKit condition is written with, decided once for the console and the record. */
|
|
2219
|
+
function homeKitConditionPresentation(code, active, reason) {
|
|
2220
|
+
return {
|
|
2221
|
+
level: active && !INFORMATIONAL_HOMEKIT_CONDITIONS.has(code) ? 'warn' : 'info',
|
|
2222
|
+
actionKey: HOMEKIT_REASON_ACTIONS[`${code}:${reason}`] ?? HOMEKIT_CONDITIONS[code][1],
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
1710
2225
|
function sanitizeStructuredEvent(message) {
|
|
1711
2226
|
let value;
|
|
1712
2227
|
try {
|
|
1713
|
-
|
|
1714
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
1715
|
-
return undefined;
|
|
1716
|
-
value = parsed;
|
|
2228
|
+
value = JSON.parse(message);
|
|
1717
2229
|
}
|
|
1718
2230
|
catch {
|
|
1719
2231
|
return undefined;
|
|
1720
2232
|
}
|
|
2233
|
+
if (!isRecord(value))
|
|
2234
|
+
return undefined;
|
|
1721
2235
|
const level = allowlistedLabel(value.level, ['debug', 'info', 'warn', 'error']);
|
|
1722
2236
|
if (!level || typeof value.scope !== 'string')
|
|
1723
2237
|
return undefined;
|
|
@@ -1728,6 +2242,13 @@ function sanitizeStructuredEvent(message) {
|
|
|
1728
2242
|
!SDK_EVENT_KEYS.has(value.event)) {
|
|
1729
2243
|
return undefined;
|
|
1730
2244
|
}
|
|
2245
|
+
if (value.event === 'realtime-readiness') {
|
|
2246
|
+
const readiness = sanitizeRealtimeReadiness(value);
|
|
2247
|
+
return readiness ? { scope: 'sdk', level: 'debug', subsystem: 'push', ...readiness } : undefined;
|
|
2248
|
+
}
|
|
2249
|
+
if (value.event === 'push-received') {
|
|
2250
|
+
return level === 'debug' ? sanitizeSdkPush(value) : undefined;
|
|
2251
|
+
}
|
|
1731
2252
|
if (value.event === 'live-start-trace') {
|
|
1732
2253
|
if (level !== 'debug')
|
|
1733
2254
|
return undefined;
|
|
@@ -1736,23 +2257,17 @@ function sanitizeStructuredEvent(message) {
|
|
|
1736
2257
|
}
|
|
1737
2258
|
const details = [];
|
|
1738
2259
|
for (const detail of Array.isArray(value.details) ? value.details.slice(0, MAX_SDK_DETAILS) : []) {
|
|
1739
|
-
if (!
|
|
2260
|
+
if (!isRecord(detail))
|
|
1740
2261
|
continue;
|
|
1741
|
-
const
|
|
1742
|
-
const errorType = allowlistedLabel(candidate.errorType, [
|
|
1743
|
-
'Error',
|
|
1744
|
-
'RangeError',
|
|
1745
|
-
'SessionExpiredError',
|
|
1746
|
-
'TypeError',
|
|
1747
|
-
]);
|
|
2262
|
+
const errorType = allowlistedLabel(detail.errorType, SDK_ERROR_TYPES);
|
|
1748
2263
|
if (errorType) {
|
|
1749
2264
|
details.push({ errorType });
|
|
1750
2265
|
continue;
|
|
1751
2266
|
}
|
|
1752
|
-
const type = allowlistedLabel(
|
|
2267
|
+
const type = allowlistedLabel(detail.type, ['boolean', 'number', 'object', 'string', 'undefined']);
|
|
1753
2268
|
if (!type)
|
|
1754
2269
|
continue;
|
|
1755
|
-
const length = nonNegativeInteger(
|
|
2270
|
+
const length = nonNegativeInteger(detail.length);
|
|
1756
2271
|
details.push({ type, ...(length === undefined ? {} : { length }) });
|
|
1757
2272
|
}
|
|
1758
2273
|
return {
|
|
@@ -1764,62 +2279,14 @@ function sanitizeStructuredEvent(message) {
|
|
|
1764
2279
|
...(value.detailsTruncated === true ? { detailsTruncated: true } : {}),
|
|
1765
2280
|
};
|
|
1766
2281
|
}
|
|
1767
|
-
if (value.scope === 'runtime-notice') {
|
|
2282
|
+
if (value.scope === 'runtime-notice' || value.scope === 'media-notice' || value.scope === 'configuration-notice') {
|
|
1768
2283
|
if (typeof value.code !== 'string' || !Object.hasOwn(RUNTIME_NOTICES, value.code))
|
|
1769
2284
|
return undefined;
|
|
1770
|
-
const notice =
|
|
1771
|
-
|
|
1772
|
-
const messageKey = durationMs !== undefined && 'durationMessageKey' in notice ? notice.durationMessageKey : notice.messageKey;
|
|
1773
|
-
return {
|
|
1774
|
-
scope: 'runtime-notice',
|
|
1775
|
-
level: notice.level,
|
|
1776
|
-
code: value.code,
|
|
1777
|
-
messageKey,
|
|
1778
|
-
...(durationMs === undefined ? {} : { durationMs }),
|
|
1779
|
-
};
|
|
1780
|
-
}
|
|
1781
|
-
if (value.scope === 'configuration-notice') {
|
|
1782
|
-
if (value.code !== 'discarded-v4-settings-unacknowledged')
|
|
1783
|
-
return undefined;
|
|
1784
|
-
return {
|
|
1785
|
-
scope: 'configuration-notice',
|
|
1786
|
-
level: 'warn',
|
|
1787
|
-
code: value.code,
|
|
1788
|
-
messageKey: 'log.discardedSettings',
|
|
1789
|
-
};
|
|
1790
|
-
}
|
|
1791
|
-
if (value.scope === 'media-notice') {
|
|
1792
|
-
if (typeof value.code !== 'string' || !Object.hasOwn(MEDIA_NOTICES, value.code))
|
|
1793
|
-
return undefined;
|
|
1794
|
-
const notice = MEDIA_NOTICES[value.code];
|
|
1795
|
-
return { scope: 'media-notice', level: notice.level, code: value.code, messageKey: notice.messageKey };
|
|
2285
|
+
const notice = runtimeNoticeRecord(value.code, nonNegativeInteger(value.durationMs));
|
|
2286
|
+
return notice.scope === value.scope ? notice : undefined;
|
|
1796
2287
|
}
|
|
1797
2288
|
if (value.scope === 'homekit') {
|
|
1798
|
-
|
|
1799
|
-
const selection = sanitizeLiveVideoSelection(value);
|
|
1800
|
-
return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
|
|
1801
|
-
}
|
|
1802
|
-
if (value.adapter === 'camera.streaming' && HOMEKIT_LIVE_REQUEST_EVENTS.has(String(value.event))) {
|
|
1803
|
-
const lifecycle = sanitizeLiveSessionTrace(value);
|
|
1804
|
-
return lifecycle ? { scope: 'homekit', level: 'debug', ...lifecycle } : undefined;
|
|
1805
|
-
}
|
|
1806
|
-
if (typeof value.adapter !== 'string' ||
|
|
1807
|
-
typeof value.event !== 'string' ||
|
|
1808
|
-
!HOMEKIT_EVENT_ROUTES[value.adapter]?.has(value.event) ||
|
|
1809
|
-
typeof value.observation !== 'string' ||
|
|
1810
|
-
!HOMEKIT_OBSERVATIONS.has(value.observation)) {
|
|
1811
|
-
return undefined;
|
|
1812
|
-
}
|
|
1813
|
-
return {
|
|
1814
|
-
scope: 'homekit',
|
|
1815
|
-
level: 'debug',
|
|
1816
|
-
adapter: value.adapter,
|
|
1817
|
-
event: value.event,
|
|
1818
|
-
observation: value.observation,
|
|
1819
|
-
...(typeof value.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(value.announcedBy)
|
|
1820
|
-
? { announcedBy: value.announcedBy }
|
|
1821
|
-
: {}),
|
|
1822
|
-
};
|
|
2289
|
+
return sanitizeHomeKitEvent(value);
|
|
1823
2290
|
}
|
|
1824
2291
|
if (value.scope === 'runtime') {
|
|
1825
2292
|
const event = allowlistedLabel(value.event, ['ready', 'stopped']);
|
|
@@ -1830,6 +2297,10 @@ function sanitizeStructuredEvent(message) {
|
|
|
1830
2297
|
if (value.scope === 'ffmpeg') {
|
|
1831
2298
|
return sanitizeAdaptationNotice(value, level);
|
|
1832
2299
|
}
|
|
2300
|
+
if (value.scope === 'live-preview') {
|
|
2301
|
+
const preview = sanitizeLivePreview(value);
|
|
2302
|
+
return preview ? { scope: 'live-preview', level: 'info', ...preview } : undefined;
|
|
2303
|
+
}
|
|
1833
2304
|
if (value.scope === 'diagnostic-condition') {
|
|
1834
2305
|
const homeKitDefinition = typeof value.code === 'string' && Object.hasOwn(HOMEKIT_CONDITIONS, value.code)
|
|
1835
2306
|
? HOMEKIT_CONDITIONS[value.code]
|
|
@@ -1864,17 +2335,18 @@ function sanitizeStructuredEvent(message) {
|
|
|
1864
2335
|
const affectedAccessoryCount = nonNegativeInteger(value.affectedAccessoryCount);
|
|
1865
2336
|
const accessoryAliases = Array.isArray(value.accessoryAliases)
|
|
1866
2337
|
? value.accessoryAliases
|
|
1867
|
-
.filter((alias) =>
|
|
2338
|
+
.filter((alias) => opaqueAccessoryAlias(alias) !== undefined)
|
|
1868
2339
|
.slice(0, MAX_ACCESSORY_ALIASES)
|
|
1869
2340
|
: [];
|
|
2341
|
+
const { level: conditionLevel, actionKey } = homeKitConditionPresentation(value.code, value.active, value.reason);
|
|
1870
2342
|
return {
|
|
1871
2343
|
scope: 'diagnostic-condition',
|
|
1872
|
-
level:
|
|
2344
|
+
level: conditionLevel,
|
|
1873
2345
|
code: value.code,
|
|
1874
2346
|
active: value.active,
|
|
1875
2347
|
reason: value.reason,
|
|
1876
2348
|
summaryKey: homeKitDefinition[0],
|
|
1877
|
-
actionKey
|
|
2349
|
+
actionKey,
|
|
1878
2350
|
...(typeof value.capability === 'string' && CAPABILITIES.has(value.capability)
|
|
1879
2351
|
? { capability: value.capability }
|
|
1880
2352
|
: {}),
|
|
@@ -1886,19 +2358,22 @@ function sanitizeStructuredEvent(message) {
|
|
|
1886
2358
|
}
|
|
1887
2359
|
return undefined;
|
|
1888
2360
|
}
|
|
1889
|
-
/**
|
|
1890
|
-
|
|
1891
|
-
const
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
target.debug?.(JSON.stringify({
|
|
1896
|
-
scope: 'runtime-notice',
|
|
1897
|
-
level: notice.level,
|
|
2361
|
+
/** The record one notice is written as, read by both halves of the path. */
|
|
2362
|
+
function runtimeNoticeRecord(code, durationMs) {
|
|
2363
|
+
const [level, messageKey, durationMessageKey] = RUNTIME_NOTICES[code];
|
|
2364
|
+
return {
|
|
2365
|
+
scope: NOTICE_SCOPES[code] ?? 'runtime-notice',
|
|
2366
|
+
level,
|
|
1898
2367
|
code,
|
|
1899
|
-
messageKey,
|
|
2368
|
+
messageKey: durationMs !== undefined && durationMessageKey ? durationMessageKey : messageKey,
|
|
1900
2369
|
...(durationMs === undefined ? {} : { durationMs }),
|
|
1901
|
-
}
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
/** Emits one fixed-shape operational notice selected by its allowlisted code. */
|
|
2373
|
+
export function reportRuntimeNotice(target, code, fields = {}) {
|
|
2374
|
+
const record = runtimeNoticeRecord(code, fields.durationMs === undefined ? undefined : Math.max(0, Math.trunc(fields.durationMs)));
|
|
2375
|
+
target[record.level]?.(`[${code}] ${localize(target, record.messageKey, { durationMs: record.durationMs ?? 0, finishBy: fields.finishBy ?? '' })}`);
|
|
2376
|
+
target.debug?.(JSON.stringify(record));
|
|
1902
2377
|
}
|
|
1903
2378
|
/**
|
|
1904
2379
|
* Picks up an authorization the persisted session file confirms, reporting whether it did.
|
|
@@ -1906,68 +2381,124 @@ export function reportRuntimeNotice(target, code, fields = {}) {
|
|
|
1906
2381
|
* The file is the authority: the supplied identifier only names which window the caller believes was opened,
|
|
1907
2382
|
* and a session the file does not hold as active changes nothing and reports nothing. A confirmed pickup emits
|
|
1908
2383
|
* one operational record, which is the evidence that this process began retaining the window's scopes, taken
|
|
1909
|
-
* at the moment it did rather than whenever a later record arrives
|
|
2384
|
+
* at the moment it did rather than whenever a later record arrives, and its console line names the time the
|
|
2385
|
+
* capture stops on its own.
|
|
1910
2386
|
*/
|
|
1911
2387
|
export function armDiagnosticsAuthorization(target, storageRoot, supportCaseId, now = Date.now) {
|
|
1912
|
-
|
|
1913
|
-
|
|
2388
|
+
const session = activeDiagnosticsSession(storageRoot, now());
|
|
2389
|
+
if (!isSupportCaseId(supportCaseId) || session?.supportCaseId !== supportCaseId) {
|
|
1914
2390
|
return false;
|
|
1915
2391
|
}
|
|
1916
|
-
reportRuntimeNotice(target, 'diagnostics-authorization-armed'
|
|
2392
|
+
reportRuntimeNotice(target, 'diagnostics-authorization-armed', {
|
|
2393
|
+
finishBy: new Date(session.expiresAt).toLocaleString(),
|
|
2394
|
+
});
|
|
1917
2395
|
return true;
|
|
1918
2396
|
}
|
|
1919
|
-
/**
|
|
1920
|
-
export function
|
|
1921
|
-
const
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
level: 'warn',
|
|
1927
|
-
code,
|
|
1928
|
-
messageKey,
|
|
1929
|
-
}));
|
|
2397
|
+
/** States once which encoders code live video on this host: every qualified hardware encoder, then `libx264`. */
|
|
2398
|
+
export function reportLiveVideoEncoders(target, encoders) {
|
|
2399
|
+
const names = [
|
|
2400
|
+
...LIVE_VIDEO_ENCODERS.filter((name) => Object.values(encoders[name] ?? {}).includes('qualified') && name !== 'libx264'),
|
|
2401
|
+
'libx264',
|
|
2402
|
+
];
|
|
2403
|
+
target.info(`[live-video-encoders] ${localize(target, 'log.liveVideoEncoders', { encoders: names.join(', ') })}`);
|
|
1930
2404
|
}
|
|
1931
|
-
/**
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...selection }));
|
|
1944
|
-
}
|
|
1945
|
-
return;
|
|
2405
|
+
/**
|
|
2406
|
+
* Narrows one offered station arbitration decision, or nothing where its own labels do not narrow.
|
|
2407
|
+
*
|
|
2408
|
+
* Read by both halves of the path — {@link reportStationClaim} builds the record and the file sink rebuilds
|
|
2409
|
+
* it — because a record only one of them recognises is written to a console nobody is reading and dropped
|
|
2410
|
+
* before it reaches a support archive.
|
|
2411
|
+
*/
|
|
2412
|
+
function sanitizeStationClaim(value) {
|
|
2413
|
+
const action = allowlistedLabel(value.action, STATION_CLAIM_ACTIONS);
|
|
2414
|
+
const claim = allowlistedLabel(value.claim, STATION_CLAIMS);
|
|
2415
|
+
if (!action || !claim) {
|
|
2416
|
+
return undefined;
|
|
1946
2417
|
}
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
2418
|
+
const against = allowlistedLabel(value.to ?? value.by ?? value.against, STATION_CLAIMS);
|
|
2419
|
+
return { event: 'station-claim', action, claim, ...(against === undefined ? {} : { against }) };
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2422
|
+
* Narrows one offered unrouted SDK event to its kind and target, or nothing where either is not allowlisted.
|
|
2423
|
+
*
|
|
2424
|
+
* Read by both halves of the path — {@link reportHomeKitEvent} builds the record and the file sink rebuilds it.
|
|
2425
|
+
*/
|
|
2426
|
+
function sanitizeUnroutedEvent(value) {
|
|
2427
|
+
const kind = allowlistedLabel(value.kind, UNROUTED_EVENT_KINDS);
|
|
2428
|
+
const target = allowlistedLabel(value.target, UNROUTED_EVENT_TARGETS);
|
|
2429
|
+
return kind && target ? { event: 'event-unrouted', kind, target } : undefined;
|
|
2430
|
+
}
|
|
2431
|
+
/**
|
|
2432
|
+
* Records one arbitration decision over a station's own session as `homekit-log` evidence.
|
|
2433
|
+
*
|
|
2434
|
+
* A still that stood aside produced no picture of its own and no failure, so without this the deferral is
|
|
2435
|
+
* indistinguishable afterwards from a capture that was never asked for. The record carries the claims and the
|
|
2436
|
+
* decision alone: a station identity is a serial, and the camera holding it is already named by the records
|
|
2437
|
+
* about that camera.
|
|
2438
|
+
*/
|
|
2439
|
+
export function reportStationClaim(target, decision) {
|
|
2440
|
+
const claim = sanitizeStationClaim(decision);
|
|
2441
|
+
if (target.debug && claim) {
|
|
2442
|
+
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...claim }));
|
|
1953
2443
|
}
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
2444
|
+
}
|
|
2445
|
+
/**
|
|
2446
|
+
* Narrows one offered HomeKit adapter fact to its `homekit` record, or nothing where its route does not narrow.
|
|
2447
|
+
*
|
|
2448
|
+
* Read by both halves of the path: {@link reportHomeKitEvent} builds the record and the file sink rebuilds it.
|
|
2449
|
+
*/
|
|
2450
|
+
function sanitizeHomeKitEvent(value) {
|
|
2451
|
+
if (value.event === 'event-unrouted') {
|
|
2452
|
+
const unrouted = sanitizeUnroutedEvent(value);
|
|
2453
|
+
return unrouted ? { scope: 'homekit', level: 'debug', ...unrouted } : undefined;
|
|
2454
|
+
}
|
|
2455
|
+
if (value.event === 'station-claim') {
|
|
2456
|
+
const claim = sanitizeStationClaim(value);
|
|
2457
|
+
return claim ? { scope: 'homekit', level: 'debug', ...claim } : undefined;
|
|
2458
|
+
}
|
|
2459
|
+
if (value.adapter === 'camera.streaming' && value.event === 'live-video-selected') {
|
|
2460
|
+
const selection = sanitizeLiveVideoSelection(value);
|
|
2461
|
+
return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
|
|
2462
|
+
}
|
|
2463
|
+
if (value.adapter === 'camera.streaming' && HOMEKIT_LIVE_REQUEST_EVENTS.has(String(value.event))) {
|
|
2464
|
+
const lifecycle = sanitizeLiveSessionTrace(value);
|
|
2465
|
+
return lifecycle ? { scope: 'homekit', level: 'debug', ...lifecycle } : undefined;
|
|
2466
|
+
}
|
|
2467
|
+
if (typeof value.adapter !== 'string' ||
|
|
2468
|
+
typeof value.event !== 'string' ||
|
|
2469
|
+
!HOMEKIT_EVENT_ROUTES[value.adapter]?.has(value.event) ||
|
|
2470
|
+
typeof value.observation !== 'string' ||
|
|
2471
|
+
!HOMEKIT_OBSERVATIONS.has(value.observation)) {
|
|
2472
|
+
return undefined;
|
|
1960
2473
|
}
|
|
1961
|
-
|
|
2474
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
2475
|
+
return {
|
|
1962
2476
|
scope: 'homekit',
|
|
1963
2477
|
level: 'debug',
|
|
1964
|
-
adapter:
|
|
1965
|
-
event:
|
|
1966
|
-
observation:
|
|
1967
|
-
...(typeof
|
|
1968
|
-
? { announcedBy:
|
|
2478
|
+
adapter: value.adapter,
|
|
2479
|
+
event: value.event,
|
|
2480
|
+
observation: value.observation,
|
|
2481
|
+
...(typeof value.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(value.announcedBy)
|
|
2482
|
+
? { announcedBy: value.announcedBy }
|
|
1969
2483
|
: {}),
|
|
1970
|
-
|
|
2484
|
+
...(typeof value.code === 'number' && Number.isInteger(value.code) && value.code >= 0 && value.code <= 0xffff
|
|
2485
|
+
? { code: value.code }
|
|
2486
|
+
: {}),
|
|
2487
|
+
...(alias === undefined ? {} : { accessory: alias }),
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* Emits one allowlisted HomeKit event trace only when host debug output is available.
|
|
2492
|
+
*
|
|
2493
|
+
* `aliasFor` resolves the trace's device identity to the support-case alias the conditions about that accessory
|
|
2494
|
+
* already carry. The identity itself never reaches the record: an unresolved one is recorded as no accessory.
|
|
2495
|
+
*/
|
|
2496
|
+
export function reportHomeKitEvent(target, trace, aliasFor) {
|
|
2497
|
+
const alias = 'serial' in trace && typeof trace.serial === 'string' ? aliasFor?.(trace.serial) : undefined;
|
|
2498
|
+
const record = sanitizeHomeKitEvent({ ...trace, accessory: alias });
|
|
2499
|
+
if (target.debug && record) {
|
|
2500
|
+
target.debug(JSON.stringify(record));
|
|
2501
|
+
}
|
|
1971
2502
|
}
|
|
1972
2503
|
/**
|
|
1973
2504
|
* Records one FFmpeg adaptation fact as the `ffmpeg-log` evidence the media support profiles declare.
|
|
@@ -1987,19 +2518,8 @@ export function reportAdaptationNotice(target, trace) {
|
|
|
1987
2518
|
}
|
|
1988
2519
|
}
|
|
1989
2520
|
/**
|
|
1990
|
-
* Reduces one FFmpeg stderr line to what a support case may keep, or nothing where nothing is left of it
|
|
1991
|
-
*
|
|
1992
|
-
* The line is the only place an encoder-level cause is stated outright, and it is also the only place this
|
|
1993
|
-
* plugin's own argument list can be echoed back: the output URL carries base64 SRTP key material, the
|
|
1994
|
-
* controller address sits beside it, and an SDK snapshot filename carries a device serial. Each of those is
|
|
1995
|
-
* replaced by what it is rather than searched for afterwards, because a redaction that depends on
|
|
1996
|
-
* recognising a secret fails on the first message shape nobody predicted.
|
|
1997
|
-
*
|
|
1998
|
-
* The key material is replaced before the path rules run, and not after. Base64 includes `/`, so a path rule
|
|
1999
|
-
* applied first splits a key into sub-runs too short for any length threshold to catch — measured on random
|
|
2000
|
-
* 30-byte keys, roughly one line in twenty then kept an eight-character fragment verbatim. The cost of this
|
|
2001
|
-
* order is that a long path made only of letters and separators is labelled as redacted rather than as a
|
|
2002
|
-
* path, which loses a label and never a secret.
|
|
2521
|
+
* Reduces one FFmpeg stderr line to what a support case may keep, or nothing where nothing is left of it: a
|
|
2522
|
+
* bounded printable line, never a `progress=` one, through {@link redactSensitiveText}.
|
|
2003
2523
|
*/
|
|
2004
2524
|
function redactAdaptationStderr(line) {
|
|
2005
2525
|
const printable = boundedText(line, MAX_ADAPTATION_STDERR_LINE_LENGTH);
|
|
@@ -2073,73 +2593,154 @@ function sanitizeAdaptationNotice(value, level) {
|
|
|
2073
2593
|
.filter((line) => line !== undefined)
|
|
2074
2594
|
.slice(-MAX_ADAPTATION_STDERR_LINES);
|
|
2075
2595
|
const sourceFragments = nonNegativeInteger(value.sourceFragments);
|
|
2596
|
+
const sourceWidth = nonNegativeInteger(value.sourceWidth);
|
|
2597
|
+
const sourceHeight = nonNegativeInteger(value.sourceHeight);
|
|
2598
|
+
const cause = allowlistedLabel(value.cause, ADAPTATION_CAUSES);
|
|
2599
|
+
const encoder = allowlistedLabel(value.encoder, LIVE_VIDEO_ENCODERS);
|
|
2600
|
+
const source = allowlistedLabel(value.source, LIVE_VIDEO_SOURCES);
|
|
2076
2601
|
return {
|
|
2077
2602
|
scope: 'ffmpeg',
|
|
2078
2603
|
level,
|
|
2079
2604
|
role: value.role,
|
|
2080
2605
|
event: value.event,
|
|
2606
|
+
...(cause === undefined ? {} : { cause }),
|
|
2607
|
+
...(encoder === undefined ? {} : { encoder }),
|
|
2608
|
+
...(source === undefined ? {} : { source }),
|
|
2609
|
+
...(sourceWidth === undefined || sourceHeight === undefined ? {} : { sourceWidth, sourceHeight }),
|
|
2081
2610
|
...(code === undefined ? {} : { code }),
|
|
2082
2611
|
...(signal === undefined ? {} : { signal }),
|
|
2083
2612
|
...(sourceFragments === undefined ? {} : { sourceFragments }),
|
|
2084
2613
|
...(stderr.length ? { stderr } : {}),
|
|
2085
2614
|
};
|
|
2086
2615
|
}
|
|
2616
|
+
/**
|
|
2617
|
+
* Records how one settings-page live preview ended, as a `live-preview` record kept whether or not a support
|
|
2618
|
+
* window is open: it is not one of the {@link VERBOSE_SCOPES}, because it holds only allowlisted labels and
|
|
2619
|
+
* bounded numbers, one per run a user started.
|
|
2620
|
+
*/
|
|
2621
|
+
export function reportLivePreview(target, trace) {
|
|
2622
|
+
const { level, ...selection } = trace.selection;
|
|
2623
|
+
const preview = sanitizeLivePreview({ ...trace, ...selection, levelName: level });
|
|
2624
|
+
if (preview)
|
|
2625
|
+
target.debug?.(JSON.stringify({ scope: 'live-preview', level: 'info', ...preview }));
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* Narrows one offered live preview run, or nothing where its selection or outcome does not narrow.
|
|
2629
|
+
*
|
|
2630
|
+
* Read by both halves of the path, {@link reportLivePreview} and the file sink, so a record only one of them
|
|
2631
|
+
* recognised could not reach a support archive.
|
|
2632
|
+
*/
|
|
2633
|
+
function sanitizeLivePreview(value) {
|
|
2634
|
+
const selection = sanitizePreviewSelection(value);
|
|
2635
|
+
const outcome = allowlistedLabel(value.outcome, PREVIEW_OUTCOMES);
|
|
2636
|
+
if (!selection || !outcome)
|
|
2637
|
+
return undefined;
|
|
2638
|
+
const encoder = allowlistedLabel(value.encoder, LIVE_VIDEO_ENCODERS);
|
|
2639
|
+
const source = allowlistedLabel(value.source, LIVE_VIDEO_SOURCES);
|
|
2640
|
+
const kbps = boundedInteger(value.kbps, MAX_LIVE_VIDEO_BIT_RATE);
|
|
2641
|
+
const accessory = opaqueAccessoryAlias(value.accessory);
|
|
2642
|
+
const ask = allowlistedLabel(value.ask, PREVIEW_ASKS);
|
|
2643
|
+
const settings = PREVIEW_SETTINGS.filter((setting) => typeof value[setting] === 'boolean');
|
|
2644
|
+
return {
|
|
2645
|
+
...selection,
|
|
2646
|
+
...(encoder === undefined ? {} : { encoder }),
|
|
2647
|
+
...(source === undefined ? {} : { source }),
|
|
2648
|
+
...(kbps === undefined ? {} : { kbps }),
|
|
2649
|
+
...(ask === undefined ? {} : { ask }),
|
|
2650
|
+
...Object.fromEntries(settings.map((setting) => [setting, value[setting]])),
|
|
2651
|
+
outcome,
|
|
2652
|
+
...(accessory === undefined ? {} : { accessory }),
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
/** A live preview's selection as flat bounded fields, or nothing where any of them falls outside its allowlist. */
|
|
2656
|
+
function sanitizePreviewSelection(candidate) {
|
|
2657
|
+
const fields = {
|
|
2658
|
+
profile: allowlistedLabel(candidate.profile, HOMEKIT_LIVE_VIDEO_PROFILES),
|
|
2659
|
+
levelName: allowlistedLabel(candidate.levelName, HOMEKIT_LIVE_VIDEO_LEVELS),
|
|
2660
|
+
width: boundedInteger(candidate.width, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION),
|
|
2661
|
+
height: boundedInteger(candidate.height, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION),
|
|
2662
|
+
fps: boundedInteger(candidate.fps, MAX_LIVE_VIDEO_FRAME_RATE, MIN_LIVE_VIDEO_FRAME_RATE),
|
|
2663
|
+
maxBitRate: boundedInteger(candidate.maxBitRate, MAX_LIVE_VIDEO_BIT_RATE),
|
|
2664
|
+
};
|
|
2665
|
+
return Object.values(fields).includes(undefined) ? undefined : fields;
|
|
2666
|
+
}
|
|
2087
2667
|
function sanitizeLiveSessionTrace(value) {
|
|
2088
2668
|
if (value.adapter !== 'camera.streaming') {
|
|
2089
2669
|
return undefined;
|
|
2090
2670
|
}
|
|
2091
|
-
|
|
2092
|
-
|
|
2671
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
2672
|
+
const accessory = alias === undefined ? {} : { accessory: alias };
|
|
2673
|
+
if (value.event === 'live-session-released') {
|
|
2674
|
+
const release = allowlistedLabel(value.release, LIVE_SESSION_RELEASES);
|
|
2675
|
+
if (!release) {
|
|
2676
|
+
return undefined;
|
|
2677
|
+
}
|
|
2678
|
+
const reports = nonNegativeInteger(value.reports);
|
|
2679
|
+
const sinceLastReportMs = boundedInteger(value.sinceLastReportMs, MAX_STARTUP_WINDOW_MS);
|
|
2680
|
+
return {
|
|
2681
|
+
adapter: value.adapter,
|
|
2682
|
+
event: value.event,
|
|
2683
|
+
release,
|
|
2684
|
+
...(reports === undefined ? {} : { reports }),
|
|
2685
|
+
...(sinceLastReportMs === undefined ? {} : { sinceLastReportMs }),
|
|
2686
|
+
...(value.stage === 'sdk-source-acquisition' ? { stage: value.stage } : {}),
|
|
2687
|
+
...accessory,
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
if (value.event === 'live-session-streaming' || value.event === 'live-talkback-started') {
|
|
2691
|
+
return { adapter: value.adapter, event: value.event, ...accessory };
|
|
2093
2692
|
}
|
|
2094
2693
|
if (value.event === 'live-request-refused') {
|
|
2095
2694
|
return typeof value.reason === 'string' && REFUSAL_REASONS.has(value.reason)
|
|
2096
|
-
? { adapter: value.adapter, event: value.event, reason: value.reason }
|
|
2695
|
+
? { adapter: value.adapter, event: value.event, reason: value.reason, ...accessory }
|
|
2097
2696
|
: undefined;
|
|
2098
2697
|
}
|
|
2099
2698
|
if (value.event === 'live-request-unaccounted') {
|
|
2100
2699
|
return typeof value.afterMs === 'number' && Number.isFinite(value.afterMs)
|
|
2101
|
-
? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs) }
|
|
2700
|
+
? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs), ...accessory }
|
|
2102
2701
|
: undefined;
|
|
2103
2702
|
}
|
|
2104
2703
|
if (value.event !== 'live-session-failed' ||
|
|
2105
2704
|
value.outcome !== 'failed' ||
|
|
2106
2705
|
typeof value.reason !== 'string' ||
|
|
2107
2706
|
!REASONS.has(value.reason) ||
|
|
2108
|
-
|
|
2109
|
-
!HOMEKIT_LIVE_SESSION_STAGES.has(value.stage)) {
|
|
2707
|
+
!allowlistedLabel(value.stage, HOMEKIT_LIVE_SESSION_STAGES)) {
|
|
2110
2708
|
return undefined;
|
|
2111
2709
|
}
|
|
2710
|
+
const sourceStage = allowlistedLabel(value.sourceStage, SDK_LIVE_START_STAGES);
|
|
2711
|
+
const sourceReason = allowlistedLabel(value.sourceReason, SDK_LIVE_START_REASONS);
|
|
2112
2712
|
return {
|
|
2113
2713
|
adapter: value.adapter,
|
|
2114
2714
|
event: value.event,
|
|
2115
2715
|
outcome: value.outcome,
|
|
2116
2716
|
reason: value.reason,
|
|
2117
2717
|
stage: value.stage,
|
|
2718
|
+
...(sourceStage === undefined ? {} : { sourceStage }),
|
|
2719
|
+
...(sourceReason === undefined ? {} : { sourceReason }),
|
|
2720
|
+
...accessory,
|
|
2118
2721
|
};
|
|
2119
2722
|
}
|
|
2120
2723
|
function sanitizeLiveVideoSelection(value) {
|
|
2121
|
-
const operation =
|
|
2122
|
-
const profile =
|
|
2123
|
-
const codecLevel = typeof value.levelName === 'string' ? value.levelName :
|
|
2724
|
+
const operation = allowlistedLabel(value.operation, HOMEKIT_LIVE_VIDEO_OPERATIONS);
|
|
2725
|
+
const profile = allowlistedLabel(value.profile, HOMEKIT_LIVE_VIDEO_PROFILES);
|
|
2726
|
+
const codecLevel = allowlistedLabel(typeof value.levelName === 'string' ? value.levelName : value.level, HOMEKIT_LIVE_VIDEO_LEVELS);
|
|
2124
2727
|
const width = boundedInteger(value.width, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION);
|
|
2125
2728
|
const height = boundedInteger(value.height, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION);
|
|
2126
2729
|
const fps = boundedInteger(value.fps, MAX_LIVE_VIDEO_FRAME_RATE, MIN_LIVE_VIDEO_FRAME_RATE);
|
|
2127
2730
|
const mtu = boundedInteger(value.mtu, MAX_LIVE_VIDEO_MTU, MIN_LIVE_VIDEO_MTU);
|
|
2128
|
-
const
|
|
2731
|
+
const maxBitRate = boundedInteger(value.maxBitRate, MAX_LIVE_VIDEO_BIT_RATE, 0);
|
|
2732
|
+
const addressVersion = allowlistedLabel(value.addressVersion, HOMEKIT_LIVE_ADDRESS_VERSIONS);
|
|
2129
2733
|
if (!operation ||
|
|
2130
|
-
!HOMEKIT_LIVE_VIDEO_OPERATIONS.has(operation) ||
|
|
2131
2734
|
!profile ||
|
|
2132
|
-
!HOMEKIT_LIVE_VIDEO_PROFILES.has(profile) ||
|
|
2133
2735
|
!codecLevel ||
|
|
2134
|
-
!HOMEKIT_LIVE_VIDEO_LEVELS.has(codecLevel) ||
|
|
2135
2736
|
width === undefined ||
|
|
2136
2737
|
height === undefined ||
|
|
2137
2738
|
fps === undefined ||
|
|
2138
2739
|
mtu === undefined ||
|
|
2139
|
-
!addressVersion
|
|
2140
|
-
!HOMEKIT_LIVE_ADDRESS_VERSIONS.has(addressVersion)) {
|
|
2740
|
+
!addressVersion) {
|
|
2141
2741
|
return undefined;
|
|
2142
2742
|
}
|
|
2743
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
2143
2744
|
return {
|
|
2144
2745
|
adapter: 'camera.streaming',
|
|
2145
2746
|
event: 'live-video-selected',
|
|
@@ -2150,7 +2751,9 @@ function sanitizeLiveVideoSelection(value) {
|
|
|
2150
2751
|
height,
|
|
2151
2752
|
fps,
|
|
2152
2753
|
mtu,
|
|
2754
|
+
...(maxBitRate === undefined ? {} : { maxBitRate }),
|
|
2153
2755
|
addressVersion,
|
|
2756
|
+
...(alias === undefined ? {} : { accessory: alias }),
|
|
2154
2757
|
};
|
|
2155
2758
|
}
|
|
2156
2759
|
/** Emits bounded normal-output condition transitions without stable device or account identity. */
|
|
@@ -2209,10 +2812,11 @@ export class DiagnosticConditions {
|
|
|
2209
2812
|
}
|
|
2210
2813
|
const uniqueDeviceIds = condition.active ? [...new Set(affectedDeviceIds)].sort() : [];
|
|
2211
2814
|
const accessoryAliases = uniqueDeviceIds
|
|
2212
|
-
.map((identity) => this.
|
|
2815
|
+
.map((identity) => this.aliasFor(identity))
|
|
2213
2816
|
.filter((alias) => alias !== undefined)
|
|
2214
2817
|
.sort();
|
|
2215
|
-
|
|
2818
|
+
const { level, actionKey } = homeKitConditionPresentation(condition.code, condition.active, condition.reason);
|
|
2819
|
+
this.write(condition.code, condition.active, condition.reason, definition[0], actionKey, level, {
|
|
2216
2820
|
...(condition.capability === undefined ? {} : { capability: condition.capability }),
|
|
2217
2821
|
...(condition.member === undefined ? {} : { member: condition.member }),
|
|
2218
2822
|
affectedAccessoryCount: uniqueDeviceIds.length,
|
|
@@ -2224,9 +2828,19 @@ export class DiagnosticConditions {
|
|
|
2224
2828
|
const alias = this.aliases.get(serial);
|
|
2225
2829
|
return name === undefined ? undefined : alias === undefined ? name : `${name} (${alias})`;
|
|
2226
2830
|
})
|
|
2227
|
-
.filter((named) => named !== undefined));
|
|
2831
|
+
.filter((named) => named !== undefined), uniqueDeviceIds);
|
|
2228
2832
|
}
|
|
2229
|
-
|
|
2833
|
+
/** Every condition active now, each held from the transition that raised it to the one that clears it. */
|
|
2834
|
+
current() {
|
|
2835
|
+
return [...this.active.values()].map(({ condition }) => condition);
|
|
2836
|
+
}
|
|
2837
|
+
/**
|
|
2838
|
+
* The support-case alias for one device identity, minted on first use.
|
|
2839
|
+
*
|
|
2840
|
+
* The one place an alias is decided, so every record about a device — a condition, a media trace — carries
|
|
2841
|
+
* the same one, and the owner's console line is what resolves it to a name.
|
|
2842
|
+
*/
|
|
2843
|
+
aliasFor(identity) {
|
|
2230
2844
|
let alias = this.aliases.get(identity);
|
|
2231
2845
|
if (!alias && this.aliases.size < MAX_ACCESSORY_ALIASES) {
|
|
2232
2846
|
alias = `accessory-${randomUUID()}`;
|
|
@@ -2236,13 +2850,16 @@ export class DiagnosticConditions {
|
|
|
2236
2850
|
}
|
|
2237
2851
|
write(code, active, reason, summaryKey, actionKey, level, fields = {}, conditionKey = code,
|
|
2238
2852
|
/** Accessory names for the console line only — never retained, see {@link DiagnosticConditions}. */
|
|
2239
|
-
names) {
|
|
2240
|
-
const fingerprint = JSON.stringify({ active, reason, ...fields });
|
|
2853
|
+
names, serials = []) {
|
|
2854
|
+
const fingerprint = JSON.stringify({ active, reason, ...fields, serials });
|
|
2241
2855
|
if (active) {
|
|
2242
|
-
if (this.active.get(conditionKey) === fingerprint) {
|
|
2856
|
+
if (this.active.get(conditionKey)?.fingerprint === fingerprint) {
|
|
2243
2857
|
return;
|
|
2244
2858
|
}
|
|
2245
|
-
this.active.set(conditionKey,
|
|
2859
|
+
this.active.set(conditionKey, {
|
|
2860
|
+
fingerprint,
|
|
2861
|
+
condition: { code, summaryKey, actionKey, ...(serials.length === 0 ? {} : { serials: [...serials] }) },
|
|
2862
|
+
});
|
|
2246
2863
|
}
|
|
2247
2864
|
else if (!this.active.delete(conditionKey)) {
|
|
2248
2865
|
return;
|