@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.16 → 5.0.0-beta.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/diagnostics.js +103 -19
- package/dist/diagnostics.js.map +1 -1
- package/dist/platform.js +1 -0
- package/dist/platform.js.map +1 -1
- package/dist/runtime/channel.js +6 -2
- package/dist/runtime/channel.js.map +1 -1
- package/dist/runtime/owner.js +3 -0
- package/dist/runtime/owner.js.map +1 -1
- package/dist/ui/dashboard.js +1 -0
- package/dist/ui/dashboard.js.map +1 -1
- package/dist/ui/server.js +1 -0
- package/dist/ui/server.js.map +1 -1
- package/homebridge-ui/public/app.css +117 -229
- package/homebridge-ui/public/i18n/en.json +85 -79
- package/homebridge-ui/public/i18n/fr.json +86 -80
- package/homebridge-ui/public/index.html +84 -94
- package/homebridge-ui/public/js/app.js +246 -423
- package/homebridge-ui/public/js/dashboard.js +42 -4
- package/homebridge-ui/public/js/profile-wizard.js +6 -55
- package/i18n/runtime/en.json +1 -1
- package/package.json +1 -1
package/dist/diagnostics.js
CHANGED
|
@@ -270,13 +270,14 @@ const BUG_REPORT_FORM = 'https://github.com/homebridge-plugins/homebridge-eufy/i
|
|
|
270
270
|
function bugReportUrl(session, missingEvidence, host) {
|
|
271
271
|
const url = new URL(BUG_REPORT_FORM);
|
|
272
272
|
url.searchParams.set('template', 'bug_report.yml');
|
|
273
|
+
url.searchParams.set('title', `[Diagnostics] ${BUG_REPORT_AREAS[session.profile]}: `);
|
|
273
274
|
url.searchParams.set('environment', [
|
|
274
275
|
`- **Plugin Version**: ${PLUGIN_VERSION}`,
|
|
275
276
|
`- **eufy SDK**: ${SDK_VERSION}`,
|
|
276
277
|
`- **Node.js Version**: ${process.version}`,
|
|
277
278
|
`- **Homebridge Version**: ${host?.homebridge ?? ''}`,
|
|
278
279
|
`- **OS**: ${process.platform} ${process.arch}`,
|
|
279
|
-
`- **Diagnostics profile**: ${session.profile}
|
|
280
|
+
`- **Diagnostics profile**: ${session.profile}`,
|
|
280
281
|
`- **Missing evidence**: ${missingEvidence.length ? missingEvidence.join(', ') : 'none'}`,
|
|
281
282
|
].join('\n'));
|
|
282
283
|
url.searchParams.set('area', BUG_REPORT_AREAS[session.profile]);
|
|
@@ -289,7 +290,7 @@ const DIAGNOSTICS_PROFILES = {
|
|
|
289
290
|
'live-media': ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
|
|
290
291
|
'hksv-recording': ['plugin-log', 'sdk-log', 'ffmpeg-log'],
|
|
291
292
|
'dashboard-ui': ['plugin-log', 'ui-log'],
|
|
292
|
-
other: ['plugin-log', 'sdk-log', 'homekit-log'],
|
|
293
|
+
other: ['plugin-log', 'sdk-log', 'homekit-log', 'ffmpeg-log'],
|
|
293
294
|
};
|
|
294
295
|
/** Narrows external profile input against the diagnostics-owned profile registry. */
|
|
295
296
|
export function isDiagnosticsProfile(value) {
|
|
@@ -339,6 +340,22 @@ function activeDiagnosticsSession(storageRoot, now) {
|
|
|
339
340
|
const session = readDiagnosticsSession(storageRoot);
|
|
340
341
|
return session && Date.parse(session.expiresAt) > now ? session : undefined;
|
|
341
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* A session as it stands at `now`: a capture still running when its authorization ends is finished at that end.
|
|
345
|
+
*
|
|
346
|
+
* A finished capture can be reviewed and exported until the archive retention has elapsed past the end of its
|
|
347
|
+
* authorization, so a capture that ran out its 72 hours keeps what it captured.
|
|
348
|
+
*/
|
|
349
|
+
function settledDiagnosticsSession(session, now) {
|
|
350
|
+
const expiresAt = Date.parse(session.expiresAt);
|
|
351
|
+
const settled = session.reproductionStartedAt && !session.reproductionEndedAt && expiresAt <= now
|
|
352
|
+
? { ...session, reproductionEndedAt: session.expiresAt }
|
|
353
|
+
: session;
|
|
354
|
+
return {
|
|
355
|
+
session: settled,
|
|
356
|
+
exportable: Boolean(settled.reproductionEndedAt) && now < expiresAt + SUPPORT_ARCHIVE_RETENTION_MS,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
342
359
|
/**
|
|
343
360
|
* Collapses one supplied string to bounded printable text, or nothing where none of it survives.
|
|
344
361
|
*
|
|
@@ -577,6 +594,22 @@ export class GuidedDiagnostics {
|
|
|
577
594
|
await this.ensureMarker(session, 'reproduction-started', session.reproductionStartedAt);
|
|
578
595
|
return this.project(session);
|
|
579
596
|
}
|
|
597
|
+
/**
|
|
598
|
+
* Deletes the persisted session without an archive, which also ends the verbose retention it authorized. UI events
|
|
599
|
+
* already queued are written first and later ones are refused until it is gone, so none lands after the delete.
|
|
600
|
+
*/
|
|
601
|
+
async cancel() {
|
|
602
|
+
this.uiEventsClosing = true;
|
|
603
|
+
try {
|
|
604
|
+
await this.uiEventWrites;
|
|
605
|
+
this.pendingSupportArchive = undefined;
|
|
606
|
+
await rm(diagnosticsSessionPath(this.storageRoot), { force: true });
|
|
607
|
+
return await this.status();
|
|
608
|
+
}
|
|
609
|
+
finally {
|
|
610
|
+
this.uiEventsClosing = false;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
580
613
|
async endReproduction() {
|
|
581
614
|
let session = this.requireAuthorized();
|
|
582
615
|
if (!session.reproductionStartedAt) {
|
|
@@ -657,10 +690,16 @@ export class GuidedDiagnostics {
|
|
|
657
690
|
/** Prepares the exact manifest and evidence snapshot that one subsequent export may encrypt. */
|
|
658
691
|
async reviewSupportArchive() {
|
|
659
692
|
await this.uiEventWrites;
|
|
660
|
-
const
|
|
693
|
+
const persisted = readDiagnosticsSession(this.storageRoot);
|
|
694
|
+
const { session, exportable } = persisted
|
|
695
|
+
? settledDiagnosticsSession(persisted, this.now())
|
|
696
|
+
: { session: undefined, exportable: false };
|
|
661
697
|
if (!session?.reproductionStartedAt || !session.reproductionEndedAt) {
|
|
662
698
|
throw new Error('A completed reproduction is required before archive review');
|
|
663
699
|
}
|
|
700
|
+
if (!exportable) {
|
|
701
|
+
throw new Error('Diagnostics authorization is inactive or expired');
|
|
702
|
+
}
|
|
664
703
|
const collected = await this.collectSupportEvidence(session);
|
|
665
704
|
const reproductionStartedAt = Date.parse(session.reproductionStartedAt);
|
|
666
705
|
const selected = DIAGNOSTICS_PROFILES[session.profile];
|
|
@@ -703,7 +742,13 @@ export class GuidedDiagnostics {
|
|
|
703
742
|
this.pendingSupportArchive = { reviewId, manifest, evidence: collected };
|
|
704
743
|
return { reviewId, manifest };
|
|
705
744
|
}
|
|
706
|
-
/**
|
|
745
|
+
/**
|
|
746
|
+
* Consumes one reviewed snapshot and returns an encrypted envelope without writing plaintext or an archive to disk.
|
|
747
|
+
*
|
|
748
|
+
* The session outlives the export, because nothing here proves the envelope reached the reporter: a fresh review
|
|
749
|
+
* exports it again until the reporter leaves it with {@link cancel}, another capture replaces it, or the archive
|
|
750
|
+
* retention ends it.
|
|
751
|
+
*/
|
|
707
752
|
async exportSupportArchive(reviewId) {
|
|
708
753
|
const pending = this.pendingSupportArchive;
|
|
709
754
|
if (!pending || pending.reviewId !== reviewId) {
|
|
@@ -749,11 +794,12 @@ export class GuidedDiagnostics {
|
|
|
749
794
|
authTag: cipher.getAuthTag().toString('base64'),
|
|
750
795
|
ciphertext: ciphertext.toString('base64'),
|
|
751
796
|
};
|
|
752
|
-
|
|
797
|
+
const exported = {
|
|
753
798
|
filename: `homebridge-eufy-${pending.manifest.supportCaseId}.eufysupport.gz`,
|
|
754
799
|
mediaType: 'application/gzip',
|
|
755
800
|
archive: await gzip(Buffer.from(`${JSON.stringify(envelope)}\n`, 'utf8')),
|
|
756
801
|
};
|
|
802
|
+
return exported;
|
|
757
803
|
}
|
|
758
804
|
finally {
|
|
759
805
|
contentKey.fill(0);
|
|
@@ -1038,16 +1084,17 @@ export class GuidedDiagnostics {
|
|
|
1038
1084
|
throw new Error('Diagnostics authorization is inactive or expired');
|
|
1039
1085
|
return session;
|
|
1040
1086
|
}
|
|
1041
|
-
async project(
|
|
1042
|
-
if (!
|
|
1087
|
+
async project(persisted) {
|
|
1088
|
+
if (!persisted) {
|
|
1043
1089
|
return { status: 'inactive', selectedEvidence: [], missingEvidence: [], partialExportAvailable: false };
|
|
1044
1090
|
}
|
|
1091
|
+
const { session, exportable } = settledDiagnosticsSession(persisted, this.now());
|
|
1045
1092
|
const selectedEvidence = DIAGNOSTICS_PROFILES[session.profile];
|
|
1046
1093
|
const collected = session.reproductionStartedAt && session.reproductionEndedAt
|
|
1047
1094
|
? await this.collectSupportEvidence(session)
|
|
1048
1095
|
: undefined;
|
|
1049
1096
|
const missingEvidence = collected ? missingEvidenceClasses(selectedEvidence, collected) : [];
|
|
1050
|
-
const expired = Date.parse(session.expiresAt) <= this.now();
|
|
1097
|
+
const expired = Date.parse(session.expiresAt) <= this.now() && !exportable;
|
|
1051
1098
|
const status = expired
|
|
1052
1099
|
? 'expired'
|
|
1053
1100
|
: session.reproductionEndedAt
|
|
@@ -1066,7 +1113,7 @@ export class GuidedDiagnostics {
|
|
|
1066
1113
|
expiresAt: session.expiresAt,
|
|
1067
1114
|
...(session.reproductionStartedAt ? { reproductionStartedAt: session.reproductionStartedAt } : {}),
|
|
1068
1115
|
...(session.reproductionEndedAt ? { reproductionEndedAt: session.reproductionEndedAt } : {}),
|
|
1069
|
-
partialExportAvailable:
|
|
1116
|
+
partialExportAvailable: exportable,
|
|
1070
1117
|
...(session.affectedDevices === undefined ? {} : { affectedDevices: session.affectedDevices }),
|
|
1071
1118
|
issueUrl: bugReportUrl(session, missingEvidence, readHostEnvironment(this.storageRoot)),
|
|
1072
1119
|
};
|
|
@@ -1984,6 +2031,33 @@ const HOMEKIT_REASON_ACTIONS = {
|
|
|
1984
2031
|
'camera-recording-unavailable:adapter-missing': 'log.action.setFfmpegPath',
|
|
1985
2032
|
'camera-live-session-failed:adaptation-spawn-failed': 'log.action.checkFfmpegPath',
|
|
1986
2033
|
};
|
|
2034
|
+
/** Projects a value onto an active condition, or nothing where the catalog does not write that code with those keys. */
|
|
2035
|
+
export function knownCondition(value) {
|
|
2036
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
2037
|
+
return undefined;
|
|
2038
|
+
const { code, summaryKey, actionKey, serials } = value;
|
|
2039
|
+
if (typeof code !== 'string')
|
|
2040
|
+
return undefined;
|
|
2041
|
+
const runtime = Object.values(RUNTIME_CONDITIONS).find((condition) => condition.code === code);
|
|
2042
|
+
const homeKit = Object.hasOwn(HOMEKIT_CONDITIONS, code)
|
|
2043
|
+
? HOMEKIT_CONDITIONS[code]
|
|
2044
|
+
: undefined;
|
|
2045
|
+
const [summary, ...actions] = runtime
|
|
2046
|
+
? [runtime.summaryKey, runtime.actionKey]
|
|
2047
|
+
: homeKit
|
|
2048
|
+
? [
|
|
2049
|
+
...homeKit,
|
|
2050
|
+
...Object.entries(HOMEKIT_REASON_ACTIONS)
|
|
2051
|
+
.filter(([key]) => key.startsWith(`${code}:`))
|
|
2052
|
+
.map(([, action]) => action),
|
|
2053
|
+
]
|
|
2054
|
+
: [];
|
|
2055
|
+
const action = allowlistedLabel(actionKey, actions);
|
|
2056
|
+
if (summary === undefined || summaryKey !== summary || action === undefined)
|
|
2057
|
+
return undefined;
|
|
2058
|
+
const named = Array.isArray(serials) ? serials.filter((serial) => typeof serial === 'string') : [];
|
|
2059
|
+
return { code, summaryKey: summary, actionKey: action, ...(named.length === 0 ? {} : { serials: named }) };
|
|
2060
|
+
}
|
|
1987
2061
|
/** HomeKit conditions that state a gap nothing in the user's setup closes, so an active one is not a warning. */
|
|
1988
2062
|
const INFORMATIONAL_HOMEKIT_CONDITIONS = new Set(['recognized-device-not-represented']);
|
|
1989
2063
|
/** The level and action one HomeKit condition is written with, decided once for the console and the record. */
|
|
@@ -2179,7 +2253,7 @@ export function reportRuntimeNotice(target, code, fields = {}) {
|
|
|
2179
2253
|
const notice = RUNTIME_NOTICES[code];
|
|
2180
2254
|
const durationMs = fields.durationMs === undefined ? undefined : Math.max(0, Math.trunc(fields.durationMs));
|
|
2181
2255
|
const messageKey = durationMs !== undefined && 'durationMessageKey' in notice ? notice.durationMessageKey : notice.messageKey;
|
|
2182
|
-
target[notice.level]?.(`[${code}] ${localize(target, messageKey, { durationMs: durationMs ?? 0 })}`);
|
|
2256
|
+
target[notice.level]?.(`[${code}] ${localize(target, messageKey, { durationMs: durationMs ?? 0, finishBy: fields.finishBy ?? '' })}`);
|
|
2183
2257
|
target.debug?.(JSON.stringify({
|
|
2184
2258
|
scope: 'runtime-notice',
|
|
2185
2259
|
level: notice.level,
|
|
@@ -2194,14 +2268,17 @@ export function reportRuntimeNotice(target, code, fields = {}) {
|
|
|
2194
2268
|
* The file is the authority: the supplied identifier only names which window the caller believes was opened,
|
|
2195
2269
|
* and a session the file does not hold as active changes nothing and reports nothing. A confirmed pickup emits
|
|
2196
2270
|
* one operational record, which is the evidence that this process began retaining the window's scopes, taken
|
|
2197
|
-
* at the moment it did rather than whenever a later record arrives
|
|
2271
|
+
* at the moment it did rather than whenever a later record arrives, and its console line names the time the
|
|
2272
|
+
* capture stops on its own.
|
|
2198
2273
|
*/
|
|
2199
2274
|
export function armDiagnosticsAuthorization(target, storageRoot, supportCaseId, now = Date.now) {
|
|
2200
|
-
|
|
2201
|
-
|
|
2275
|
+
const session = activeDiagnosticsSession(storageRoot, now());
|
|
2276
|
+
if (!isSupportCaseId(supportCaseId) || session?.supportCaseId !== supportCaseId) {
|
|
2202
2277
|
return false;
|
|
2203
2278
|
}
|
|
2204
|
-
reportRuntimeNotice(target, 'diagnostics-authorization-armed'
|
|
2279
|
+
reportRuntimeNotice(target, 'diagnostics-authorization-armed', {
|
|
2280
|
+
finishBy: new Date(session.expiresAt).toLocaleString(),
|
|
2281
|
+
});
|
|
2205
2282
|
return true;
|
|
2206
2283
|
}
|
|
2207
2284
|
/** Emits the startup notice for discarded settings awaiting acknowledgement. */
|
|
@@ -2575,7 +2652,11 @@ export class DiagnosticConditions {
|
|
|
2575
2652
|
const alias = this.aliases.get(serial);
|
|
2576
2653
|
return name === undefined ? undefined : alias === undefined ? name : `${name} (${alias})`;
|
|
2577
2654
|
})
|
|
2578
|
-
.filter((named) => named !== undefined));
|
|
2655
|
+
.filter((named) => named !== undefined), uniqueDeviceIds);
|
|
2656
|
+
}
|
|
2657
|
+
/** Every condition active now, each held from the transition that raised it to the one that clears it. */
|
|
2658
|
+
current() {
|
|
2659
|
+
return [...this.active.values()].map(({ condition }) => condition);
|
|
2579
2660
|
}
|
|
2580
2661
|
/**
|
|
2581
2662
|
* The support-case alias for one device identity, minted on first use.
|
|
@@ -2593,13 +2674,16 @@ export class DiagnosticConditions {
|
|
|
2593
2674
|
}
|
|
2594
2675
|
write(code, active, reason, summaryKey, actionKey, level, fields = {}, conditionKey = code,
|
|
2595
2676
|
/** Accessory names for the console line only — never retained, see {@link DiagnosticConditions}. */
|
|
2596
|
-
names) {
|
|
2597
|
-
const fingerprint = JSON.stringify({ active, reason, ...fields });
|
|
2677
|
+
names, serials = []) {
|
|
2678
|
+
const fingerprint = JSON.stringify({ active, reason, ...fields, serials });
|
|
2598
2679
|
if (active) {
|
|
2599
|
-
if (this.active.get(conditionKey) === fingerprint) {
|
|
2680
|
+
if (this.active.get(conditionKey)?.fingerprint === fingerprint) {
|
|
2600
2681
|
return;
|
|
2601
2682
|
}
|
|
2602
|
-
this.active.set(conditionKey,
|
|
2683
|
+
this.active.set(conditionKey, {
|
|
2684
|
+
fingerprint,
|
|
2685
|
+
condition: { code, summaryKey, actionKey, ...(serials.length === 0 ? {} : { serials: [...serials] }) },
|
|
2686
|
+
});
|
|
2603
2687
|
}
|
|
2604
2688
|
else if (!this.active.delete(conditionKey)) {
|
|
2605
2689
|
return;
|