@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.4 → 5.0.0-beta.41
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 +966 -337
- 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 +94 -41
- 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 +161 -82
- package/homebridge-ui/public/i18n/fr.json +162 -83
- package/homebridge-ui/public/index.html +224 -96
- package/homebridge-ui/public/js/app.js +821 -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,12 +239,31 @@ 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',
|
|
159
254
|
'dashboard-opened',
|
|
160
255
|
'authentication-opened',
|
|
161
256
|
'request-failed',
|
|
257
|
+
'authentication-submitted',
|
|
258
|
+
'authentication-captcha',
|
|
259
|
+
'authentication-two-factor',
|
|
260
|
+
'authentication-restart-required',
|
|
261
|
+
'authentication-blocked',
|
|
262
|
+
'authentication-plugin-running',
|
|
263
|
+
'authentication-failed',
|
|
264
|
+
'authentication-commit-failed',
|
|
265
|
+
'authentication-timed-out',
|
|
266
|
+
'authentication-closed',
|
|
162
267
|
];
|
|
163
268
|
const SUPPORT_ARCHIVE_KEY = {
|
|
164
269
|
keyId: 'support-2026-08-01',
|
|
@@ -208,32 +313,41 @@ const BUG_REPORT_FORM = 'https://github.com/homebridge-plugins/homebridge-eufy/i
|
|
|
208
313
|
*
|
|
209
314
|
* Addresses the committed issue form by filename, and fills the field ids that form declares. A prefill is
|
|
210
315
|
* honoured only against a named template, and only the operational class travels in a query string.
|
|
316
|
+
*
|
|
317
|
+
* Every field this process can answer is answered, because a blank one is charged to the reporter: the form's
|
|
318
|
+
* own triage asks for what the environment block leaves empty, so a line left for the reporter to complete
|
|
319
|
+
* turns a report made the intended way into an incomplete one. The host version is the host's own public
|
|
320
|
+
* version and empty where no run has recorded it yet. The support case id is not among them — it sits above
|
|
321
|
+
* the operational class, and the archive's own filename carries it to the report that attaches one.
|
|
322
|
+
*
|
|
323
|
+
* The archive field is left unaddressed, because a query string cannot carry a file. Any text placed there
|
|
324
|
+
* satisfies the field's own `required` validation, so a prefill would answer it on the reporter's behalf and
|
|
325
|
+
* the form would accept a report with no archive on it.
|
|
211
326
|
*/
|
|
212
|
-
function bugReportUrl(session, missingEvidence) {
|
|
327
|
+
function bugReportUrl(session, missingEvidence, host) {
|
|
213
328
|
const url = new URL(BUG_REPORT_FORM);
|
|
214
329
|
url.searchParams.set('template', 'bug_report.yml');
|
|
330
|
+
url.searchParams.set('title', `[Diagnostics] ${BUG_REPORT_AREAS[session.profile]}: `);
|
|
215
331
|
url.searchParams.set('environment', [
|
|
216
332
|
`- **Plugin Version**: ${PLUGIN_VERSION}`,
|
|
217
333
|
`- **eufy SDK**: ${SDK_VERSION}`,
|
|
218
334
|
`- **Node.js Version**: ${process.version}`,
|
|
219
|
-
`- **Homebridge Version**: `,
|
|
335
|
+
`- **Homebridge Version**: ${host?.homebridge ?? ''}`,
|
|
220
336
|
`- **OS**: ${process.platform} ${process.arch}`,
|
|
221
|
-
`- **
|
|
222
|
-
`- **Diagnostics profile**: ${session.profile} (${session.reproductionMode})`,
|
|
337
|
+
`- **Diagnostics profile**: ${session.profile}`,
|
|
223
338
|
`- **Missing evidence**: ${missingEvidence.length ? missingEvidence.join(', ') : 'none'}`,
|
|
224
339
|
].join('\n'));
|
|
225
340
|
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
341
|
return url.toString();
|
|
228
342
|
}
|
|
229
343
|
const DIAGNOSTICS_PROFILES = {
|
|
230
|
-
'startup-authentication': ['plugin-log', 'sdk-log'],
|
|
344
|
+
'startup-authentication': ['plugin-log', 'sdk-log', 'ui-log'],
|
|
231
345
|
'device-representation': ['plugin-log', 'sdk-log', 'homekit-log'],
|
|
232
346
|
'control-state': ['plugin-log', 'sdk-log', 'homekit-log'],
|
|
233
347
|
'live-media': ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
|
|
234
348
|
'hksv-recording': ['plugin-log', 'sdk-log', 'ffmpeg-log'],
|
|
235
349
|
'dashboard-ui': ['plugin-log', 'ui-log'],
|
|
236
|
-
other: ['plugin-log', 'sdk-log', 'homekit-log'],
|
|
350
|
+
other: ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
|
|
237
351
|
};
|
|
238
352
|
/** Narrows external profile input against the diagnostics-owned profile registry. */
|
|
239
353
|
export function isDiagnosticsProfile(value) {
|
|
@@ -283,6 +397,22 @@ function activeDiagnosticsSession(storageRoot, now) {
|
|
|
283
397
|
const session = readDiagnosticsSession(storageRoot);
|
|
284
398
|
return session && Date.parse(session.expiresAt) > now ? session : undefined;
|
|
285
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* A session as it stands at `now`: a capture still running when its authorization ends is finished at that end.
|
|
402
|
+
*
|
|
403
|
+
* A finished capture can be reviewed and exported until the archive retention has elapsed past the end of its
|
|
404
|
+
* authorization, so a capture that ran out its 72 hours keeps what it captured.
|
|
405
|
+
*/
|
|
406
|
+
function settledDiagnosticsSession(session, now) {
|
|
407
|
+
const expiresAt = Date.parse(session.expiresAt);
|
|
408
|
+
const settled = session.reproductionStartedAt && !session.reproductionEndedAt && expiresAt <= now
|
|
409
|
+
? { ...session, reproductionEndedAt: session.expiresAt }
|
|
410
|
+
: session;
|
|
411
|
+
return {
|
|
412
|
+
session: settled,
|
|
413
|
+
exportable: Boolean(settled.reproductionEndedAt) && now < expiresAt + SUPPORT_ARCHIVE_RETENTION_MS,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
286
416
|
/**
|
|
287
417
|
* Collapses one supplied string to bounded printable text, or nothing where none of it survives.
|
|
288
418
|
*
|
|
@@ -299,54 +429,170 @@ function boundedText(value, maximumLength) {
|
|
|
299
429
|
.trim();
|
|
300
430
|
return printable === '' ? undefined : printable;
|
|
301
431
|
}
|
|
302
|
-
|
|
303
|
-
|
|
432
|
+
/** Whether a value is a plain JSON object, which is the only shape a record crossing a process boundary has. */
|
|
433
|
+
function isRecord(value) {
|
|
434
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
435
|
+
}
|
|
436
|
+
function fleetPath(storageRoot) {
|
|
437
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, FLEET_FILE);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Records what the account's devices are and what each becomes, replacing the record whole.
|
|
441
|
+
*
|
|
442
|
+
* Written by the process that holds both the inventory and the accessory aliases, because an archive is
|
|
443
|
+
* assembled by another one. Rewritten on every complete inventory, so a device withdrawn or newly recognised
|
|
444
|
+
* cannot be reported as still present. A write that fails is dropped: an archive then declares no fleet, which
|
|
445
|
+
* is honest, whereas failing over a diagnostic file would cost the user their cameras.
|
|
446
|
+
*/
|
|
447
|
+
export function recordFleet(storageRoot, fleet) {
|
|
448
|
+
try {
|
|
449
|
+
const path = fleetPath(storageRoot);
|
|
450
|
+
mkdirSync(dirname(path), { mode: 0o700, recursive: true });
|
|
451
|
+
writeFileSync(path, `${JSON.stringify({ version: 1, devices: fleet })}\n`, { mode: 0o600 });
|
|
452
|
+
}
|
|
453
|
+
catch { }
|
|
454
|
+
}
|
|
455
|
+
/** Reads the recorded fleet, keeping each entry whose own fields narrow and dropping each that does not. */
|
|
456
|
+
function readFleet(storageRoot) {
|
|
457
|
+
try {
|
|
458
|
+
const candidate = JSON.parse(readFileSync(fleetPath(storageRoot), 'utf8'));
|
|
459
|
+
if (candidate.version !== 1 || !Array.isArray(candidate.devices)) {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
const devices = candidate.devices.flatMap((row) => {
|
|
463
|
+
if (!isRecord(row))
|
|
464
|
+
return [];
|
|
465
|
+
const accessory = opaqueAccessoryAlias(row.accessory);
|
|
466
|
+
if (accessory === undefined || typeof row.represented !== 'boolean' || typeof row.attached !== 'boolean') {
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
const model = typeof row.model === 'string' ? boundedText(row.model, MAX_DEVICE_MODEL_LENGTH) : undefined;
|
|
470
|
+
const services = (Array.isArray(row.services) ? row.services : [])
|
|
471
|
+
.filter((service) => typeof service === 'string')
|
|
472
|
+
.slice(0, MAX_FLEET_SERVICES)
|
|
473
|
+
.flatMap((service) => {
|
|
474
|
+
const bounded = boundedText(service, MAX_DEVICE_MODEL_LENGTH);
|
|
475
|
+
return bounded === undefined ? [] : [bounded];
|
|
476
|
+
});
|
|
477
|
+
return [
|
|
478
|
+
{
|
|
479
|
+
accessory,
|
|
480
|
+
...(model === undefined ? {} : { model }),
|
|
481
|
+
represented: row.represented,
|
|
482
|
+
attached: row.attached,
|
|
483
|
+
services,
|
|
484
|
+
},
|
|
485
|
+
];
|
|
486
|
+
});
|
|
487
|
+
return devices.length === 0 ? undefined : devices.slice(0, MAX_ACCESSORY_ALIASES);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
304
492
|
}
|
|
493
|
+
function hostEnvironmentPath(storageRoot) {
|
|
494
|
+
return join(storageRoot, DIAGNOSTICS_DIRECTORY, HOST_ENVIRONMENT_FILE);
|
|
495
|
+
}
|
|
496
|
+
/** The longest host version string retained, which bounds a value this plugin does not choose. */
|
|
497
|
+
const MAX_HOST_VERSION_LENGTH = 64;
|
|
305
498
|
/**
|
|
306
|
-
* Records
|
|
499
|
+
* Records what this run is hosted by, so a support archive states the host a failure came from.
|
|
500
|
+
*
|
|
501
|
+
* It is persisted rather than held in memory because the process that knows these facts is not the one that
|
|
502
|
+
* assembles an archive, and it is rewritten on every start so a host or binary that changed between runs
|
|
503
|
+
* cannot be reported as the one in use. Each call replaces the record whole, so a caller states everything it
|
|
504
|
+
* knows rather than accumulating into a file two writers would race for.
|
|
307
505
|
*
|
|
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.
|
|
506
|
+
* A write that fails is dropped: the archive then declares the environment without these facts, which is
|
|
507
|
+
* honest, whereas failing startup over a diagnostic file would cost the user their cameras.
|
|
313
508
|
*/
|
|
314
|
-
export function
|
|
509
|
+
export function recordHostEnvironment(storageRoot, host) {
|
|
315
510
|
try {
|
|
316
|
-
const path =
|
|
511
|
+
const path = hostEnvironmentPath(storageRoot);
|
|
317
512
|
mkdirSync(dirname(path), { mode: 0o700, recursive: true });
|
|
318
|
-
writeFileSync(path, `${JSON.stringify({ version:
|
|
513
|
+
writeFileSync(path, `${JSON.stringify({ version: 2, ...host })}\n`, { mode: 0o600 });
|
|
319
514
|
}
|
|
320
515
|
catch { }
|
|
321
516
|
}
|
|
322
517
|
/**
|
|
323
|
-
* Reads
|
|
518
|
+
* Reads what this run is hosted by, keeping each fact that narrows and dropping each that does not.
|
|
324
519
|
*
|
|
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.
|
|
520
|
+
* The facts are independent, so a malformed binary identity does not withhold a host version, and a record
|
|
521
|
+
* stating neither is no record at all.
|
|
328
522
|
*/
|
|
329
|
-
function
|
|
523
|
+
function readHostEnvironment(storageRoot) {
|
|
330
524
|
try {
|
|
331
|
-
const candidate = JSON.parse(readFileSync(
|
|
332
|
-
|
|
333
|
-
if (candidate.version !== 1 || !ffmpeg || typeof ffmpeg !== 'object' || Array.isArray(ffmpeg)) {
|
|
525
|
+
const candidate = JSON.parse(readFileSync(hostEnvironmentPath(storageRoot), 'utf8'));
|
|
526
|
+
if (candidate.version !== 2) {
|
|
334
527
|
return undefined;
|
|
335
528
|
}
|
|
336
|
-
const
|
|
337
|
-
|
|
529
|
+
const homebridge = typeof candidate.homebridge === 'string' ? boundedText(candidate.homebridge, MAX_HOST_VERSION_LENGTH) : undefined;
|
|
530
|
+
const ffmpeg = readFfmpegIdentity(candidate.ffmpeg);
|
|
531
|
+
if (homebridge === undefined && ffmpeg === undefined) {
|
|
338
532
|
return undefined;
|
|
339
533
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
: { path, source: recorded.source, ...(version === undefined ? {} : { version }) };
|
|
534
|
+
return {
|
|
535
|
+
...(homebridge === undefined ? {} : { homebridge }),
|
|
536
|
+
...(ffmpeg === undefined ? {} : { ffmpeg }),
|
|
537
|
+
};
|
|
345
538
|
}
|
|
346
539
|
catch {
|
|
347
540
|
return undefined;
|
|
348
541
|
}
|
|
349
542
|
}
|
|
543
|
+
/**
|
|
544
|
+
* The recorded adaptation binary, or nothing where the record's own fields do not narrow.
|
|
545
|
+
*
|
|
546
|
+
* The path and the banner are kept as written rather than pattern-replaced, because naming the binary is the
|
|
547
|
+
* entire purpose of the record and a redacted one answers nothing. Neither is device or account material: the
|
|
548
|
+
* path is this plugin's own setting or the binary it ships, and the banner is that build's public identity.
|
|
549
|
+
*/
|
|
550
|
+
function readFfmpegIdentity(recorded) {
|
|
551
|
+
if (!isRecord(recorded)) {
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
if (recorded.source !== 'bundled' && recorded.source !== 'configured') {
|
|
555
|
+
return undefined;
|
|
556
|
+
}
|
|
557
|
+
const path = typeof recorded.path === 'string' ? boundedText(recorded.path, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
|
|
558
|
+
const version = typeof recorded.version === 'string' ? boundedText(recorded.version, MAX_FFMPEG_IDENTITY_LENGTH) : undefined;
|
|
559
|
+
const encoders = isRecord(recorded.encoders) ? readEncoderQualification(recorded.encoders) : undefined;
|
|
560
|
+
return path === undefined
|
|
561
|
+
? undefined
|
|
562
|
+
: {
|
|
563
|
+
path,
|
|
564
|
+
source: recorded.source,
|
|
565
|
+
...(version === undefined ? {} : { version }),
|
|
566
|
+
...(encoders === undefined ? {} : { encoders }),
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* The recorded encoder qualification, keeping only encoder names, `profile@level` selections and outcomes this
|
|
571
|
+
* build names, so the record is bounded by those three closed sets, and a `bytes` outcome's two integers.
|
|
572
|
+
*/
|
|
573
|
+
function readEncoderQualification(recorded) {
|
|
574
|
+
return Object.fromEntries(LIVE_VIDEO_ENCODERS.filter((name) => Object.hasOwn(recorded, name)).map((name) => {
|
|
575
|
+
const outcomes = isRecord(recorded[name]) ? Object.entries(recorded[name]) : [];
|
|
576
|
+
return [
|
|
577
|
+
name,
|
|
578
|
+
Object.fromEntries(outcomes.flatMap(([selection, value]) => {
|
|
579
|
+
const [profile, level] = selection.split('@');
|
|
580
|
+
const outcome = Array.isArray(value) &&
|
|
581
|
+
value.length === 3 &&
|
|
582
|
+
value[0] === 'bytes' &&
|
|
583
|
+
value.slice(1).every(Number.isSafeInteger)
|
|
584
|
+
? value
|
|
585
|
+
: allowlistedLabel(value, QUALIFICATION_OUTCOMES);
|
|
586
|
+
return allowlistedLabel(profile, HOMEKIT_LIVE_VIDEO_PROFILES) &&
|
|
587
|
+
allowlistedLabel(level, HOMEKIT_LIVE_VIDEO_LEVELS) &&
|
|
588
|
+
selection === `${profile}@${level}` &&
|
|
589
|
+
outcome
|
|
590
|
+
? [[selection, outcome]]
|
|
591
|
+
: [];
|
|
592
|
+
})),
|
|
593
|
+
];
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
350
596
|
/**
|
|
351
597
|
* The one log evidence class a retained record belongs to.
|
|
352
598
|
*
|
|
@@ -440,6 +686,22 @@ export class GuidedDiagnostics {
|
|
|
440
686
|
await this.ensureMarker(session, 'reproduction-started', session.reproductionStartedAt);
|
|
441
687
|
return this.project(session);
|
|
442
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Deletes the persisted session without an archive, which also ends the verbose retention it authorized. UI events
|
|
691
|
+
* already queued are written first and later ones are refused until it is gone, so none lands after the delete.
|
|
692
|
+
*/
|
|
693
|
+
async cancel() {
|
|
694
|
+
this.uiEventsClosing = true;
|
|
695
|
+
try {
|
|
696
|
+
await this.uiEventWrites;
|
|
697
|
+
this.pendingSupportArchive = undefined;
|
|
698
|
+
await rm(diagnosticsSessionPath(this.storageRoot), { force: true });
|
|
699
|
+
return await this.status();
|
|
700
|
+
}
|
|
701
|
+
finally {
|
|
702
|
+
this.uiEventsClosing = false;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
443
705
|
async endReproduction() {
|
|
444
706
|
let session = this.requireAuthorized();
|
|
445
707
|
if (!session.reproductionStartedAt) {
|
|
@@ -480,8 +742,10 @@ export class GuidedDiagnostics {
|
|
|
480
742
|
this.pendingUiEvents += 1;
|
|
481
743
|
const write = this.uiEventWrites.then(async () => {
|
|
482
744
|
const session = this.requireAuthorized();
|
|
483
|
-
if (session.profile
|
|
484
|
-
|
|
745
|
+
if (!DIAGNOSTICS_PROFILES[session.profile].includes('ui-log') ||
|
|
746
|
+
!session.reproductionStartedAt ||
|
|
747
|
+
session.reproductionEndedAt) {
|
|
748
|
+
throw new Error('An active dashboard or sign-in reproduction is required');
|
|
485
749
|
}
|
|
486
750
|
const directory = join(this.storageRoot, DIAGNOSTICS_DIRECTORY);
|
|
487
751
|
const path = join(directory, UI_EVENTS_FILE);
|
|
@@ -520,10 +784,16 @@ export class GuidedDiagnostics {
|
|
|
520
784
|
/** Prepares the exact manifest and evidence snapshot that one subsequent export may encrypt. */
|
|
521
785
|
async reviewSupportArchive() {
|
|
522
786
|
await this.uiEventWrites;
|
|
523
|
-
const
|
|
787
|
+
const persisted = readDiagnosticsSession(this.storageRoot);
|
|
788
|
+
const { session, exportable } = persisted
|
|
789
|
+
? settledDiagnosticsSession(persisted, this.now())
|
|
790
|
+
: { session: undefined, exportable: false };
|
|
524
791
|
if (!session?.reproductionStartedAt || !session.reproductionEndedAt) {
|
|
525
792
|
throw new Error('A completed reproduction is required before archive review');
|
|
526
793
|
}
|
|
794
|
+
if (!exportable) {
|
|
795
|
+
throw new Error('Diagnostics authorization is inactive or expired');
|
|
796
|
+
}
|
|
527
797
|
const collected = await this.collectSupportEvidence(session);
|
|
528
798
|
const reproductionStartedAt = Date.parse(session.reproductionStartedAt);
|
|
529
799
|
const selected = DIAGNOSTICS_PROFILES[session.profile];
|
|
@@ -566,7 +836,13 @@ export class GuidedDiagnostics {
|
|
|
566
836
|
this.pendingSupportArchive = { reviewId, manifest, evidence: collected };
|
|
567
837
|
return { reviewId, manifest };
|
|
568
838
|
}
|
|
569
|
-
/**
|
|
839
|
+
/**
|
|
840
|
+
* Consumes one reviewed snapshot and returns an encrypted envelope without writing plaintext or an archive to disk.
|
|
841
|
+
*
|
|
842
|
+
* The session outlives the export, because nothing here proves the envelope reached the reporter: a fresh review
|
|
843
|
+
* exports it again until the reporter leaves it with {@link cancel}, another capture replaces it, or the archive
|
|
844
|
+
* retention ends it.
|
|
845
|
+
*/
|
|
570
846
|
async exportSupportArchive(reviewId) {
|
|
571
847
|
const pending = this.pendingSupportArchive;
|
|
572
848
|
if (!pending || pending.reviewId !== reviewId) {
|
|
@@ -612,18 +888,20 @@ export class GuidedDiagnostics {
|
|
|
612
888
|
authTag: cipher.getAuthTag().toString('base64'),
|
|
613
889
|
ciphertext: ciphertext.toString('base64'),
|
|
614
890
|
};
|
|
615
|
-
|
|
891
|
+
const exported = {
|
|
616
892
|
filename: `homebridge-eufy-${pending.manifest.supportCaseId}.eufysupport.gz`,
|
|
617
893
|
mediaType: 'application/gzip',
|
|
618
894
|
archive: await gzip(Buffer.from(`${JSON.stringify(envelope)}\n`, 'utf8')),
|
|
619
895
|
};
|
|
896
|
+
return exported;
|
|
620
897
|
}
|
|
621
898
|
finally {
|
|
622
899
|
contentKey.fill(0);
|
|
623
900
|
}
|
|
624
901
|
}
|
|
625
902
|
async collectSupportEvidence(session) {
|
|
626
|
-
const
|
|
903
|
+
const host = readHostEnvironment(this.storageRoot);
|
|
904
|
+
const fleet = readFleet(this.storageRoot);
|
|
627
905
|
const evidence = [
|
|
628
906
|
...(session.affectedDevices === undefined
|
|
629
907
|
? []
|
|
@@ -632,15 +910,34 @@ export class GuidedDiagnostics {
|
|
|
632
910
|
evidence: 'reporter-statement',
|
|
633
911
|
privacyClass: 'diagnostic',
|
|
634
912
|
contentType: 'application/json',
|
|
635
|
-
content: `${JSON.stringify({ version:
|
|
913
|
+
content: `${JSON.stringify({ version: 2, affectedDevices: countedAffectedDevices(session.affectedDevices) })}\n`,
|
|
636
914
|
/**
|
|
637
|
-
* What the reporter answered, not what the plugin observed. A serial
|
|
638
|
-
*
|
|
639
|
-
* the
|
|
915
|
+
* What the reporter answered, not what the plugin observed. A serial is a device identity and no
|
|
916
|
+
* evidence class carries one, so the answer travels as its scope: every device, or how many were
|
|
917
|
+
* named. Which ones is the reporter's to say in their own words.
|
|
640
918
|
*/
|
|
641
919
|
fields: [
|
|
642
920
|
{ field: 'version', privacyClass: 'operational' },
|
|
643
|
-
{ field: 'affectedDevices', privacyClass: '
|
|
921
|
+
{ field: 'affectedDevices', privacyClass: 'operational' },
|
|
922
|
+
],
|
|
923
|
+
},
|
|
924
|
+
]),
|
|
925
|
+
...(fleet === undefined
|
|
926
|
+
? []
|
|
927
|
+
: [
|
|
928
|
+
{
|
|
929
|
+
evidence: 'fleet',
|
|
930
|
+
privacyClass: 'operational',
|
|
931
|
+
contentType: 'application/json',
|
|
932
|
+
content: `${JSON.stringify({ version: 1, devices: fleet })}\n`,
|
|
933
|
+
/**
|
|
934
|
+
* What each device is and becomes, against the alias every other record about it carries. A model
|
|
935
|
+
* and an admission outcome are product facts rather than device identity; the alias is what makes
|
|
936
|
+
* an entry resolvable to a device, and only by the owner whose console paired it with a name.
|
|
937
|
+
*/
|
|
938
|
+
fields: [
|
|
939
|
+
{ field: 'version', privacyClass: 'operational' },
|
|
940
|
+
{ field: 'devices', privacyClass: 'pseudonymous' },
|
|
644
941
|
],
|
|
645
942
|
},
|
|
646
943
|
]),
|
|
@@ -648,18 +945,22 @@ export class GuidedDiagnostics {
|
|
|
648
945
|
evidence: 'environment',
|
|
649
946
|
privacyClass: 'operational',
|
|
650
947
|
contentType: 'application/json',
|
|
651
|
-
content: `${JSON.stringify({ version:
|
|
948
|
+
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
949
|
/**
|
|
653
950
|
* The record is operational and one field is classified above it: a resolved FFmpeg path is an
|
|
654
951
|
* environment fact, but it can carry the home directory of the account Homebridge runs as, so it is
|
|
655
952
|
* declared as diagnostic rather than presented alongside the host's own architecture.
|
|
953
|
+
*
|
|
954
|
+
* The Homebridge version is operational and load-bearing: this plugin requires Homebridge 2, and a
|
|
955
|
+
* host that predates it loads the plugin and then fails in ways a media fault cannot be told from.
|
|
656
956
|
*/
|
|
657
957
|
fields: [
|
|
658
958
|
...['version', 'plugin', 'sdk', 'node', 'platform', 'arch'].map((field) => ({
|
|
659
959
|
field,
|
|
660
960
|
privacyClass: 'operational',
|
|
661
961
|
})),
|
|
662
|
-
...(
|
|
962
|
+
...(host?.homebridge ? [{ field: 'homebridge', privacyClass: 'operational' }] : []),
|
|
963
|
+
...(host?.ffmpeg ? [{ field: 'ffmpeg', privacyClass: 'diagnostic' }] : []),
|
|
663
964
|
],
|
|
664
965
|
},
|
|
665
966
|
];
|
|
@@ -710,7 +1011,11 @@ export class GuidedDiagnostics {
|
|
|
710
1011
|
...(carried.retainedFrom === undefined ? {} : { retainedFrom: carried.retainedFrom }),
|
|
711
1012
|
fields: [...new Set(carried.records.flatMap((record) => Object.keys(record)))].sort().map((field) => ({
|
|
712
1013
|
field,
|
|
713
|
-
privacyClass: field
|
|
1014
|
+
privacyClass: PSEUDONYMOUS_FIELDS.has(field)
|
|
1015
|
+
? 'pseudonymous'
|
|
1016
|
+
: field === 'timestamp'
|
|
1017
|
+
? 'operational'
|
|
1018
|
+
: 'diagnostic',
|
|
714
1019
|
})),
|
|
715
1020
|
});
|
|
716
1021
|
}
|
|
@@ -873,16 +1178,17 @@ export class GuidedDiagnostics {
|
|
|
873
1178
|
throw new Error('Diagnostics authorization is inactive or expired');
|
|
874
1179
|
return session;
|
|
875
1180
|
}
|
|
876
|
-
async project(
|
|
877
|
-
if (!
|
|
1181
|
+
async project(persisted) {
|
|
1182
|
+
if (!persisted) {
|
|
878
1183
|
return { status: 'inactive', selectedEvidence: [], missingEvidence: [], partialExportAvailable: false };
|
|
879
1184
|
}
|
|
1185
|
+
const { session, exportable } = settledDiagnosticsSession(persisted, this.now());
|
|
880
1186
|
const selectedEvidence = DIAGNOSTICS_PROFILES[session.profile];
|
|
881
1187
|
const collected = session.reproductionStartedAt && session.reproductionEndedAt
|
|
882
1188
|
? await this.collectSupportEvidence(session)
|
|
883
1189
|
: undefined;
|
|
884
1190
|
const missingEvidence = collected ? missingEvidenceClasses(selectedEvidence, collected) : [];
|
|
885
|
-
const expired = Date.parse(session.expiresAt) <= this.now();
|
|
1191
|
+
const expired = Date.parse(session.expiresAt) <= this.now() && !exportable;
|
|
886
1192
|
const status = expired
|
|
887
1193
|
? 'expired'
|
|
888
1194
|
: session.reproductionEndedAt
|
|
@@ -901,9 +1207,9 @@ export class GuidedDiagnostics {
|
|
|
901
1207
|
expiresAt: session.expiresAt,
|
|
902
1208
|
...(session.reproductionStartedAt ? { reproductionStartedAt: session.reproductionStartedAt } : {}),
|
|
903
1209
|
...(session.reproductionEndedAt ? { reproductionEndedAt: session.reproductionEndedAt } : {}),
|
|
904
|
-
partialExportAvailable:
|
|
1210
|
+
partialExportAvailable: exportable,
|
|
905
1211
|
...(session.affectedDevices === undefined ? {} : { affectedDevices: session.affectedDevices }),
|
|
906
|
-
issueUrl: bugReportUrl(session, missingEvidence),
|
|
1212
|
+
issueUrl: bugReportUrl(session, missingEvidence, readHostEnvironment(this.storageRoot)),
|
|
907
1213
|
};
|
|
908
1214
|
}
|
|
909
1215
|
/**
|
|
@@ -944,8 +1250,8 @@ export class GuidedDiagnostics {
|
|
|
944
1250
|
await this.replaceOwnedFile(join(directory, REPRODUCTION_MARKERS_FILE), `${retained}${marker}`);
|
|
945
1251
|
}
|
|
946
1252
|
}
|
|
947
|
-
function formatMessage(
|
|
948
|
-
const template =
|
|
1253
|
+
function formatMessage(key, parameters = {}) {
|
|
1254
|
+
const template = EN_MESSAGES[key] ?? key;
|
|
949
1255
|
return template
|
|
950
1256
|
.replace(/\{([a-zA-Z0-9]+)\}/g, (placeholder, name) => parameters[name] === undefined ? placeholder : String(parameters[name]))
|
|
951
1257
|
.replace(/\s+/g, ' ')
|
|
@@ -953,7 +1259,7 @@ function formatMessage(catalog, key, parameters = {}) {
|
|
|
953
1259
|
}
|
|
954
1260
|
function localize(target, key, parameters = {}) {
|
|
955
1261
|
const translator = target.localize;
|
|
956
|
-
return translator?.(key, parameters) ?? formatMessage(
|
|
1262
|
+
return translator?.(key, parameters) ?? formatMessage(key, parameters);
|
|
957
1263
|
}
|
|
958
1264
|
class JsonLineLog {
|
|
959
1265
|
onError;
|
|
@@ -970,11 +1276,8 @@ class JsonLineLog {
|
|
|
970
1276
|
this.directory = join(storageRoot, LOG_DIRECTORY);
|
|
971
1277
|
this.path = join(this.directory, LOG_FILE);
|
|
972
1278
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
if (!payload) {
|
|
976
|
-
return;
|
|
977
|
-
}
|
|
1279
|
+
/** Queues one record the sanitizer already rebuilt. */
|
|
1280
|
+
write(payload) {
|
|
978
1281
|
const record = this.serialize(payload);
|
|
979
1282
|
const bytes = Buffer.byteLength(record);
|
|
980
1283
|
if (bytes > MAX_LOG_RECORD_BYTES || this.queuedBytes + bytes > MAX_QUEUED_LOG_BYTES) {
|
|
@@ -982,21 +1285,12 @@ class JsonLineLog {
|
|
|
982
1285
|
this.droppedRecords += 1;
|
|
983
1286
|
return;
|
|
984
1287
|
}
|
|
985
|
-
const
|
|
986
|
-
const droppedAt = this.droppedAt;
|
|
987
|
-
this.droppedRecords = 0;
|
|
988
|
-
this.droppedAt = undefined;
|
|
1288
|
+
const dropped = this.drainDropped();
|
|
989
1289
|
this.queuedBytes += bytes;
|
|
990
1290
|
this.pending = this.pending
|
|
991
1291
|
.then(async () => {
|
|
992
|
-
if (
|
|
993
|
-
await this.append(
|
|
994
|
-
scope: 'diagnostics',
|
|
995
|
-
level: 'warn',
|
|
996
|
-
event: 'records-dropped',
|
|
997
|
-
droppedRecords,
|
|
998
|
-
}, droppedAt));
|
|
999
|
-
}
|
|
1292
|
+
if (dropped)
|
|
1293
|
+
await this.append(dropped);
|
|
1000
1294
|
await this.append(record);
|
|
1001
1295
|
})
|
|
1002
1296
|
.catch(() => this.onError())
|
|
@@ -1005,22 +1299,21 @@ class JsonLineLog {
|
|
|
1005
1299
|
});
|
|
1006
1300
|
}
|
|
1007
1301
|
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());
|
|
1302
|
+
const dropped = this.drainDropped();
|
|
1303
|
+
if (dropped) {
|
|
1304
|
+
this.pending = this.pending.then(() => this.append(dropped)).catch(() => this.onError());
|
|
1021
1305
|
}
|
|
1022
1306
|
return this.pending;
|
|
1023
1307
|
}
|
|
1308
|
+
/** The `records-dropped` record for what was refused since the last one, resetting the tally, or nothing. */
|
|
1309
|
+
drainDropped() {
|
|
1310
|
+
const { droppedRecords, droppedAt } = this;
|
|
1311
|
+
this.droppedRecords = 0;
|
|
1312
|
+
this.droppedAt = undefined;
|
|
1313
|
+
return droppedRecords === 0
|
|
1314
|
+
? undefined
|
|
1315
|
+
: this.serialize({ scope: 'diagnostics', level: 'warn', event: 'records-dropped', droppedRecords }, droppedAt);
|
|
1316
|
+
}
|
|
1024
1317
|
serialize(payload, timestamp = new Date(this.now()).toISOString()) {
|
|
1025
1318
|
return `${JSON.stringify({ ...payload, timestamp })}\n`;
|
|
1026
1319
|
}
|
|
@@ -1079,12 +1372,12 @@ class JsonLineLog {
|
|
|
1079
1372
|
* Routes human messages to Homebridge and structured events to the plugin JSONL log.
|
|
1080
1373
|
* File writes preserve accepted order; records beyond the bounded pending queue are counted and reported.
|
|
1081
1374
|
*/
|
|
1082
|
-
export function createDiagnosticLogger(target, storageRoot,
|
|
1375
|
+
export function createDiagnosticLogger(target, storageRoot, now = Date.now) {
|
|
1083
1376
|
let fileFailureReported = false;
|
|
1084
1377
|
const reportFileFailure = () => {
|
|
1085
1378
|
if (!fileFailureReported) {
|
|
1086
1379
|
fileFailureReported = true;
|
|
1087
|
-
target.warn(`[diagnostic-log-write-failed] ${formatMessage(
|
|
1380
|
+
target.warn(`[diagnostic-log-write-failed] ${formatMessage('log.diagnosticFileFailed')}`);
|
|
1088
1381
|
}
|
|
1089
1382
|
};
|
|
1090
1383
|
const file = storageRoot ? new JsonLineLog(storageRoot, reportFileFailure, now) : undefined;
|
|
@@ -1098,7 +1391,7 @@ export function createDiagnosticLogger(target, storageRoot, catalog = EN_MESSAGE
|
|
|
1098
1391
|
if (verbose && (!session || !DIAGNOSTICS_PROFILES[session.profile].includes(evidenceClassOf(event)))) {
|
|
1099
1392
|
return;
|
|
1100
1393
|
}
|
|
1101
|
-
file.write(
|
|
1394
|
+
file.write(event);
|
|
1102
1395
|
}
|
|
1103
1396
|
: undefined;
|
|
1104
1397
|
return {
|
|
@@ -1106,13 +1399,15 @@ export function createDiagnosticLogger(target, storageRoot, catalog = EN_MESSAGE
|
|
|
1106
1399
|
error: (message) => target.error(message),
|
|
1107
1400
|
flush: () => file?.flush() ?? Promise.resolve(),
|
|
1108
1401
|
info: (message) => target.info(message),
|
|
1109
|
-
localize: (key, parameters) => formatMessage(
|
|
1402
|
+
localize: (key, parameters) => formatMessage(key, parameters),
|
|
1110
1403
|
warn: (message) => target.warn(message),
|
|
1111
1404
|
};
|
|
1112
1405
|
}
|
|
1113
1406
|
/** The class an SDK message belongs to, or nothing when its text matches none of them. */
|
|
1114
1407
|
function classifySdkEvent(message) {
|
|
1115
1408
|
const normalized = message.toLowerCase();
|
|
1409
|
+
if (normalized.includes('rtc sign for') && normalized.includes('refused'))
|
|
1410
|
+
return 'rtc-sign-refused';
|
|
1116
1411
|
if (normalized.includes('restored persisted session'))
|
|
1117
1412
|
return 'session-restored';
|
|
1118
1413
|
if (normalized.includes('retrying') || normalized.includes('reconnecting'))
|
|
@@ -1191,6 +1486,14 @@ function allowlistedLabel(value, allowed) {
|
|
|
1191
1486
|
* like any other field that fails its shape.
|
|
1192
1487
|
*/
|
|
1193
1488
|
const MAX_STARTUP_WINDOW_MS = 60_000;
|
|
1489
|
+
/** Why a level-2 wait ended without a key, which is the whole of what such an ending states. */
|
|
1490
|
+
const LEVEL2_UNAVAILABLE_REASONS = [
|
|
1491
|
+
'no-cipher-key',
|
|
1492
|
+
'derivation-failed',
|
|
1493
|
+
'not-negotiating',
|
|
1494
|
+
'session-closed',
|
|
1495
|
+
'grace-elapsed',
|
|
1496
|
+
];
|
|
1194
1497
|
/** The three phases whose only field is the action they report, which the union fixes at `start`. */
|
|
1195
1498
|
const retainedStart = (candidate) => candidate.action === 'start' ? { action: 'start' } : undefined;
|
|
1196
1499
|
/** A phase whose only field is one bounded integer, retained under the name the SDK declares for it. */
|
|
@@ -1236,7 +1539,74 @@ const LIVE_TRACE_PHASES = {
|
|
|
1236
1539
|
'sequence-restart': retainedInteger('dataType', 3),
|
|
1237
1540
|
'level2-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
|
|
1238
1541
|
'level2-ready': retainedInteger('cipherId', 65535),
|
|
1239
|
-
'level2-
|
|
1542
|
+
'level2-set-payload-only': retainedInteger('cipherId', 65535),
|
|
1543
|
+
'session-connect-wait': retainedInteger('waitMs', MAX_STARTUP_WINDOW_MS),
|
|
1544
|
+
'session-connected': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
|
|
1545
|
+
'session-unreachable': retainedInteger('waitedMs', MAX_STARTUP_WINDOW_MS),
|
|
1546
|
+
/**
|
|
1547
|
+
* Why a level-2 wait ended without a key, and what it had to name.
|
|
1548
|
+
*
|
|
1549
|
+
* The reason is the record: a wait that ran out states how long it waited, and one answered without waiting
|
|
1550
|
+
* states nothing more, because the negotiation is one-shot per connection. Only a reason reached under a
|
|
1551
|
+
* negotiation has a cipher to name, so both durations are optional and each is retained where it is stated.
|
|
1552
|
+
*/
|
|
1553
|
+
'level2-unavailable': (c) => {
|
|
1554
|
+
const reason = allowlistedLabel(c.reason, LEVEL2_UNAVAILABLE_REASONS);
|
|
1555
|
+
if (!reason) {
|
|
1556
|
+
return undefined;
|
|
1557
|
+
}
|
|
1558
|
+
const cipherId = boundedInteger(c.cipherId, 65535);
|
|
1559
|
+
const waitedMs = boundedInteger(c.waitedMs, MAX_STARTUP_WINDOW_MS);
|
|
1560
|
+
return {
|
|
1561
|
+
reason,
|
|
1562
|
+
...(cipherId === undefined ? {} : { cipherId }),
|
|
1563
|
+
...(waitedMs === undefined ? {} : { waitedMs }),
|
|
1564
|
+
};
|
|
1565
|
+
},
|
|
1566
|
+
/** The cipher a station asked for, beside the one whose material answered it. */
|
|
1567
|
+
'cipher-fallback': (c) => {
|
|
1568
|
+
const cipherId = boundedInteger(c.cipherId, 65535);
|
|
1569
|
+
const answeredCipherId = boundedInteger(c.answeredCipherId, 65535);
|
|
1570
|
+
return cipherId === undefined || answeredCipherId === undefined ? undefined : { cipherId, answeredCipherId };
|
|
1571
|
+
},
|
|
1572
|
+
'level2-negotiating': retainedInteger('cipherId', 65535),
|
|
1573
|
+
/**
|
|
1574
|
+
* What a call resolved a station as, which decides what its failure means.
|
|
1575
|
+
*
|
|
1576
|
+
* An attached camera's media start has no unencrypted form, so the topology is what separates a key that
|
|
1577
|
+
* never arrived from a start that had no form to be sent in. The channel is an index on a base and the
|
|
1578
|
+
* administrator is a relation to the signed-in account: neither is an identity of its own.
|
|
1579
|
+
*/
|
|
1580
|
+
/**
|
|
1581
|
+
* Which lookup channels a connection could ask for a station on, before it asked.
|
|
1582
|
+
*
|
|
1583
|
+
* A connect that had one channel failed for that channel's reason alone, and a connect that had neither
|
|
1584
|
+
* could not have succeeded. Both are otherwise indistinguishable from a station that is switched off.
|
|
1585
|
+
* `cloudMissing` names what the cloud channel lacked when it was unavailable.
|
|
1586
|
+
*/
|
|
1587
|
+
'lookup-channels': (c) => {
|
|
1588
|
+
if (typeof c.local !== 'boolean' || typeof c.cloud !== 'boolean')
|
|
1589
|
+
return undefined;
|
|
1590
|
+
const cloudMissing = allowlistedLabel(c.cloudMissing, ['dsk-key', 'cloud-addresses']);
|
|
1591
|
+
return { local: c.local, cloud: c.cloud, ...(cloudMissing === undefined ? {} : { cloudMissing }) };
|
|
1592
|
+
},
|
|
1593
|
+
'station-resolved': (c) => {
|
|
1594
|
+
const topology = allowlistedLabel(c.topology, ['attached', 'own']);
|
|
1595
|
+
const stationAdmin = allowlistedLabel(c.stationAdmin, ['self', 'other', 'unstated']);
|
|
1596
|
+
const channel = boundedInteger(c.channel, MAX_STATION_CHANNEL);
|
|
1597
|
+
const stationModel = typeof c.stationModel === 'string' ? boundedText(c.stationModel, MAX_DEVICE_MODEL_LENGTH) : undefined;
|
|
1598
|
+
return topology && stationAdmin && channel !== undefined
|
|
1599
|
+
? { topology, channel, stationAdmin, ...(stationModel === undefined ? {} : { stationModel }) }
|
|
1600
|
+
: undefined;
|
|
1601
|
+
},
|
|
1602
|
+
/**
|
|
1603
|
+
* Why a device could not be addressed on its station: its record states no channel, or another attached
|
|
1604
|
+
* device states the same one. The call was refused before anything was sent.
|
|
1605
|
+
*/
|
|
1606
|
+
'station-channel-unresolved': (c) => {
|
|
1607
|
+
const issue = allowlistedLabel(c.issue, ['missing', 'shared']);
|
|
1608
|
+
return issue ? { issue } : undefined;
|
|
1609
|
+
},
|
|
1240
1610
|
/**
|
|
1241
1611
|
* How long a connection's path has answered nothing, which is the station stating the path is gone.
|
|
1242
1612
|
*
|
|
@@ -1244,20 +1614,51 @@ const LIVE_TRACE_PHASES = {
|
|
|
1244
1614
|
* silence longer than that belongs to a session nothing is waiting on.
|
|
1245
1615
|
*/
|
|
1246
1616
|
'path-stale': retainedInteger('silentMs', MAX_STARTUP_WINDOW_MS),
|
|
1617
|
+
/**
|
|
1618
|
+
* How long a camera's own channel delivered nothing, and whether the station was asked to serve it again.
|
|
1619
|
+
*
|
|
1620
|
+
* Both halves are the record: a station re-tasked to a sibling is repaired by the re-assert, and a pull
|
|
1621
|
+
* nothing is attached to declines it so the station is left with the camera someone is watching. A picture
|
|
1622
|
+
* that stopped advancing while no such record exists stopped for a reason the station's attention does not
|
|
1623
|
+
* reach.
|
|
1624
|
+
*/
|
|
1625
|
+
'channel-silent': (c) => {
|
|
1626
|
+
const silentMs = boundedInteger(c.silentMs, MAX_STARTUP_WINDOW_MS);
|
|
1627
|
+
const outcome = allowlistedLabel(c.outcome, ['declined', 'reasserted']);
|
|
1628
|
+
return silentMs !== undefined && outcome ? { silentMs, outcome } : undefined;
|
|
1629
|
+
},
|
|
1247
1630
|
'media-command-unsent': (c) => {
|
|
1248
1631
|
const reason = allowlistedLabel(c.reason, ['level2-key', 'address']);
|
|
1249
1632
|
return reason ? { reason } : undefined;
|
|
1250
1633
|
},
|
|
1634
|
+
/** `errorName` is an upstream class name, so it stays out of the archive; reason and stage say enough. */
|
|
1635
|
+
'start-failed': (c) => {
|
|
1636
|
+
const reason = allowlistedLabel(c.reason, ['warm-timeout', 'source-error', 'source-ended']);
|
|
1637
|
+
const stage = allowlistedLabel(c.stage, ['awaiting-first-frame', 'audio-only', 'awaiting-keyframe']);
|
|
1638
|
+
const attempts = boundedInteger(c.attempts, 1000);
|
|
1639
|
+
return reason && stage && attempts !== undefined ? { reason, stage, attempts } : undefined;
|
|
1640
|
+
},
|
|
1251
1641
|
warming: (c) => {
|
|
1252
1642
|
const retryMs = boundedInteger(c.retryMs, MAX_STARTUP_WINDOW_MS);
|
|
1253
1643
|
const deadlineMs = boundedInteger(c.deadlineMs, MAX_STARTUP_WINDOW_MS);
|
|
1254
1644
|
return retryMs === undefined || deadlineMs === undefined ? undefined : { retryMs, deadlineMs };
|
|
1255
1645
|
},
|
|
1256
1646
|
};
|
|
1257
|
-
|
|
1258
|
-
|
|
1647
|
+
/**
|
|
1648
|
+
* Narrows one push the SDK received to its wire event type, whether it knew the device, and how many events it
|
|
1649
|
+
* decoded: a push that decoded into none is the code a device sends that the library does not map yet.
|
|
1650
|
+
*/
|
|
1651
|
+
function sanitizeSdkPush(value) {
|
|
1652
|
+
const eventType = boundedInteger(value.eventType, 0xffff);
|
|
1653
|
+
const device = allowlistedLabel(value.device, ['known', 'unknown', 'none']);
|
|
1654
|
+
const events = boundedInteger(value.events, 64);
|
|
1655
|
+
return eventType === undefined || !device || events === undefined
|
|
1656
|
+
? undefined
|
|
1657
|
+
: { scope: 'sdk', level: 'debug', subsystem: 'push', event: 'push-received', eventType, device, events };
|
|
1658
|
+
}
|
|
1659
|
+
function sanitizeSdkLiveStartTrace(candidate) {
|
|
1660
|
+
if (!isRecord(candidate))
|
|
1259
1661
|
return undefined;
|
|
1260
|
-
const candidate = value;
|
|
1261
1662
|
const phase = String(candidate.phase);
|
|
1262
1663
|
if (!Object.hasOwn(LIVE_TRACE_PHASES, phase))
|
|
1263
1664
|
return undefined;
|
|
@@ -1277,6 +1678,51 @@ function sanitizeSdkLiveStartTrace(value) {
|
|
|
1277
1678
|
function opaquePullHandle(value) {
|
|
1278
1679
|
return typeof value === 'string' && /^(?:pull|station)-\d{1,6}(?::\d{1,3})?$/.test(value) ? value : undefined;
|
|
1279
1680
|
}
|
|
1681
|
+
/**
|
|
1682
|
+
* One support-case accessory alias, or nothing where the value is not one.
|
|
1683
|
+
*
|
|
1684
|
+
* Matched against the shape an alias has rather than against what a serial looks like, on the same rule as
|
|
1685
|
+
* {@link opaquePullHandle}: a record carries an alias or it carries no accessory at all.
|
|
1686
|
+
*/
|
|
1687
|
+
function opaqueAccessoryAlias(value) {
|
|
1688
|
+
return typeof value === 'string' &&
|
|
1689
|
+
/^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)
|
|
1690
|
+
? value
|
|
1691
|
+
: undefined;
|
|
1692
|
+
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Narrows one realtime startup outcome to its state, the push plane's start counts, and the mqtt and wired P2P
|
|
1695
|
+
* planes' counts nested under their names, or nothing where the state is unknown.
|
|
1696
|
+
*
|
|
1697
|
+
* Read by both halves of the path — {@link reportRealtimeReadiness} builds the record and the file sink rebuilds it.
|
|
1698
|
+
*/
|
|
1699
|
+
function sanitizeRealtimeReadiness(value) {
|
|
1700
|
+
const state = allowlistedLabel(value.state, ['ready', 'partial', 'disabled', 'superseded', 'timed-out']);
|
|
1701
|
+
if (!state)
|
|
1702
|
+
return undefined;
|
|
1703
|
+
const counts = (plane) => Object.fromEntries(['required', 'ready', 'failed', 'pending'].flatMap((key) => {
|
|
1704
|
+
const count = isRecord(plane) ? nonNegativeInteger(plane[key]) : undefined;
|
|
1705
|
+
return count === undefined ? [] : [[key, count]];
|
|
1706
|
+
}));
|
|
1707
|
+
const planes = Object.fromEntries(['mqtt', 'wiredP2p'].flatMap((plane) => {
|
|
1708
|
+
const planeCounts = counts(value[plane]);
|
|
1709
|
+
return Object.keys(planeCounts).length ? [[plane, planeCounts]] : [];
|
|
1710
|
+
}));
|
|
1711
|
+
return { event: 'realtime-readiness', state, ...counts(value), ...planes };
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Records how the SDK's realtime startup settled, with each plane's start counts, as `sdk-log` evidence.
|
|
1715
|
+
*
|
|
1716
|
+
* Carries the state and the counts alone, which name the plane — push, mqtt or wired P2P — that failed or never
|
|
1717
|
+
* settled behind a `partial` start.
|
|
1718
|
+
*/
|
|
1719
|
+
export function reportRealtimeReadiness(target, readiness) {
|
|
1720
|
+
const { state, push, mqtt, wiredP2p } = readiness;
|
|
1721
|
+
const record = sanitizeRealtimeReadiness({ ...push, state, mqtt, wiredP2p });
|
|
1722
|
+
if (target?.debug && record) {
|
|
1723
|
+
target.debug(JSON.stringify({ scope: 'sdk', level: 'debug', subsystem: 'push', ...record }));
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1280
1726
|
/**
|
|
1281
1727
|
* Adapts SDK protocol detail to bounded debug output without preserving supplied values.
|
|
1282
1728
|
*
|
|
@@ -1298,6 +1744,9 @@ export function createSdkLogger(target) {
|
|
|
1298
1744
|
if (trace)
|
|
1299
1745
|
return { scope: 'sdk', subsystem: 'p2p', event: 'live-start-trace', ...trace };
|
|
1300
1746
|
}
|
|
1747
|
+
const push = /^\[push\] in: eventType=(\d+) device=(\w+) events=(\d+)$/.exec(message);
|
|
1748
|
+
if (push)
|
|
1749
|
+
return sanitizeSdkPush({ eventType: Number(push[1]), device: push[2], events: Number(push[3]) });
|
|
1301
1750
|
const requestedSubsystem = /^\[([a-z0-9-]+)(?:\s+[^\]]+)?\]/i.exec(message)?.[1]?.toLowerCase();
|
|
1302
1751
|
if (requestedSubsystem === 'ffmpeg') {
|
|
1303
1752
|
const stderr = message.slice(message.indexOf(']') + 1).split(/\r?\n/);
|
|
@@ -1318,9 +1767,7 @@ export function createSdkLogger(target) {
|
|
|
1318
1767
|
const subsystem = SDK_SUBSYSTEMS.has(requestedSubsystem ?? '') ? requestedSubsystem : (aliasedSubsystem ?? 'sdk');
|
|
1319
1768
|
const details = args.slice(0, MAX_SDK_DETAILS).map((value) => {
|
|
1320
1769
|
if (value instanceof Error) {
|
|
1321
|
-
const errorType =
|
|
1322
|
-
? value.name
|
|
1323
|
-
: 'Error';
|
|
1770
|
+
const errorType = SDK_ERROR_TYPES.includes(value.name) ? value.name : 'Error';
|
|
1324
1771
|
return { errorType };
|
|
1325
1772
|
}
|
|
1326
1773
|
if (typeof value === 'string') {
|
|
@@ -1421,6 +1868,7 @@ const REASONS = new Set([
|
|
|
1421
1868
|
'live-failed',
|
|
1422
1869
|
'live-no-keyframe',
|
|
1423
1870
|
'live-source-failed',
|
|
1871
|
+
'live-station-unreachable',
|
|
1424
1872
|
'live-unavailable',
|
|
1425
1873
|
'live-undecodable-burst',
|
|
1426
1874
|
'malformed',
|
|
@@ -1444,6 +1892,9 @@ const REASONS = new Set([
|
|
|
1444
1892
|
'source-input-unstable',
|
|
1445
1893
|
'source-stopped',
|
|
1446
1894
|
'source-unavailable',
|
|
1895
|
+
'source-warm-timeout',
|
|
1896
|
+
'station-key-unavailable',
|
|
1897
|
+
'station-unreachable',
|
|
1447
1898
|
'stored-download-failed',
|
|
1448
1899
|
'stored-failed',
|
|
1449
1900
|
'stored-invalid-image',
|
|
@@ -1454,6 +1905,7 @@ const REASONS = new Set([
|
|
|
1454
1905
|
'unsupported',
|
|
1455
1906
|
'unsupported-selection',
|
|
1456
1907
|
'device-audio-failed',
|
|
1908
|
+
'no-controller-audio',
|
|
1457
1909
|
]);
|
|
1458
1910
|
/** Every runtime state, plus the withdrawal a recovered condition reports. */
|
|
1459
1911
|
const RUNTIME_CONDITION_REASONS = new Set([...RUNTIME_STATES, 'recovered']);
|
|
@@ -1462,6 +1914,7 @@ const HOMEKIT_EVENT_ROUTES = {
|
|
|
1462
1914
|
'battery.status': new Set(['battery-alert', 'battery-level']),
|
|
1463
1915
|
'contact.sensor': new Set(['contact-state']),
|
|
1464
1916
|
'doorbell.press': new Set(['doorbell-press']),
|
|
1917
|
+
'doorbell.package': new Set(['package-presence']),
|
|
1465
1918
|
'lock.mechanism': new Set(['lock-state']),
|
|
1466
1919
|
'motion.sensor': new Set(['motion-detection']),
|
|
1467
1920
|
'arming.security-system': new Set(['arming-mode-changed', 'security-system-alarm']),
|
|
@@ -1469,7 +1922,7 @@ const HOMEKIT_EVENT_ROUTES = {
|
|
|
1469
1922
|
'camera.streaming': new Set(['camera-enabled-changed']),
|
|
1470
1923
|
'camera.controls': new Set(['camera-enabled-changed']),
|
|
1471
1924
|
};
|
|
1472
|
-
const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid']);
|
|
1925
|
+
const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid', 'ignored']);
|
|
1473
1926
|
/**
|
|
1474
1927
|
* What announced a change, for an adapter that follows more than one announcement for the same state.
|
|
1475
1928
|
*
|
|
@@ -1477,9 +1930,9 @@ const HOMEKIT_OBSERVATIONS = new Set(['malformed', 'missing', 'valid']);
|
|
|
1477
1930
|
* field is omitted where an adapter states no announcement and refused where it states one absent from here.
|
|
1478
1931
|
*/
|
|
1479
1932
|
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 =
|
|
1933
|
+
const HOMEKIT_LIVE_VIDEO_OPERATIONS = ['start', 'reconfigure'];
|
|
1934
|
+
const HOMEKIT_LIVE_VIDEO_PROFILES = ['baseline', 'main', 'high'];
|
|
1935
|
+
const HOMEKIT_LIVE_VIDEO_LEVELS = ['3.1', '3.2', '4.0'];
|
|
1483
1936
|
/**
|
|
1484
1937
|
* The inclusive bounds a controller-selected live geometry and frame rate are retained within.
|
|
1485
1938
|
*
|
|
@@ -1500,30 +1953,86 @@ const MAX_LIVE_VIDEO_FRAME_RATE = 120;
|
|
|
1500
1953
|
*/
|
|
1501
1954
|
const MIN_LIVE_VIDEO_MTU = 0;
|
|
1502
1955
|
const MAX_LIVE_VIDEO_MTU = 65_535;
|
|
1956
|
+
/** The bit rate ceiling a controller negotiated, in kbps, bounded to the unsigned 16-bit field that carries it. */
|
|
1957
|
+
const MAX_LIVE_VIDEO_BIT_RATE = 65_535;
|
|
1503
1958
|
/** The two address families HomeKit negotiates a destination in. */
|
|
1504
|
-
const HOMEKIT_LIVE_ADDRESS_VERSIONS =
|
|
1505
|
-
const HOMEKIT_LIVE_SESSION_STAGES =
|
|
1959
|
+
const HOMEKIT_LIVE_ADDRESS_VERSIONS = ['ipv4', 'ipv6'];
|
|
1960
|
+
const HOMEKIT_LIVE_SESSION_STAGES = [
|
|
1506
1961
|
'sdk-source-acquisition',
|
|
1507
1962
|
'first-source-keyframe',
|
|
1508
1963
|
'first-adapted-output',
|
|
1509
1964
|
'controller-rtcp',
|
|
1510
|
-
]
|
|
1965
|
+
];
|
|
1966
|
+
/** The SDK's own stage and reason for a live warm-up that ended without a keyframe. */
|
|
1967
|
+
const SDK_LIVE_START_STAGES = ['awaiting-first-frame', 'audio-only', 'awaiting-keyframe'];
|
|
1968
|
+
const SDK_LIVE_START_REASONS = ['warm-timeout', 'source-error', 'source-ended'];
|
|
1511
1969
|
/**
|
|
1512
1970
|
* Which adaptation process an FFmpeg record came from.
|
|
1513
1971
|
*
|
|
1514
1972
|
* A superset of what the media domain can report, because the SDK runs FFmpeg of its own for snapshot
|
|
1515
1973
|
* decoding and WebRTC containers and forwards its output here under the same evidence class.
|
|
1516
1974
|
*/
|
|
1517
|
-
const ADAPTATION_ROLES = new Set([
|
|
1975
|
+
const ADAPTATION_ROLES = new Set([
|
|
1976
|
+
'live-video',
|
|
1977
|
+
'live-audio',
|
|
1978
|
+
'return-audio',
|
|
1979
|
+
'recording',
|
|
1980
|
+
'preview-encode',
|
|
1981
|
+
'preview-decode',
|
|
1982
|
+
'preview-raw',
|
|
1983
|
+
'sdk',
|
|
1984
|
+
]);
|
|
1518
1985
|
/** What that process did. `output` is a process reported for what it wrote rather than for how it ended. */
|
|
1519
1986
|
const ADAPTATION_EVENTS = new Set([
|
|
1520
1987
|
'started',
|
|
1988
|
+
'encoder-disqualified',
|
|
1521
1989
|
'spawn-failed',
|
|
1522
1990
|
'input-failed',
|
|
1523
1991
|
'exited-before-output',
|
|
1524
1992
|
'exited-while-streaming',
|
|
1525
1993
|
'output',
|
|
1526
1994
|
]);
|
|
1995
|
+
/** How a settings-page live preview ended. */
|
|
1996
|
+
const PREVIEW_OUTCOMES = ['picture', 'unplayable', 'no-video', 'stopped'];
|
|
1997
|
+
/** The iPhone asks a live preview codes for, and the live video settings it may try. */
|
|
1998
|
+
const PREVIEW_ASKS = ['wifi', 'cellular'];
|
|
1999
|
+
const PREVIEW_SETTINGS = [
|
|
2000
|
+
'smallVideoPackets',
|
|
2001
|
+
'liveVideoPassthrough',
|
|
2002
|
+
'hardwareVideoEncoder',
|
|
2003
|
+
'ignoreHomeBitRate',
|
|
2004
|
+
];
|
|
2005
|
+
/**
|
|
2006
|
+
* Why a process was started, on the record that reports it starting.
|
|
2007
|
+
*
|
|
2008
|
+
* A replacement carries the identity of the stream the previous process was already sending on, so what it
|
|
2009
|
+
* started mid-flight is judged against what that one left. A first adaptation has nothing behind it.
|
|
2010
|
+
*/
|
|
2011
|
+
const ADAPTATION_CAUSES = ['first', 'controller-selection', 'source-configuration'];
|
|
2012
|
+
/** The encoders a live video process may code with, `copy` being none. */
|
|
2013
|
+
const LIVE_VIDEO_ENCODERS = [
|
|
2014
|
+
'copy',
|
|
2015
|
+
'libx264',
|
|
2016
|
+
'h264_videotoolbox',
|
|
2017
|
+
'h264_qsv',
|
|
2018
|
+
'h264_nvenc',
|
|
2019
|
+
'h264_amf',
|
|
2020
|
+
'h264_vaapi',
|
|
2021
|
+
'h264_v4l2m2m',
|
|
2022
|
+
];
|
|
2023
|
+
/** What qualifying a hardware encoder for one selection found, but `bytes`, which is read with its two integers. */
|
|
2024
|
+
const QUALIFICATION_OUTCOMES = [
|
|
2025
|
+
'qualified',
|
|
2026
|
+
'budget',
|
|
2027
|
+
'encode',
|
|
2028
|
+
'trace',
|
|
2029
|
+
'profile',
|
|
2030
|
+
'level',
|
|
2031
|
+
'geometry',
|
|
2032
|
+
'frames',
|
|
2033
|
+
];
|
|
2034
|
+
/** The codec a live video source delivers. */
|
|
2035
|
+
const LIVE_VIDEO_SOURCES = ['h264', 'h265', 'av1'];
|
|
1527
2036
|
/**
|
|
1528
2037
|
* Which of those events is a failure, and so the ones a record is levelled `warn` for.
|
|
1529
2038
|
*
|
|
@@ -1533,6 +2042,7 @@ const ADAPTATION_EVENTS = new Set([
|
|
|
1533
2042
|
* log for every session that succeeded.
|
|
1534
2043
|
*/
|
|
1535
2044
|
const ADAPTATION_FAILURE_EVENTS = new Set([
|
|
2045
|
+
'encoder-disqualified',
|
|
1536
2046
|
'spawn-failed',
|
|
1537
2047
|
'input-failed',
|
|
1538
2048
|
'exited-before-output',
|
|
@@ -1564,74 +2074,32 @@ const ADAPTATION_SIGNALS = new Set([
|
|
|
1564
2074
|
const MAX_ADAPTATION_STDERR_LINES = 8;
|
|
1565
2075
|
const MAX_ADAPTATION_STDERR_LINE_LENGTH = 200;
|
|
1566
2076
|
/**
|
|
1567
|
-
* The
|
|
2077
|
+
* The fixed-shape operational notices, each `[level, messageKey, durationMessageKey?]`.
|
|
1568
2078
|
*
|
|
1569
2079
|
* Operational rather than verbose, so each is `plugin-log` evidence that every profile declares.
|
|
1570
2080
|
*/
|
|
1571
|
-
const MEDIA_NOTICES = {
|
|
1572
|
-
'camera-snapshot-cache-invalid': {
|
|
1573
|
-
level: 'warn',
|
|
1574
|
-
messageKey: 'log.snapshotCacheInvalid',
|
|
1575
|
-
},
|
|
1576
|
-
};
|
|
1577
2081
|
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
|
-
},
|
|
2082
|
+
'status-publication-failed': ['warn', 'log.notice.statusPublicationFailed'],
|
|
2083
|
+
'ownership-release-not-finalized': ['error', 'log.notice.ownershipReleaseNotFinalized'],
|
|
2084
|
+
'ownership-release-failed': ['error', 'log.notice.ownershipReleaseFailed'],
|
|
2085
|
+
'shutdown-failed': ['error', 'log.notice.shutdownFailed'],
|
|
2086
|
+
'shutdown-timeout': ['warn', 'log.notice.shutdownTimeout', 'log.notice.shutdownTimeoutWithDuration'],
|
|
2087
|
+
'ownership-acquisition-failed': ['error', 'log.notice.ownershipAcquisitionFailed'],
|
|
2088
|
+
'ownership-release-incomplete': ['warn', 'log.notice.ownershipReleaseIncomplete'],
|
|
2089
|
+
'registry-subscriber-failed': ['warn', 'log.notice.registrySubscriberFailed'],
|
|
2090
|
+
'event-subscriber-failed': ['warn', 'log.notice.eventSubscriberFailed'],
|
|
2091
|
+
'state-subscriber-failed': ['warn', 'log.notice.stateSubscriberFailed'],
|
|
2092
|
+
'channel-serve-failed': ['warn', 'log.notice.channelServeFailed'],
|
|
2093
|
+
'diagnostics-authorization-armed': ['info', 'log.notice.diagnosticsAuthorizationArmed'],
|
|
2094
|
+
'stand-down-not-rearmed': ['warn', 'log.notice.standDownNotRearmed'],
|
|
2095
|
+
'stand-down-account-replaced': ['warn', 'log.notice.standDownAccountReplaced'],
|
|
2096
|
+
'camera-snapshot-cache-invalid': ['warn', 'log.snapshotCacheInvalid'],
|
|
2097
|
+
'discarded-v4-settings-unacknowledged': ['warn', 'log.discardedSettings'],
|
|
2098
|
+
};
|
|
2099
|
+
/** The scope a notice is recorded under where it is not `runtime-notice`. */
|
|
2100
|
+
const NOTICE_SCOPES = {
|
|
2101
|
+
'camera-snapshot-cache-invalid': 'media-notice',
|
|
2102
|
+
'discarded-v4-settings-unacknowledged': 'configuration-notice',
|
|
1635
2103
|
};
|
|
1636
2104
|
const RUNTIME_CONDITIONS = {
|
|
1637
2105
|
degraded: {
|
|
@@ -1660,7 +2128,7 @@ const RUNTIME_CONDITIONS = {
|
|
|
1660
2128
|
},
|
|
1661
2129
|
};
|
|
1662
2130
|
const HOMEKIT_CONDITIONS = {
|
|
1663
|
-
'recognized-device-not-represented': ['log.homekit.recognizedNotRepresented', 'log.action.
|
|
2131
|
+
'recognized-device-not-represented': ['log.homekit.recognizedNotRepresented', 'log.action.notSupportedYet'],
|
|
1664
2132
|
'battery-capability-unavailable': ['log.homekit.batteryCapabilityUnavailable', 'log.action.waitBattery'],
|
|
1665
2133
|
'invalid-battery-observation': ['log.homekit.invalidBatteryObservation', 'log.action.waitBatteryObservation'],
|
|
1666
2134
|
'battery-temperature-alert': ['log.homekit.batteryTemperatureAlert', 'log.action.allowBatteryCooling'],
|
|
@@ -1673,7 +2141,7 @@ const HOMEKIT_CONDITIONS = {
|
|
|
1673
2141
|
'smart-light-operation-failed': ['log.homekit.lightOperationFailed', 'log.action.retryLight'],
|
|
1674
2142
|
'smart-light-reconciliation-expired': ['log.homekit.lightReconciliationExpired', 'log.action.checkPhysicalLight'],
|
|
1675
2143
|
'arming-capability-unavailable': ['log.homekit.armingCapabilityUnavailable', 'log.action.waitArming'],
|
|
1676
|
-
'unsupported-arming-mode': ['log.homekit.unsupportedArmingMode', 'log.action.
|
|
2144
|
+
'unsupported-arming-mode': ['log.homekit.unsupportedArmingMode', 'log.action.mapArmingMode'],
|
|
1677
2145
|
'arming-operation-failed': ['log.homekit.armingOperationFailed', 'log.action.retryArming'],
|
|
1678
2146
|
'arming-reconciliation-expired': ['log.homekit.armingReconciliationExpired', 'log.action.checkPhysicalArmingMode'],
|
|
1679
2147
|
'lock-capability-unavailable': ['log.homekit.lockCapabilityUnavailable', 'log.action.waitLock'],
|
|
@@ -1707,17 +2175,75 @@ const HOMEKIT_CONDITIONS = {
|
|
|
1707
2175
|
'camera-talkback-failed': ['log.homekit.cameraTalkbackFailed', 'log.action.retryTalkback'],
|
|
1708
2176
|
'camera-talkback-capability-unavailable': ['log.homekit.cameraTalkbackUnavailable', 'log.action.waitTalkback'],
|
|
1709
2177
|
};
|
|
2178
|
+
/**
|
|
2179
|
+
* The action a HomeKit condition names for a reason whose remedy is not the condition's own, keyed `code:reason`.
|
|
2180
|
+
*
|
|
2181
|
+
* A camera adapter is missing only where no FFmpeg path resolved, an adaptation fails to spawn only where the path
|
|
2182
|
+
* cannot be run, and a live adaptation that exits before any output is the FFmpeg build refusing this host, so all
|
|
2183
|
+
* three are fixed in the plugin's settings rather than waited out. A control that timed out
|
|
2184
|
+
* will likely time out again, so it points at the Eufy app instead of a retry. A station whose session did not
|
|
2185
|
+
* connect is fixed at that station, not at the camera a viewer opened.
|
|
2186
|
+
*/
|
|
2187
|
+
const HOMEKIT_REASON_ACTIONS = {
|
|
2188
|
+
'camera-streaming-capability-unavailable:adapter-missing': 'log.action.setFfmpegPath',
|
|
2189
|
+
'camera-recording-unavailable:adapter-missing': 'log.action.setFfmpegPath',
|
|
2190
|
+
'camera-live-session-failed:adaptation-spawn-failed': 'log.action.checkFfmpegPath',
|
|
2191
|
+
'camera-live-session-failed:adaptation-exited-before-output': 'log.action.setFfmpegPath',
|
|
2192
|
+
'lock-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2193
|
+
'arming-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2194
|
+
'smart-light-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2195
|
+
'camera-control-operation-failed:timeout': 'log.action.controlTimedOut',
|
|
2196
|
+
'camera-live-session-failed:station-unreachable': 'log.action.checkStation',
|
|
2197
|
+
'camera-live-session-failed:source-warm-timeout': 'log.action.retryLiveViewNoPicture',
|
|
2198
|
+
'camera-snapshot-unavailable:live-station-unreachable': 'log.action.checkStation',
|
|
2199
|
+
'camera-talkback-failed:no-controller-audio': 'log.action.checkTalkbackMicrophone',
|
|
2200
|
+
};
|
|
2201
|
+
/** Projects a value onto an active condition, or nothing where the catalog does not write that code with those keys. */
|
|
2202
|
+
export function knownCondition(value) {
|
|
2203
|
+
if (!isRecord(value))
|
|
2204
|
+
return undefined;
|
|
2205
|
+
const { code, summaryKey, actionKey, serials } = value;
|
|
2206
|
+
if (typeof code !== 'string')
|
|
2207
|
+
return undefined;
|
|
2208
|
+
const runtime = Object.values(RUNTIME_CONDITIONS).find((condition) => condition.code === code);
|
|
2209
|
+
const homeKit = Object.hasOwn(HOMEKIT_CONDITIONS, code)
|
|
2210
|
+
? HOMEKIT_CONDITIONS[code]
|
|
2211
|
+
: undefined;
|
|
2212
|
+
const [summary, ...actions] = runtime
|
|
2213
|
+
? [runtime.summaryKey, runtime.actionKey]
|
|
2214
|
+
: homeKit
|
|
2215
|
+
? [
|
|
2216
|
+
...homeKit,
|
|
2217
|
+
...Object.entries(HOMEKIT_REASON_ACTIONS)
|
|
2218
|
+
.filter(([key]) => key.startsWith(`${code}:`))
|
|
2219
|
+
.map(([, action]) => action),
|
|
2220
|
+
]
|
|
2221
|
+
: [];
|
|
2222
|
+
const action = allowlistedLabel(actionKey, actions);
|
|
2223
|
+
if (summary === undefined || summaryKey !== summary || action === undefined)
|
|
2224
|
+
return undefined;
|
|
2225
|
+
const named = Array.isArray(serials) ? serials.filter((serial) => typeof serial === 'string') : [];
|
|
2226
|
+
return { code, summaryKey: summary, actionKey: action, ...(named.length === 0 ? {} : { serials: named }) };
|
|
2227
|
+
}
|
|
2228
|
+
/** HomeKit conditions that state a gap nothing in the user's setup closes, so an active one is not a warning. */
|
|
2229
|
+
const INFORMATIONAL_HOMEKIT_CONDITIONS = new Set(['recognized-device-not-represented']);
|
|
2230
|
+
/** The level and action one HomeKit condition is written with, decided once for the console and the record. */
|
|
2231
|
+
function homeKitConditionPresentation(code, active, reason) {
|
|
2232
|
+
return {
|
|
2233
|
+
level: active && !INFORMATIONAL_HOMEKIT_CONDITIONS.has(code) ? 'warn' : 'info',
|
|
2234
|
+
actionKey: HOMEKIT_REASON_ACTIONS[`${code}:${reason}`] ?? HOMEKIT_CONDITIONS[code][1],
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
1710
2237
|
function sanitizeStructuredEvent(message) {
|
|
1711
2238
|
let value;
|
|
1712
2239
|
try {
|
|
1713
|
-
|
|
1714
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
1715
|
-
return undefined;
|
|
1716
|
-
value = parsed;
|
|
2240
|
+
value = JSON.parse(message);
|
|
1717
2241
|
}
|
|
1718
2242
|
catch {
|
|
1719
2243
|
return undefined;
|
|
1720
2244
|
}
|
|
2245
|
+
if (!isRecord(value))
|
|
2246
|
+
return undefined;
|
|
1721
2247
|
const level = allowlistedLabel(value.level, ['debug', 'info', 'warn', 'error']);
|
|
1722
2248
|
if (!level || typeof value.scope !== 'string')
|
|
1723
2249
|
return undefined;
|
|
@@ -1728,6 +2254,13 @@ function sanitizeStructuredEvent(message) {
|
|
|
1728
2254
|
!SDK_EVENT_KEYS.has(value.event)) {
|
|
1729
2255
|
return undefined;
|
|
1730
2256
|
}
|
|
2257
|
+
if (value.event === 'realtime-readiness') {
|
|
2258
|
+
const readiness = sanitizeRealtimeReadiness(value);
|
|
2259
|
+
return readiness ? { scope: 'sdk', level: 'debug', subsystem: 'push', ...readiness } : undefined;
|
|
2260
|
+
}
|
|
2261
|
+
if (value.event === 'push-received') {
|
|
2262
|
+
return level === 'debug' ? sanitizeSdkPush(value) : undefined;
|
|
2263
|
+
}
|
|
1731
2264
|
if (value.event === 'live-start-trace') {
|
|
1732
2265
|
if (level !== 'debug')
|
|
1733
2266
|
return undefined;
|
|
@@ -1736,23 +2269,17 @@ function sanitizeStructuredEvent(message) {
|
|
|
1736
2269
|
}
|
|
1737
2270
|
const details = [];
|
|
1738
2271
|
for (const detail of Array.isArray(value.details) ? value.details.slice(0, MAX_SDK_DETAILS) : []) {
|
|
1739
|
-
if (!
|
|
2272
|
+
if (!isRecord(detail))
|
|
1740
2273
|
continue;
|
|
1741
|
-
const
|
|
1742
|
-
const errorType = allowlistedLabel(candidate.errorType, [
|
|
1743
|
-
'Error',
|
|
1744
|
-
'RangeError',
|
|
1745
|
-
'SessionExpiredError',
|
|
1746
|
-
'TypeError',
|
|
1747
|
-
]);
|
|
2274
|
+
const errorType = allowlistedLabel(detail.errorType, SDK_ERROR_TYPES);
|
|
1748
2275
|
if (errorType) {
|
|
1749
2276
|
details.push({ errorType });
|
|
1750
2277
|
continue;
|
|
1751
2278
|
}
|
|
1752
|
-
const type = allowlistedLabel(
|
|
2279
|
+
const type = allowlistedLabel(detail.type, ['boolean', 'number', 'object', 'string', 'undefined']);
|
|
1753
2280
|
if (!type)
|
|
1754
2281
|
continue;
|
|
1755
|
-
const length = nonNegativeInteger(
|
|
2282
|
+
const length = nonNegativeInteger(detail.length);
|
|
1756
2283
|
details.push({ type, ...(length === undefined ? {} : { length }) });
|
|
1757
2284
|
}
|
|
1758
2285
|
return {
|
|
@@ -1764,62 +2291,14 @@ function sanitizeStructuredEvent(message) {
|
|
|
1764
2291
|
...(value.detailsTruncated === true ? { detailsTruncated: true } : {}),
|
|
1765
2292
|
};
|
|
1766
2293
|
}
|
|
1767
|
-
if (value.scope === 'runtime-notice') {
|
|
2294
|
+
if (value.scope === 'runtime-notice' || value.scope === 'media-notice' || value.scope === 'configuration-notice') {
|
|
1768
2295
|
if (typeof value.code !== 'string' || !Object.hasOwn(RUNTIME_NOTICES, value.code))
|
|
1769
2296
|
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 };
|
|
2297
|
+
const notice = runtimeNoticeRecord(value.code, nonNegativeInteger(value.durationMs));
|
|
2298
|
+
return notice.scope === value.scope ? notice : undefined;
|
|
1796
2299
|
}
|
|
1797
2300
|
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
|
-
};
|
|
2301
|
+
return sanitizeHomeKitEvent(value);
|
|
1823
2302
|
}
|
|
1824
2303
|
if (value.scope === 'runtime') {
|
|
1825
2304
|
const event = allowlistedLabel(value.event, ['ready', 'stopped']);
|
|
@@ -1830,6 +2309,10 @@ function sanitizeStructuredEvent(message) {
|
|
|
1830
2309
|
if (value.scope === 'ffmpeg') {
|
|
1831
2310
|
return sanitizeAdaptationNotice(value, level);
|
|
1832
2311
|
}
|
|
2312
|
+
if (value.scope === 'live-preview') {
|
|
2313
|
+
const preview = sanitizeLivePreview(value);
|
|
2314
|
+
return preview ? { scope: 'live-preview', level: 'info', ...preview } : undefined;
|
|
2315
|
+
}
|
|
1833
2316
|
if (value.scope === 'diagnostic-condition') {
|
|
1834
2317
|
const homeKitDefinition = typeof value.code === 'string' && Object.hasOwn(HOMEKIT_CONDITIONS, value.code)
|
|
1835
2318
|
? HOMEKIT_CONDITIONS[value.code]
|
|
@@ -1864,17 +2347,18 @@ function sanitizeStructuredEvent(message) {
|
|
|
1864
2347
|
const affectedAccessoryCount = nonNegativeInteger(value.affectedAccessoryCount);
|
|
1865
2348
|
const accessoryAliases = Array.isArray(value.accessoryAliases)
|
|
1866
2349
|
? value.accessoryAliases
|
|
1867
|
-
.filter((alias) =>
|
|
2350
|
+
.filter((alias) => opaqueAccessoryAlias(alias) !== undefined)
|
|
1868
2351
|
.slice(0, MAX_ACCESSORY_ALIASES)
|
|
1869
2352
|
: [];
|
|
2353
|
+
const { level: conditionLevel, actionKey } = homeKitConditionPresentation(value.code, value.active, value.reason);
|
|
1870
2354
|
return {
|
|
1871
2355
|
scope: 'diagnostic-condition',
|
|
1872
|
-
level:
|
|
2356
|
+
level: conditionLevel,
|
|
1873
2357
|
code: value.code,
|
|
1874
2358
|
active: value.active,
|
|
1875
2359
|
reason: value.reason,
|
|
1876
2360
|
summaryKey: homeKitDefinition[0],
|
|
1877
|
-
actionKey
|
|
2361
|
+
actionKey,
|
|
1878
2362
|
...(typeof value.capability === 'string' && CAPABILITIES.has(value.capability)
|
|
1879
2363
|
? { capability: value.capability }
|
|
1880
2364
|
: {}),
|
|
@@ -1886,19 +2370,22 @@ function sanitizeStructuredEvent(message) {
|
|
|
1886
2370
|
}
|
|
1887
2371
|
return undefined;
|
|
1888
2372
|
}
|
|
1889
|
-
/**
|
|
1890
|
-
|
|
1891
|
-
const
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
target.debug?.(JSON.stringify({
|
|
1896
|
-
scope: 'runtime-notice',
|
|
1897
|
-
level: notice.level,
|
|
2373
|
+
/** The record one notice is written as, read by both halves of the path. */
|
|
2374
|
+
function runtimeNoticeRecord(code, durationMs) {
|
|
2375
|
+
const [level, messageKey, durationMessageKey] = RUNTIME_NOTICES[code];
|
|
2376
|
+
return {
|
|
2377
|
+
scope: NOTICE_SCOPES[code] ?? 'runtime-notice',
|
|
2378
|
+
level,
|
|
1898
2379
|
code,
|
|
1899
|
-
messageKey,
|
|
2380
|
+
messageKey: durationMs !== undefined && durationMessageKey ? durationMessageKey : messageKey,
|
|
1900
2381
|
...(durationMs === undefined ? {} : { durationMs }),
|
|
1901
|
-
}
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
/** Emits one fixed-shape operational notice selected by its allowlisted code. */
|
|
2385
|
+
export function reportRuntimeNotice(target, code, fields = {}) {
|
|
2386
|
+
const record = runtimeNoticeRecord(code, fields.durationMs === undefined ? undefined : Math.max(0, Math.trunc(fields.durationMs)));
|
|
2387
|
+
target[record.level]?.(`[${code}] ${localize(target, record.messageKey, { durationMs: record.durationMs ?? 0, finishBy: fields.finishBy ?? '' })}`);
|
|
2388
|
+
target.debug?.(JSON.stringify(record));
|
|
1902
2389
|
}
|
|
1903
2390
|
/**
|
|
1904
2391
|
* Picks up an authorization the persisted session file confirms, reporting whether it did.
|
|
@@ -1906,68 +2393,124 @@ export function reportRuntimeNotice(target, code, fields = {}) {
|
|
|
1906
2393
|
* The file is the authority: the supplied identifier only names which window the caller believes was opened,
|
|
1907
2394
|
* and a session the file does not hold as active changes nothing and reports nothing. A confirmed pickup emits
|
|
1908
2395
|
* 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
|
|
2396
|
+
* at the moment it did rather than whenever a later record arrives, and its console line names the time the
|
|
2397
|
+
* capture stops on its own.
|
|
1910
2398
|
*/
|
|
1911
2399
|
export function armDiagnosticsAuthorization(target, storageRoot, supportCaseId, now = Date.now) {
|
|
1912
|
-
|
|
1913
|
-
|
|
2400
|
+
const session = activeDiagnosticsSession(storageRoot, now());
|
|
2401
|
+
if (!isSupportCaseId(supportCaseId) || session?.supportCaseId !== supportCaseId) {
|
|
1914
2402
|
return false;
|
|
1915
2403
|
}
|
|
1916
|
-
reportRuntimeNotice(target, 'diagnostics-authorization-armed'
|
|
2404
|
+
reportRuntimeNotice(target, 'diagnostics-authorization-armed', {
|
|
2405
|
+
finishBy: new Date(session.expiresAt).toLocaleString(),
|
|
2406
|
+
});
|
|
1917
2407
|
return true;
|
|
1918
2408
|
}
|
|
1919
|
-
/**
|
|
1920
|
-
export function
|
|
1921
|
-
const
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
level: 'warn',
|
|
1927
|
-
code,
|
|
1928
|
-
messageKey,
|
|
1929
|
-
}));
|
|
2409
|
+
/** States once which encoders code live video on this host: every qualified hardware encoder, then `libx264`. */
|
|
2410
|
+
export function reportLiveVideoEncoders(target, encoders) {
|
|
2411
|
+
const names = [
|
|
2412
|
+
...LIVE_VIDEO_ENCODERS.filter((name) => Object.values(encoders[name] ?? {}).includes('qualified') && name !== 'libx264'),
|
|
2413
|
+
'libx264',
|
|
2414
|
+
];
|
|
2415
|
+
target.info(`[live-video-encoders] ${localize(target, 'log.liveVideoEncoders', { encoders: names.join(', ') })}`);
|
|
1930
2416
|
}
|
|
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;
|
|
2417
|
+
/**
|
|
2418
|
+
* Narrows one offered station arbitration decision, or nothing where its own labels do not narrow.
|
|
2419
|
+
*
|
|
2420
|
+
* Read by both halves of the path — {@link reportStationClaim} builds the record and the file sink rebuilds
|
|
2421
|
+
* it — because a record only one of them recognises is written to a console nobody is reading and dropped
|
|
2422
|
+
* before it reaches a support archive.
|
|
2423
|
+
*/
|
|
2424
|
+
function sanitizeStationClaim(value) {
|
|
2425
|
+
const action = allowlistedLabel(value.action, STATION_CLAIM_ACTIONS);
|
|
2426
|
+
const claim = allowlistedLabel(value.claim, STATION_CLAIMS);
|
|
2427
|
+
if (!action || !claim) {
|
|
2428
|
+
return undefined;
|
|
1946
2429
|
}
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
2430
|
+
const against = allowlistedLabel(value.to ?? value.by ?? value.against, STATION_CLAIMS);
|
|
2431
|
+
return { event: 'station-claim', action, claim, ...(against === undefined ? {} : { against }) };
|
|
2432
|
+
}
|
|
2433
|
+
/**
|
|
2434
|
+
* Narrows one offered unrouted SDK event to its kind and target, or nothing where either is not allowlisted.
|
|
2435
|
+
*
|
|
2436
|
+
* Read by both halves of the path — {@link reportHomeKitEvent} builds the record and the file sink rebuilds it.
|
|
2437
|
+
*/
|
|
2438
|
+
function sanitizeUnroutedEvent(value) {
|
|
2439
|
+
const kind = allowlistedLabel(value.kind, UNROUTED_EVENT_KINDS);
|
|
2440
|
+
const target = allowlistedLabel(value.target, UNROUTED_EVENT_TARGETS);
|
|
2441
|
+
return kind && target ? { event: 'event-unrouted', kind, target } : undefined;
|
|
2442
|
+
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Records one arbitration decision over a station's own session as `homekit-log` evidence.
|
|
2445
|
+
*
|
|
2446
|
+
* A still that stood aside produced no picture of its own and no failure, so without this the deferral is
|
|
2447
|
+
* indistinguishable afterwards from a capture that was never asked for. The record carries the claims and the
|
|
2448
|
+
* decision alone: a station identity is a serial, and the camera holding it is already named by the records
|
|
2449
|
+
* about that camera.
|
|
2450
|
+
*/
|
|
2451
|
+
export function reportStationClaim(target, decision) {
|
|
2452
|
+
const claim = sanitizeStationClaim(decision);
|
|
2453
|
+
if (target.debug && claim) {
|
|
2454
|
+
target.debug(JSON.stringify({ scope: 'homekit', level: 'debug', ...claim }));
|
|
1953
2455
|
}
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
2456
|
+
}
|
|
2457
|
+
/**
|
|
2458
|
+
* Narrows one offered HomeKit adapter fact to its `homekit` record, or nothing where its route does not narrow.
|
|
2459
|
+
*
|
|
2460
|
+
* Read by both halves of the path: {@link reportHomeKitEvent} builds the record and the file sink rebuilds it.
|
|
2461
|
+
*/
|
|
2462
|
+
function sanitizeHomeKitEvent(value) {
|
|
2463
|
+
if (value.event === 'event-unrouted') {
|
|
2464
|
+
const unrouted = sanitizeUnroutedEvent(value);
|
|
2465
|
+
return unrouted ? { scope: 'homekit', level: 'debug', ...unrouted } : undefined;
|
|
2466
|
+
}
|
|
2467
|
+
if (value.event === 'station-claim') {
|
|
2468
|
+
const claim = sanitizeStationClaim(value);
|
|
2469
|
+
return claim ? { scope: 'homekit', level: 'debug', ...claim } : undefined;
|
|
2470
|
+
}
|
|
2471
|
+
if (value.adapter === 'camera.streaming' && value.event === 'live-video-selected') {
|
|
2472
|
+
const selection = sanitizeLiveVideoSelection(value);
|
|
2473
|
+
return selection ? { scope: 'homekit', level: 'debug', ...selection } : undefined;
|
|
2474
|
+
}
|
|
2475
|
+
if (value.adapter === 'camera.streaming' && HOMEKIT_LIVE_REQUEST_EVENTS.has(String(value.event))) {
|
|
2476
|
+
const lifecycle = sanitizeLiveSessionTrace(value);
|
|
2477
|
+
return lifecycle ? { scope: 'homekit', level: 'debug', ...lifecycle } : undefined;
|
|
2478
|
+
}
|
|
2479
|
+
if (typeof value.adapter !== 'string' ||
|
|
2480
|
+
typeof value.event !== 'string' ||
|
|
2481
|
+
!HOMEKIT_EVENT_ROUTES[value.adapter]?.has(value.event) ||
|
|
2482
|
+
typeof value.observation !== 'string' ||
|
|
2483
|
+
!HOMEKIT_OBSERVATIONS.has(value.observation)) {
|
|
2484
|
+
return undefined;
|
|
1960
2485
|
}
|
|
1961
|
-
|
|
2486
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
2487
|
+
return {
|
|
1962
2488
|
scope: 'homekit',
|
|
1963
2489
|
level: 'debug',
|
|
1964
|
-
adapter:
|
|
1965
|
-
event:
|
|
1966
|
-
observation:
|
|
1967
|
-
...(typeof
|
|
1968
|
-
? { announcedBy:
|
|
2490
|
+
adapter: value.adapter,
|
|
2491
|
+
event: value.event,
|
|
2492
|
+
observation: value.observation,
|
|
2493
|
+
...(typeof value.announcedBy === 'string' && HOMEKIT_ANNOUNCEMENTS.has(value.announcedBy)
|
|
2494
|
+
? { announcedBy: value.announcedBy }
|
|
1969
2495
|
: {}),
|
|
1970
|
-
|
|
2496
|
+
...(typeof value.code === 'number' && Number.isInteger(value.code) && value.code >= 0 && value.code <= 0xffff
|
|
2497
|
+
? { code: value.code }
|
|
2498
|
+
: {}),
|
|
2499
|
+
...(alias === undefined ? {} : { accessory: alias }),
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
/**
|
|
2503
|
+
* Emits one allowlisted HomeKit event trace only when host debug output is available.
|
|
2504
|
+
*
|
|
2505
|
+
* `aliasFor` resolves the trace's device identity to the support-case alias the conditions about that accessory
|
|
2506
|
+
* already carry. The identity itself never reaches the record: an unresolved one is recorded as no accessory.
|
|
2507
|
+
*/
|
|
2508
|
+
export function reportHomeKitEvent(target, trace, aliasFor) {
|
|
2509
|
+
const alias = 'serial' in trace && typeof trace.serial === 'string' ? aliasFor?.(trace.serial) : undefined;
|
|
2510
|
+
const record = sanitizeHomeKitEvent({ ...trace, accessory: alias });
|
|
2511
|
+
if (target.debug && record) {
|
|
2512
|
+
target.debug(JSON.stringify(record));
|
|
2513
|
+
}
|
|
1971
2514
|
}
|
|
1972
2515
|
/**
|
|
1973
2516
|
* Records one FFmpeg adaptation fact as the `ffmpeg-log` evidence the media support profiles declare.
|
|
@@ -1987,19 +2530,8 @@ export function reportAdaptationNotice(target, trace) {
|
|
|
1987
2530
|
}
|
|
1988
2531
|
}
|
|
1989
2532
|
/**
|
|
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.
|
|
2533
|
+
* Reduces one FFmpeg stderr line to what a support case may keep, or nothing where nothing is left of it: a
|
|
2534
|
+
* bounded printable line, never a `progress=` one, through {@link redactSensitiveText}.
|
|
2003
2535
|
*/
|
|
2004
2536
|
function redactAdaptationStderr(line) {
|
|
2005
2537
|
const printable = boundedText(line, MAX_ADAPTATION_STDERR_LINE_LENGTH);
|
|
@@ -2073,73 +2605,154 @@ function sanitizeAdaptationNotice(value, level) {
|
|
|
2073
2605
|
.filter((line) => line !== undefined)
|
|
2074
2606
|
.slice(-MAX_ADAPTATION_STDERR_LINES);
|
|
2075
2607
|
const sourceFragments = nonNegativeInteger(value.sourceFragments);
|
|
2608
|
+
const sourceWidth = nonNegativeInteger(value.sourceWidth);
|
|
2609
|
+
const sourceHeight = nonNegativeInteger(value.sourceHeight);
|
|
2610
|
+
const cause = allowlistedLabel(value.cause, ADAPTATION_CAUSES);
|
|
2611
|
+
const encoder = allowlistedLabel(value.encoder, LIVE_VIDEO_ENCODERS);
|
|
2612
|
+
const source = allowlistedLabel(value.source, LIVE_VIDEO_SOURCES);
|
|
2076
2613
|
return {
|
|
2077
2614
|
scope: 'ffmpeg',
|
|
2078
2615
|
level,
|
|
2079
2616
|
role: value.role,
|
|
2080
2617
|
event: value.event,
|
|
2618
|
+
...(cause === undefined ? {} : { cause }),
|
|
2619
|
+
...(encoder === undefined ? {} : { encoder }),
|
|
2620
|
+
...(source === undefined ? {} : { source }),
|
|
2621
|
+
...(sourceWidth === undefined || sourceHeight === undefined ? {} : { sourceWidth, sourceHeight }),
|
|
2081
2622
|
...(code === undefined ? {} : { code }),
|
|
2082
2623
|
...(signal === undefined ? {} : { signal }),
|
|
2083
2624
|
...(sourceFragments === undefined ? {} : { sourceFragments }),
|
|
2084
2625
|
...(stderr.length ? { stderr } : {}),
|
|
2085
2626
|
};
|
|
2086
2627
|
}
|
|
2628
|
+
/**
|
|
2629
|
+
* Records how one settings-page live preview ended, as a `live-preview` record kept whether or not a support
|
|
2630
|
+
* window is open: it is not one of the {@link VERBOSE_SCOPES}, because it holds only allowlisted labels and
|
|
2631
|
+
* bounded numbers, one per run a user started.
|
|
2632
|
+
*/
|
|
2633
|
+
export function reportLivePreview(target, trace) {
|
|
2634
|
+
const { level, ...selection } = trace.selection;
|
|
2635
|
+
const preview = sanitizeLivePreview({ ...trace, ...selection, levelName: level });
|
|
2636
|
+
if (preview)
|
|
2637
|
+
target.debug?.(JSON.stringify({ scope: 'live-preview', level: 'info', ...preview }));
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* Narrows one offered live preview run, or nothing where its selection or outcome does not narrow.
|
|
2641
|
+
*
|
|
2642
|
+
* Read by both halves of the path, {@link reportLivePreview} and the file sink, so a record only one of them
|
|
2643
|
+
* recognised could not reach a support archive.
|
|
2644
|
+
*/
|
|
2645
|
+
function sanitizeLivePreview(value) {
|
|
2646
|
+
const selection = sanitizePreviewSelection(value);
|
|
2647
|
+
const outcome = allowlistedLabel(value.outcome, PREVIEW_OUTCOMES);
|
|
2648
|
+
if (!selection || !outcome)
|
|
2649
|
+
return undefined;
|
|
2650
|
+
const encoder = allowlistedLabel(value.encoder, LIVE_VIDEO_ENCODERS);
|
|
2651
|
+
const source = allowlistedLabel(value.source, LIVE_VIDEO_SOURCES);
|
|
2652
|
+
const kbps = boundedInteger(value.kbps, MAX_LIVE_VIDEO_BIT_RATE);
|
|
2653
|
+
const accessory = opaqueAccessoryAlias(value.accessory);
|
|
2654
|
+
const ask = allowlistedLabel(value.ask, PREVIEW_ASKS);
|
|
2655
|
+
const settings = PREVIEW_SETTINGS.filter((setting) => typeof value[setting] === 'boolean');
|
|
2656
|
+
return {
|
|
2657
|
+
...selection,
|
|
2658
|
+
...(encoder === undefined ? {} : { encoder }),
|
|
2659
|
+
...(source === undefined ? {} : { source }),
|
|
2660
|
+
...(kbps === undefined ? {} : { kbps }),
|
|
2661
|
+
...(ask === undefined ? {} : { ask }),
|
|
2662
|
+
...Object.fromEntries(settings.map((setting) => [setting, value[setting]])),
|
|
2663
|
+
outcome,
|
|
2664
|
+
...(accessory === undefined ? {} : { accessory }),
|
|
2665
|
+
};
|
|
2666
|
+
}
|
|
2667
|
+
/** A live preview's selection as flat bounded fields, or nothing where any of them falls outside its allowlist. */
|
|
2668
|
+
function sanitizePreviewSelection(candidate) {
|
|
2669
|
+
const fields = {
|
|
2670
|
+
profile: allowlistedLabel(candidate.profile, HOMEKIT_LIVE_VIDEO_PROFILES),
|
|
2671
|
+
levelName: allowlistedLabel(candidate.levelName, HOMEKIT_LIVE_VIDEO_LEVELS),
|
|
2672
|
+
width: boundedInteger(candidate.width, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION),
|
|
2673
|
+
height: boundedInteger(candidate.height, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION),
|
|
2674
|
+
fps: boundedInteger(candidate.fps, MAX_LIVE_VIDEO_FRAME_RATE, MIN_LIVE_VIDEO_FRAME_RATE),
|
|
2675
|
+
maxBitRate: boundedInteger(candidate.maxBitRate, MAX_LIVE_VIDEO_BIT_RATE),
|
|
2676
|
+
};
|
|
2677
|
+
return Object.values(fields).includes(undefined) ? undefined : fields;
|
|
2678
|
+
}
|
|
2087
2679
|
function sanitizeLiveSessionTrace(value) {
|
|
2088
2680
|
if (value.adapter !== 'camera.streaming') {
|
|
2089
2681
|
return undefined;
|
|
2090
2682
|
}
|
|
2091
|
-
|
|
2092
|
-
|
|
2683
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
2684
|
+
const accessory = alias === undefined ? {} : { accessory: alias };
|
|
2685
|
+
if (value.event === 'live-session-released') {
|
|
2686
|
+
const release = allowlistedLabel(value.release, LIVE_SESSION_RELEASES);
|
|
2687
|
+
if (!release) {
|
|
2688
|
+
return undefined;
|
|
2689
|
+
}
|
|
2690
|
+
const reports = nonNegativeInteger(value.reports);
|
|
2691
|
+
const sinceLastReportMs = boundedInteger(value.sinceLastReportMs, MAX_STARTUP_WINDOW_MS);
|
|
2692
|
+
return {
|
|
2693
|
+
adapter: value.adapter,
|
|
2694
|
+
event: value.event,
|
|
2695
|
+
release,
|
|
2696
|
+
...(reports === undefined ? {} : { reports }),
|
|
2697
|
+
...(sinceLastReportMs === undefined ? {} : { sinceLastReportMs }),
|
|
2698
|
+
...(value.stage === 'sdk-source-acquisition' ? { stage: value.stage } : {}),
|
|
2699
|
+
...accessory,
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
if (value.event === 'live-session-streaming' || value.event === 'live-talkback-started') {
|
|
2703
|
+
return { adapter: value.adapter, event: value.event, ...accessory };
|
|
2093
2704
|
}
|
|
2094
2705
|
if (value.event === 'live-request-refused') {
|
|
2095
2706
|
return typeof value.reason === 'string' && REFUSAL_REASONS.has(value.reason)
|
|
2096
|
-
? { adapter: value.adapter, event: value.event, reason: value.reason }
|
|
2707
|
+
? { adapter: value.adapter, event: value.event, reason: value.reason, ...accessory }
|
|
2097
2708
|
: undefined;
|
|
2098
2709
|
}
|
|
2099
2710
|
if (value.event === 'live-request-unaccounted') {
|
|
2100
2711
|
return typeof value.afterMs === 'number' && Number.isFinite(value.afterMs)
|
|
2101
|
-
? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs) }
|
|
2712
|
+
? { adapter: value.adapter, event: value.event, afterMs: Math.round(value.afterMs), ...accessory }
|
|
2102
2713
|
: undefined;
|
|
2103
2714
|
}
|
|
2104
2715
|
if (value.event !== 'live-session-failed' ||
|
|
2105
2716
|
value.outcome !== 'failed' ||
|
|
2106
2717
|
typeof value.reason !== 'string' ||
|
|
2107
2718
|
!REASONS.has(value.reason) ||
|
|
2108
|
-
|
|
2109
|
-
!HOMEKIT_LIVE_SESSION_STAGES.has(value.stage)) {
|
|
2719
|
+
!allowlistedLabel(value.stage, HOMEKIT_LIVE_SESSION_STAGES)) {
|
|
2110
2720
|
return undefined;
|
|
2111
2721
|
}
|
|
2722
|
+
const sourceStage = allowlistedLabel(value.sourceStage, SDK_LIVE_START_STAGES);
|
|
2723
|
+
const sourceReason = allowlistedLabel(value.sourceReason, SDK_LIVE_START_REASONS);
|
|
2112
2724
|
return {
|
|
2113
2725
|
adapter: value.adapter,
|
|
2114
2726
|
event: value.event,
|
|
2115
2727
|
outcome: value.outcome,
|
|
2116
2728
|
reason: value.reason,
|
|
2117
2729
|
stage: value.stage,
|
|
2730
|
+
...(sourceStage === undefined ? {} : { sourceStage }),
|
|
2731
|
+
...(sourceReason === undefined ? {} : { sourceReason }),
|
|
2732
|
+
...accessory,
|
|
2118
2733
|
};
|
|
2119
2734
|
}
|
|
2120
2735
|
function sanitizeLiveVideoSelection(value) {
|
|
2121
|
-
const operation =
|
|
2122
|
-
const profile =
|
|
2123
|
-
const codecLevel = typeof value.levelName === 'string' ? value.levelName :
|
|
2736
|
+
const operation = allowlistedLabel(value.operation, HOMEKIT_LIVE_VIDEO_OPERATIONS);
|
|
2737
|
+
const profile = allowlistedLabel(value.profile, HOMEKIT_LIVE_VIDEO_PROFILES);
|
|
2738
|
+
const codecLevel = allowlistedLabel(typeof value.levelName === 'string' ? value.levelName : value.level, HOMEKIT_LIVE_VIDEO_LEVELS);
|
|
2124
2739
|
const width = boundedInteger(value.width, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION);
|
|
2125
2740
|
const height = boundedInteger(value.height, MAX_LIVE_VIDEO_DIMENSION, MIN_LIVE_VIDEO_DIMENSION);
|
|
2126
2741
|
const fps = boundedInteger(value.fps, MAX_LIVE_VIDEO_FRAME_RATE, MIN_LIVE_VIDEO_FRAME_RATE);
|
|
2127
2742
|
const mtu = boundedInteger(value.mtu, MAX_LIVE_VIDEO_MTU, MIN_LIVE_VIDEO_MTU);
|
|
2128
|
-
const
|
|
2743
|
+
const maxBitRate = boundedInteger(value.maxBitRate, MAX_LIVE_VIDEO_BIT_RATE, 0);
|
|
2744
|
+
const addressVersion = allowlistedLabel(value.addressVersion, HOMEKIT_LIVE_ADDRESS_VERSIONS);
|
|
2129
2745
|
if (!operation ||
|
|
2130
|
-
!HOMEKIT_LIVE_VIDEO_OPERATIONS.has(operation) ||
|
|
2131
2746
|
!profile ||
|
|
2132
|
-
!HOMEKIT_LIVE_VIDEO_PROFILES.has(profile) ||
|
|
2133
2747
|
!codecLevel ||
|
|
2134
|
-
!HOMEKIT_LIVE_VIDEO_LEVELS.has(codecLevel) ||
|
|
2135
2748
|
width === undefined ||
|
|
2136
2749
|
height === undefined ||
|
|
2137
2750
|
fps === undefined ||
|
|
2138
2751
|
mtu === undefined ||
|
|
2139
|
-
!addressVersion
|
|
2140
|
-
!HOMEKIT_LIVE_ADDRESS_VERSIONS.has(addressVersion)) {
|
|
2752
|
+
!addressVersion) {
|
|
2141
2753
|
return undefined;
|
|
2142
2754
|
}
|
|
2755
|
+
const alias = opaqueAccessoryAlias(value.accessory);
|
|
2143
2756
|
return {
|
|
2144
2757
|
adapter: 'camera.streaming',
|
|
2145
2758
|
event: 'live-video-selected',
|
|
@@ -2150,7 +2763,9 @@ function sanitizeLiveVideoSelection(value) {
|
|
|
2150
2763
|
height,
|
|
2151
2764
|
fps,
|
|
2152
2765
|
mtu,
|
|
2766
|
+
...(maxBitRate === undefined ? {} : { maxBitRate }),
|
|
2153
2767
|
addressVersion,
|
|
2768
|
+
...(alias === undefined ? {} : { accessory: alias }),
|
|
2154
2769
|
};
|
|
2155
2770
|
}
|
|
2156
2771
|
/** Emits bounded normal-output condition transitions without stable device or account identity. */
|
|
@@ -2209,10 +2824,11 @@ export class DiagnosticConditions {
|
|
|
2209
2824
|
}
|
|
2210
2825
|
const uniqueDeviceIds = condition.active ? [...new Set(affectedDeviceIds)].sort() : [];
|
|
2211
2826
|
const accessoryAliases = uniqueDeviceIds
|
|
2212
|
-
.map((identity) => this.
|
|
2827
|
+
.map((identity) => this.aliasFor(identity))
|
|
2213
2828
|
.filter((alias) => alias !== undefined)
|
|
2214
2829
|
.sort();
|
|
2215
|
-
|
|
2830
|
+
const { level, actionKey } = homeKitConditionPresentation(condition.code, condition.active, condition.reason);
|
|
2831
|
+
this.write(condition.code, condition.active, condition.reason, definition[0], actionKey, level, {
|
|
2216
2832
|
...(condition.capability === undefined ? {} : { capability: condition.capability }),
|
|
2217
2833
|
...(condition.member === undefined ? {} : { member: condition.member }),
|
|
2218
2834
|
affectedAccessoryCount: uniqueDeviceIds.length,
|
|
@@ -2224,9 +2840,19 @@ export class DiagnosticConditions {
|
|
|
2224
2840
|
const alias = this.aliases.get(serial);
|
|
2225
2841
|
return name === undefined ? undefined : alias === undefined ? name : `${name} (${alias})`;
|
|
2226
2842
|
})
|
|
2227
|
-
.filter((named) => named !== undefined));
|
|
2843
|
+
.filter((named) => named !== undefined), uniqueDeviceIds);
|
|
2228
2844
|
}
|
|
2229
|
-
|
|
2845
|
+
/** Every condition active now, each held from the transition that raised it to the one that clears it. */
|
|
2846
|
+
current() {
|
|
2847
|
+
return [...this.active.values()].map(({ condition }) => condition);
|
|
2848
|
+
}
|
|
2849
|
+
/**
|
|
2850
|
+
* The support-case alias for one device identity, minted on first use.
|
|
2851
|
+
*
|
|
2852
|
+
* The one place an alias is decided, so every record about a device — a condition, a media trace — carries
|
|
2853
|
+
* the same one, and the owner's console line is what resolves it to a name.
|
|
2854
|
+
*/
|
|
2855
|
+
aliasFor(identity) {
|
|
2230
2856
|
let alias = this.aliases.get(identity);
|
|
2231
2857
|
if (!alias && this.aliases.size < MAX_ACCESSORY_ALIASES) {
|
|
2232
2858
|
alias = `accessory-${randomUUID()}`;
|
|
@@ -2236,13 +2862,16 @@ export class DiagnosticConditions {
|
|
|
2236
2862
|
}
|
|
2237
2863
|
write(code, active, reason, summaryKey, actionKey, level, fields = {}, conditionKey = code,
|
|
2238
2864
|
/** Accessory names for the console line only — never retained, see {@link DiagnosticConditions}. */
|
|
2239
|
-
names) {
|
|
2240
|
-
const fingerprint = JSON.stringify({ active, reason, ...fields });
|
|
2865
|
+
names, serials = []) {
|
|
2866
|
+
const fingerprint = JSON.stringify({ active, reason, ...fields, serials });
|
|
2241
2867
|
if (active) {
|
|
2242
|
-
if (this.active.get(conditionKey) === fingerprint) {
|
|
2868
|
+
if (this.active.get(conditionKey)?.fingerprint === fingerprint) {
|
|
2243
2869
|
return;
|
|
2244
2870
|
}
|
|
2245
|
-
this.active.set(conditionKey,
|
|
2871
|
+
this.active.set(conditionKey, {
|
|
2872
|
+
fingerprint,
|
|
2873
|
+
condition: { code, summaryKey, actionKey, ...(serials.length === 0 ? {} : { serials: [...serials] }) },
|
|
2874
|
+
});
|
|
2246
2875
|
}
|
|
2247
2876
|
else if (!this.active.delete(conditionKey)) {
|
|
2248
2877
|
return;
|