@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.1 → 5.0.0-beta.11

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.
Files changed (35) hide show
  1. package/README.md +4 -6
  2. package/config.schema.json +20 -0
  3. package/dist/configuration.js +26 -2
  4. package/dist/configuration.js.map +1 -1
  5. package/dist/device/member-evidence.js +8 -1
  6. package/dist/device/member-evidence.js.map +1 -1
  7. package/dist/diagnostics.js +438 -60
  8. package/dist/diagnostics.js.map +1 -1
  9. package/dist/homekit/adapters/camera-streaming.js +54 -26
  10. package/dist/homekit/adapters/camera-streaming.js.map +1 -1
  11. package/dist/homekit/adapters/security-system.js +85 -44
  12. package/dist/homekit/adapters/security-system.js.map +1 -1
  13. package/dist/homekit/reconciler.js +5 -2
  14. package/dist/homekit/reconciler.js.map +1 -1
  15. package/dist/media/live-stream.js +98 -54
  16. package/dist/media/live-stream.js.map +1 -1
  17. package/dist/media/recording.js +7 -5
  18. package/dist/media/recording.js.map +1 -1
  19. package/dist/media/snapshot.js +4 -4
  20. package/dist/media/station-live-sessions.js +47 -43
  21. package/dist/media/station-live-sessions.js.map +1 -1
  22. package/dist/platform.js +24 -5
  23. package/dist/platform.js.map +1 -1
  24. package/dist/ui/dashboard.js +5 -1
  25. package/dist/ui/dashboard.js.map +1 -1
  26. package/dist/ui/server.js +26 -4
  27. package/dist/ui/server.js.map +1 -1
  28. package/homebridge-ui/public/app.css +356 -3
  29. package/homebridge-ui/public/i18n/en.json +25 -14
  30. package/homebridge-ui/public/i18n/fr.json +25 -14
  31. package/homebridge-ui/public/index.html +24 -32
  32. package/homebridge-ui/public/js/app.js +187 -65
  33. package/homebridge-ui/public/js/dashboard.js +80 -1
  34. package/homebridge-ui/public/js/profile-wizard.js +40 -28
  35. package/package.json +2 -2
@@ -188,44 +188,34 @@
188
188
  <div class="diagnostics-stage">
189
189
  <div class="diagnostics-wizard diagnostics-surface" data-diagnostics-wizard aria-live="polite">
190
190
  <div class="diagnostics-question" data-diagnostics-question>
191
- <strong id="diagnostics-question-heading" tabindex="-1" data-diagnostics-question-text></strong>
192
- <div class="diagnostics-choice-actions" role="group" aria-labelledby="diagnostics-question-heading">
193
- <button
194
- type="button"
195
- class="diagnostics-secondary-action"
196
- data-diagnostics-answer="no"
197
- data-i18n="diagnosticsNo"
198
- ></button>
199
- <button type="button" data-diagnostics-answer="yes" data-i18n="diagnosticsYes"></button>
191
+ <strong
192
+ id="diagnostics-question-heading"
193
+ tabindex="-1"
194
+ data-diagnostics-question-text
195
+ data-i18n="diagnosticsTilesHeading"
196
+ ></strong>
197
+ <div class="diagnostics-tiles" role="group" aria-labelledby="diagnostics-question-heading">
198
+ <button type="button" data-diagnostics-tile="startup-authentication" data-i18n="diagnosticsProfileStartup"></button>
199
+ <button type="button" data-diagnostics-tile="device-representation" data-i18n="diagnosticsProfileDevices"></button>
200
+ <button type="button" data-diagnostics-tile="control-state" data-i18n="diagnosticsProfileControl"></button>
201
+ <button type="button" data-diagnostics-tile="live-media" data-i18n="diagnosticsProfileLiveMedia"></button>
202
+ <button type="button" data-diagnostics-tile="hksv-recording" data-i18n="diagnosticsProfileRecording"></button>
203
+ <button type="button" data-diagnostics-tile="dashboard-ui" data-i18n="diagnosticsProfileDashboard"></button>
204
+ <button type="button" data-diagnostics-tile="other" data-i18n="diagnosticsProfileOther"></button>
200
205
  </div>
201
- <button
202
- type="button"
203
- class="diagnostics-quiet-action"
204
- data-diagnostics-direct
205
- data-i18n="diagnosticsChooseDirectly"
206
- ></button>
207
206
  </div>
208
- <div class="diagnostics-direct" data-diagnostics-direct-panel hidden>
209
- <label>
210
- <span data-i18n="diagnosticsProfileLabel"></span>
211
- <select data-diagnostics-profile>
212
- <option value="startup-authentication" data-i18n="diagnosticsProfileStartup"></option>
213
- <option value="device-representation" data-i18n="diagnosticsProfileDevices"></option>
214
- <option value="control-state" data-i18n="diagnosticsProfileControl"></option>
215
- <option value="live-media" data-i18n="diagnosticsProfileLiveMedia"></option>
216
- <option value="hksv-recording" data-i18n="diagnosticsProfileRecording"></option>
217
- <option value="dashboard-ui" data-i18n="diagnosticsProfileDashboard"></option>
218
- <option value="other" data-i18n="diagnosticsProfileOther"></option>
219
- </select>
220
- </label>
207
+ <div class="diagnostics-devices" data-diagnostics-devices hidden>
208
+ <strong id="diagnostics-devices-heading" tabindex="-1" data-i18n="diagnosticsDevicesHeading"></strong>
209
+ <p data-i18n="diagnosticsDevicesHint"></p>
210
+ <div class="diagnostics-device-list" data-diagnostics-device-list role="group" aria-labelledby="diagnostics-devices-heading"></div>
221
211
  <div class="diagnostics-choice-actions">
222
212
  <button
223
213
  type="button"
224
214
  class="diagnostics-secondary-action"
225
- data-diagnostics-direct-back
226
- data-i18n="diagnosticsBackToQuestions"
215
+ data-diagnostics-devices-every
216
+ data-i18n="diagnosticsDevicesEvery"
227
217
  ></button>
228
- <button type="button" data-diagnostics-direct-choose data-i18n="diagnosticsUseProfile"></button>
218
+ <button type="button" data-diagnostics-devices-chosen disabled data-i18n="diagnosticsDevicesChosen"></button>
229
219
  </div>
230
220
  </div>
231
221
  <div class="diagnostics-frequency" data-diagnostics-frequency hidden>
@@ -288,20 +278,22 @@
288
278
  <div class="diagnostics-result diagnostics-surface" data-diagnostics-result hidden>
289
279
  <strong tabindex="-1" data-diagnostics-result-heading data-i18n="diagnosticsEvidenceReady"></strong>
290
280
  <p data-i18n="diagnosticsArchiveReviewIntro"></p>
291
- <button type="button" data-diagnostics-review data-i18n="diagnosticsArchiveReview"></button>
292
281
  <div class="diagnostics-manifest" data-diagnostics-manifest hidden></div>
293
282
  <label class="diagnostics-review-confirm" data-diagnostics-review-confirm-label hidden>
294
283
  <input type="checkbox" data-diagnostics-review-confirm />
295
284
  <span data-i18n="diagnosticsArchiveReviewConfirm"></span>
296
285
  </label>
286
+ <p class="diagnostics-local-note" data-i18n="diagnosticsStaysLocal"></p>
297
287
  <button type="button" data-diagnostics-export disabled hidden data-i18n="diagnosticsArchiveExport"></button>
298
288
  <a
299
289
  data-diagnostics-issue
300
290
  hidden
301
291
  target="_blank"
302
292
  rel="noopener noreferrer"
293
+ aria-describedby="diagnostics-issue-hint"
303
294
  data-i18n="diagnosticsOpenIssue"
304
295
  ></a>
296
+ <p class="diagnostics-step-hint" id="diagnostics-issue-hint" data-diagnostics-issue-hint></p>
305
297
  <button
306
298
  type="button"
307
299
  class="diagnostics-secondary-action"
@@ -39,12 +39,11 @@ const diagnosticsClose = document.querySelector('[data-diagnostics-close]');
39
39
  const diagnosticsWizardPanel = document.querySelector('[data-diagnostics-wizard]');
40
40
  const diagnosticsQuestion = document.querySelector('[data-diagnostics-question]');
41
41
  const diagnosticsQuestionText = document.querySelector('[data-diagnostics-question-text]');
42
- const diagnosticsYes = document.querySelector('[data-diagnostics-answer="yes"]');
43
- const diagnosticsNo = document.querySelector('[data-diagnostics-answer="no"]');
44
- const diagnosticsDirect = document.querySelector('[data-diagnostics-direct]');
45
- const diagnosticsDirectPanel = document.querySelector('[data-diagnostics-direct-panel]');
46
- const diagnosticsDirectChoose = document.querySelector('[data-diagnostics-direct-choose]');
47
- const diagnosticsDirectBack = document.querySelector('[data-diagnostics-direct-back]');
42
+ const diagnosticsTiles = [...document.querySelectorAll('[data-diagnostics-tile]')];
43
+ const diagnosticsDevices = document.querySelector('[data-diagnostics-devices]');
44
+ const diagnosticsDeviceList = document.querySelector('[data-diagnostics-device-list]');
45
+ const diagnosticsDevicesEvery = document.querySelector('[data-diagnostics-devices-every]');
46
+ const diagnosticsDevicesChosen = document.querySelector('[data-diagnostics-devices-chosen]');
48
47
  const diagnosticsFrequency = document.querySelector('[data-diagnostics-frequency]');
49
48
  const diagnosticsFrequencyHeading = document.querySelector('#diagnostics-frequency-heading');
50
49
  const diagnosticsFrequencyNow = document.querySelector('[data-diagnostics-frequency-answer="now"]');
@@ -52,11 +51,11 @@ const diagnosticsFrequencyIntermittent = document.querySelector('[data-diagnosti
52
51
  const diagnosticsFrequencyBack = document.querySelector('[data-diagnostics-frequency-back]');
53
52
  const diagnosticsMatch = document.querySelector('[data-diagnostics-match]');
54
53
  const diagnosticsReject = document.querySelector('[data-diagnostics-reject]');
55
- const diagnosticsProfile = document.querySelector('[data-diagnostics-profile]');
56
54
  const diagnosticsAuthorize = document.querySelector('[data-diagnostics-authorize]');
57
55
  const diagnosticsReproduction = document.querySelector('[data-diagnostics-reproduction]');
58
56
  const diagnosticsStatus = document.querySelector('[data-diagnostics-status]');
59
57
  const diagnosticsIssue = document.querySelector('[data-diagnostics-issue]');
58
+ const diagnosticsIssueHint = document.querySelector('[data-diagnostics-issue-hint]');
60
59
  const diagnosticsResult = document.querySelector('[data-diagnostics-result]');
61
60
  const diagnosticsActions = document.querySelector('[data-diagnostics-actions]');
62
61
  const diagnosticsGuidanceTitle = document.querySelector('[data-diagnostics-guidance-title]');
@@ -66,7 +65,6 @@ const diagnosticsPhaseTitle = document.querySelector('[data-diagnostics-phase-ti
66
65
  const diagnosticsGuidanceBeforeSection = document.querySelector('[data-diagnostics-guidance-before-section]');
67
66
  const diagnosticsGuidanceBefore = document.querySelector('[data-diagnostics-guidance-before]');
68
67
  const diagnosticsGuidanceAction = document.querySelector('[data-diagnostics-guidance-action]');
69
- const diagnosticsReview = document.querySelector('[data-diagnostics-review]');
70
68
  const diagnosticsManifest = document.querySelector('[data-diagnostics-manifest]');
71
69
  const diagnosticsReviewConfirm = document.querySelector('[data-diagnostics-review-confirm]');
72
70
  const diagnosticsReviewConfirmLabel = document.querySelector('[data-diagnostics-review-confirm-label]');
@@ -100,6 +98,12 @@ let challenge = '';
100
98
  let legacyNames = [];
101
99
  let legacyAcknowledged = false;
102
100
  let diagnosticsState = { status: 'inactive', missingEvidence: [] };
101
+ /** Whether the reporter already downloaded this session's archive, which is what the report offer follows. */
102
+ let diagnosticsArchiveDownloaded = false;
103
+ /** The session whose manifest is on screen, so it is fetched once rather than on every render. */
104
+ let diagnosticsReviewedCaseId = '';
105
+ /** Guards the one fetch, so a burst of renders does not assemble the manifest several times over. */
106
+ let diagnosticsReviewPending = false;
103
107
  let diagnosticsReviewId = '';
104
108
  let diagnosticsStartingAnother = false;
105
109
  let panelReturn;
@@ -258,17 +262,82 @@ function renderDiagnosticsGuidance(profile) {
258
262
  diagnosticsGuidanceAction.textContent = messages[guidance.action] ?? '';
259
263
  }
260
264
 
265
+ /**
266
+ * The devices the reporter may name, one tile each.
267
+ *
268
+ * A tile carries the device's own name above its retained image where it has one, and the icon the devices
269
+ * view uses for a device where it does not. The image is capped at half the tile: it is there to be recognised
270
+ * at a glance, and a picture that fills the tile leaves the name it belongs to competing with it.
271
+ *
272
+ * Only the devices the chosen area's fault could be about are offered, so the answer cannot name a device that
273
+ * never had the thing being reported.
274
+ *
275
+ * The list is asked for when it is empty rather than relying on the devices view having been opened first, and
276
+ * a request that goes unanswered leaves the step able to say every device, which is a complete answer.
277
+ */
278
+ async function renderDiagnosticsDeviceList() {
279
+ if (dashboardDevices.length === 0) {
280
+ try {
281
+ const snapshot = await requestWithinDeadline('/dashboard', { representationPreferences: {} }, 12000);
282
+ dashboardDevices = snapshot.devices ?? [];
283
+ } catch {
284
+ dashboardDevices = [];
285
+ }
286
+ }
287
+ diagnosticsDeviceList.replaceChildren();
288
+ for (const device of diagnosticsWizard.affectable(diagnosticsWizardState.profile, dashboardDevices)) {
289
+ const tile = document.createElement('button');
290
+ tile.type = 'button';
291
+ tile.dataset.serial = device.serial;
292
+ tile.setAttribute('aria-pressed', 'false');
293
+ const art = document.createElement('img');
294
+ art.alt = '';
295
+ if (device.artwork) {
296
+ art.className = 'device-photo';
297
+ art.loading = 'lazy';
298
+ art.src = device.artwork;
299
+ } else {
300
+ art.src = 'assets/icons/inventory.svg';
301
+ }
302
+ const name = document.createElement('span');
303
+ name.textContent = device.name;
304
+ tile.append(art, name);
305
+ tile.addEventListener('click', () => {
306
+ tile.setAttribute('aria-pressed', tile.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
307
+ renderDiagnosticsDeviceChoice();
308
+ });
309
+ diagnosticsDeviceList.append(tile);
310
+ }
311
+ renderDiagnosticsDeviceChoice();
312
+ }
313
+
314
+ /**
315
+ * The two controls that follow the pressed tiles: whether continuing is possible, and what the escape does.
316
+ *
317
+ * With every tile pressed there is nothing left for a select-all to select, so the same control clears instead —
318
+ * otherwise the only way back from a full grid is pressing each tile again.
319
+ */
320
+ function renderDiagnosticsDeviceChoice() {
321
+ const tiles = diagnosticsDeviceList.querySelectorAll('[data-serial]').length;
322
+ const chosen = chosenDiagnosticsDevices().length;
323
+ diagnosticsDevicesChosen.disabled = chosen === 0;
324
+ diagnosticsDevicesEvery.textContent =
325
+ messages[chosen > 0 && chosen === tiles ? 'diagnosticsDevicesNone' : 'diagnosticsDevicesEvery'] ?? '';
326
+ }
327
+
328
+ /** The serials pressed in the device step, in the order the grid drew them. */
329
+ function chosenDiagnosticsDevices() {
330
+ return [...diagnosticsDeviceList.querySelectorAll('[data-serial]')]
331
+ .filter((tile) => tile.getAttribute('aria-pressed') === 'true')
332
+ .map((tile) => tile.dataset.serial);
333
+ }
334
+
261
335
  function renderDiagnosticsWizard() {
262
- diagnosticsQuestion.hidden = diagnosticsWizardState.mode !== 'questions';
263
- diagnosticsDirectPanel.hidden = diagnosticsWizardState.mode !== 'direct';
336
+ diagnosticsQuestion.hidden = diagnosticsWizardState.mode !== 'tiles';
337
+ diagnosticsDevices.hidden = diagnosticsWizardState.mode !== 'devices';
264
338
  diagnosticsFrequency.hidden = diagnosticsWizardState.mode !== 'frequency';
265
339
  diagnosticsMatch.hidden = diagnosticsWizardState.mode !== 'match';
266
- if (diagnosticsWizardState.mode === 'questions') {
267
- const question = diagnosticsWizard.questions[diagnosticsWizardState.questionIndex];
268
- diagnosticsQuestionText.textContent = messages[question.message] ?? '';
269
- }
270
340
  if (diagnosticsWizardState.mode === 'match') {
271
- diagnosticsProfile.value = diagnosticsWizardState.profile;
272
341
  renderDiagnosticsGuidance(diagnosticsWizardState.profile);
273
342
  diagnosticsModeSummary.textContent =
274
343
  messages[
@@ -279,7 +348,21 @@ function renderDiagnosticsWizard() {
279
348
  }
280
349
  }
281
350
 
351
+ /**
352
+ * The report step, reachable or not.
353
+ *
354
+ * Without an archive there is nothing to attach, so the anchor carries no `href` — it is not a link — and
355
+ * `aria-disabled` states the same thing to a reader who cannot see it greyed. The hint says why, because a
356
+ * control that cannot be used and does not explain itself reads as broken rather than as not yet.
357
+ */
358
+ function setIssueStepReachable(url) {
359
+ diagnosticsIssue.href = url;
360
+ diagnosticsIssue.setAttribute('aria-disabled', url ? 'false' : 'true');
361
+ diagnosticsIssueHint.textContent = messages[url ? 'diagnosticsIssueOpensTab' : 'diagnosticsIssueNeedsArchive'] ?? '';
362
+ }
363
+
282
364
  function renderDiagnostics(state) {
365
+ if (state.supportCaseId !== diagnosticsState.supportCaseId) diagnosticsArchiveDownloaded = false;
283
366
  diagnosticsState = state;
284
367
  const screen = diagnosticsWizard.screen(state, diagnosticsStartingAnother);
285
368
  const choosing = screen === 'choose';
@@ -307,17 +390,20 @@ function renderDiagnostics(state) {
307
390
  : state.status === 'reproducing'
308
391
  ? messages.diagnosticsNowFinish
309
392
  : messages.diagnosticsStartRecording;
310
- diagnosticsIssue.hidden = !reviewing;
311
- diagnosticsIssue.href = state.issueUrl ?? '';
393
+ diagnosticsIssue.hidden = true;
394
+ setIssueStepReachable(diagnosticsArchiveDownloaded ? (state.issueUrl ?? '') : '');
312
395
  diagnosticsResult.hidden = !reviewing;
313
- diagnosticsReview.hidden = !reviewing;
314
- diagnosticsManifest.hidden = true;
315
- diagnosticsReviewConfirmLabel.hidden = true;
316
- diagnosticsReviewConfirm.checked = false;
317
- diagnosticsExport.hidden = true;
318
- diagnosticsExport.disabled = true;
319
- diagnosticsReviewId = '';
320
- if (state.profile) diagnosticsProfile.value = state.profile;
396
+ const reviewed = reviewing && diagnosticsReviewedCaseId === (state.supportCaseId ?? '');
397
+ diagnosticsManifest.hidden = !reviewed;
398
+ diagnosticsReviewConfirmLabel.hidden = !reviewed;
399
+ diagnosticsExport.hidden = !reviewed;
400
+ diagnosticsIssue.hidden = !reviewed;
401
+ if (!reviewed) {
402
+ diagnosticsReviewConfirm.checked = false;
403
+ diagnosticsExport.disabled = true;
404
+ diagnosticsReviewId = '';
405
+ if (reviewing) void ensureArchiveReview(state.supportCaseId ?? '');
406
+ }
321
407
  if (screen === 'reproduce') {
322
408
  renderDiagnosticsGuidance(state.profile);
323
409
  if (reproductionMode === 'intermittent') {
@@ -349,6 +435,21 @@ function renderDiagnostics(state) {
349
435
  ).replace('{evidence}', state.missingEvidence?.join(', ') ?? '');
350
436
  }
351
437
 
438
+ /**
439
+ * The catalogue entry that says, in a reader's words, what a never-collected class is.
440
+ *
441
+ * A class with no entry falls back to its own identifier, which is visible rather than absent, so a newly
442
+ * declared class shows up as something to translate instead of quietly disappearing from the list.
443
+ */
444
+ const NEVER_COLLECTED_LABELS = {
445
+ 'credentials-and-authentication': 'excludedCredentials',
446
+ 'tokens-cookies-and-authorization': 'excludedTokens',
447
+ 'session-and-push-stores': 'excludedSessions',
448
+ 'private-and-symmetric-keys': 'excludedKeys',
449
+ 'unconstrained-internal-objects': 'excludedInternalData',
450
+ 'camera-images-talkback-and-raw-media': 'excludedMedia',
451
+ };
452
+
352
453
  function renderArchiveManifest(manifest) {
353
454
  diagnosticsManifest.replaceChildren();
354
455
  const summary = document.createElement('p');
@@ -366,9 +467,16 @@ function renderArchiveManifest(manifest) {
366
467
  row.textContent = `${item.evidence} · ${item.privacyClass} · ${item.status}${missing}${size}${truncated}${fields ? ` · ${messages.diagnosticsArchiveFields ?? ''} ${fields}` : ''}`;
367
468
  evidence.append(row);
368
469
  }
470
+ const detail = document.createElement('details');
471
+ const detailLabel = document.createElement('summary');
472
+ detailLabel.textContent = messages.diagnosticsArchiveDetail ?? '';
473
+ detail.append(detailLabel, summary, evidence);
369
474
  const exclusions = document.createElement('p');
370
- exclusions.textContent = `${messages.diagnosticsArchiveExcluded ?? ''} ${(manifest.excludedClasses ?? []).join(', ')}`;
371
- diagnosticsManifest.append(summary, evidence, exclusions);
475
+ const neverCollected = (manifest.excludedClasses ?? []).map(
476
+ (excluded) => messages[NEVER_COLLECTED_LABELS[excluded]] ?? excluded,
477
+ );
478
+ exclusions.textContent = `${messages.diagnosticsArchiveExcluded ?? ''} ${neverCollected.join(', ')}.`;
479
+ diagnosticsManifest.append(detail, exclusions);
372
480
  const uncovered = (manifest.evidence ?? []).filter((item) => item.coversReproduction === false);
373
481
  if (uncovered.length > 0) {
374
482
  const gap = document.createElement('p');
@@ -385,25 +493,32 @@ function renderArchiveManifest(manifest) {
385
493
  diagnosticsManifest.hidden = false;
386
494
  }
387
495
 
388
- diagnosticsReview.addEventListener('click', async () => {
389
- diagnosticsReview.disabled = true;
496
+ /**
497
+ * The manifest for the session on screen, fetched once.
498
+ *
499
+ * A completed reproduction that the reporter is looking at is a file they came for, so the manifest is
500
+ * read without being asked for. It is read once per session rather than per render, because assembling it
501
+ * reads every collected log. Exporting spends it, so the session is forgotten again and a second download
502
+ * asks for a fresh one.
503
+ */
504
+ async function ensureArchiveReview(caseId) {
505
+ if (!caseId || diagnosticsReviewedCaseId === caseId || diagnosticsReviewPending) return;
506
+ diagnosticsReviewPending = true;
390
507
  try {
391
508
  const review = await requestWithinDeadline('/diagnostics/archive/review', undefined, 12000);
392
509
  diagnosticsReviewId = review.reviewId;
510
+ diagnosticsReviewedCaseId = caseId;
393
511
  renderArchiveManifest(review.manifest);
394
512
  diagnosticsReviewConfirmLabel.hidden = false;
395
513
  diagnosticsExport.hidden = false;
514
+ diagnosticsExport.disabled = !diagnosticsReviewConfirm.checked;
515
+ diagnosticsIssue.hidden = false;
396
516
  } catch {
397
- try {
398
- renderDiagnostics(await requestWithinDeadline('/diagnostics/status', undefined, 12000));
399
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
400
- } catch {
401
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
402
- }
517
+ diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
403
518
  } finally {
404
- diagnosticsReview.disabled = false;
519
+ diagnosticsReviewPending = false;
405
520
  }
406
- });
521
+ }
407
522
 
408
523
  diagnosticsReviewConfirm.addEventListener('change', () => {
409
524
  diagnosticsExport.disabled = !diagnosticsReviewConfirm.checked || !diagnosticsReviewId;
@@ -425,7 +540,10 @@ diagnosticsExport.addEventListener('click', async () => {
425
540
  download.click();
426
541
  document.body.removeChild(download);
427
542
  diagnosticsReviewId = '';
543
+ diagnosticsReviewedCaseId = '';
428
544
  diagnosticsReviewConfirm.checked = false;
545
+ diagnosticsArchiveDownloaded = true;
546
+ setIssueStepReachable(diagnosticsState.issueUrl ?? '');
429
547
  } catch {
430
548
  diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
431
549
  }
@@ -441,6 +559,7 @@ diagnosticsAuthorize.addEventListener('click', async () => {
441
559
  {
442
560
  profile: diagnosticsWizardState.profile,
443
561
  reproductionMode: diagnosticsWizardState.reproductionMode,
562
+ ...(diagnosticsWizardState.devices === undefined ? {} : { affectedDevices: diagnosticsWizardState.devices }),
444
563
  },
445
564
  12000,
446
565
  );
@@ -465,37 +584,34 @@ diagnosticsAuthorize.addEventListener('click', async () => {
465
584
  }
466
585
  });
467
586
 
468
- diagnosticsYes.addEventListener('click', () => {
469
- diagnosticsWizardState = diagnosticsWizard.answer(diagnosticsWizardState, true);
470
- renderDiagnosticsWizard();
471
- diagnosticsFrequencyHeading.focus?.();
472
- });
473
-
474
- diagnosticsNo.addEventListener('click', () => {
475
- diagnosticsWizardState = diagnosticsWizard.answer(diagnosticsWizardState, false);
476
- renderDiagnosticsWizard();
477
- if (diagnosticsWizardState.mode === 'frequency') diagnosticsFrequencyHeading.focus?.();
478
- else diagnosticsQuestionText.focus?.();
479
- });
587
+ for (const tile of diagnosticsTiles) {
588
+ tile.addEventListener('click', async () => {
589
+ diagnosticsWizardState = diagnosticsWizard.select(diagnosticsWizardState, tile.dataset.diagnosticsTile);
590
+ renderDiagnosticsWizard();
591
+ if (diagnosticsWizardState.mode === 'devices') await renderDiagnosticsDeviceList();
592
+ else diagnosticsFrequencyHeading.focus?.();
593
+ });
594
+ }
480
595
 
481
- diagnosticsDirect.addEventListener('click', () => {
482
- diagnosticsWizardState = diagnosticsWizard.direct();
483
- renderDiagnosticsWizard();
484
- diagnosticsProfile.focus?.();
596
+ diagnosticsDevicesEvery.addEventListener('click', () => {
597
+ const tiles = [...diagnosticsDeviceList.querySelectorAll('[data-serial]')];
598
+ if (tiles.length === 0) {
599
+ diagnosticsWizardState = diagnosticsWizard.chooseDevices(diagnosticsWizardState, 'all');
600
+ renderDiagnosticsWizard();
601
+ diagnosticsFrequencyHeading.focus?.();
602
+ return;
603
+ }
604
+ const clearing = chosenDiagnosticsDevices().length === tiles.length;
605
+ for (const tile of tiles) tile.setAttribute('aria-pressed', clearing ? 'false' : 'true');
606
+ renderDiagnosticsDeviceChoice();
485
607
  });
486
608
 
487
- diagnosticsDirectChoose.addEventListener('click', () => {
488
- diagnosticsWizardState = diagnosticsWizard.selectDirect(diagnosticsWizardState, diagnosticsProfile.value);
609
+ diagnosticsDevicesChosen.addEventListener('click', () => {
610
+ diagnosticsWizardState = diagnosticsWizard.chooseDevices(diagnosticsWizardState, chosenDiagnosticsDevices());
489
611
  renderDiagnosticsWizard();
490
612
  diagnosticsFrequencyHeading.focus?.();
491
613
  });
492
614
 
493
- diagnosticsDirectBack.addEventListener('click', () => {
494
- diagnosticsWizardState = diagnosticsWizard.start();
495
- renderDiagnosticsWizard();
496
- diagnosticsQuestionText.focus?.();
497
- });
498
-
499
615
  function chooseDiagnosticsReproductionMode(reproductionMode) {
500
616
  diagnosticsWizardState = diagnosticsWizard.chooseReproductionMode(diagnosticsWizardState, reproductionMode);
501
617
  renderDiagnosticsWizard();
@@ -508,15 +624,13 @@ diagnosticsFrequencyIntermittent.addEventListener('click', () => chooseDiagnosti
508
624
  diagnosticsFrequencyBack.addEventListener('click', () => {
509
625
  diagnosticsWizardState = diagnosticsWizard.backFromFrequency(diagnosticsWizardState);
510
626
  renderDiagnosticsWizard();
511
- if (diagnosticsWizardState.mode === 'direct') diagnosticsProfile.focus?.();
512
- else diagnosticsQuestionText.focus?.();
627
+ diagnosticsQuestionText.focus?.();
513
628
  });
514
629
 
515
630
  diagnosticsReject.addEventListener('click', () => {
516
631
  diagnosticsWizardState = diagnosticsWizard.reject(diagnosticsWizardState);
517
632
  renderDiagnosticsWizard();
518
- if (diagnosticsWizardState.mode === 'direct') diagnosticsProfile.focus?.();
519
- else diagnosticsQuestionText.focus?.();
633
+ diagnosticsQuestionText.focus?.();
520
634
  });
521
635
 
522
636
  diagnosticsStartAnother.addEventListener('click', () => {
@@ -618,7 +732,15 @@ mastheadDiagnostics.addEventListener('click', () => menuDiagnostics.click());
618
732
  dashboardDiagnose.addEventListener('click', () => menuDiagnostics.click());
619
733
 
620
734
  menuDiagnostics.addEventListener('click', async () => {
735
+ /*
736
+ * Opened from the sign-in screen, the area is not in question: what is going wrong is getting signed in. The
737
+ * answer is presumed rather than fixed, so the frequency step's way back still reaches the other six.
738
+ */
739
+ const signingIn = !setupContent.hidden;
621
740
  openDashboardPanel(diagnosticsPanel, menuDiagnostics);
741
+ if (signingIn) {
742
+ diagnosticsWizardState = diagnosticsWizard.select(diagnosticsWizard.start(), 'startup-authentication');
743
+ }
622
744
  // Drawn on opening, because the wizard's first question is only ever put on the page by this call.
623
745
  renderDiagnosticsWizard();
624
746
  try {
@@ -8,12 +8,62 @@
8
8
  .replaceAll("'", '&#039;');
9
9
  }
10
10
 
11
+ const EUFY_MODES = [
12
+ { value: 'away', label: 'Away' },
13
+ { value: 'home', label: 'Home' },
14
+ { value: 'schedule', label: 'Schedule' },
15
+ { value: 'custom1', label: 'Custom 1' },
16
+ { value: 'custom2', label: 'Custom 2' },
17
+ { value: 'custom3', label: 'Custom 3' },
18
+ { value: 'off', label: 'Off' },
19
+ { value: 'geo', label: 'Geofencing' },
20
+ { value: 'disarmed', label: 'Disarmed' },
21
+ ];
22
+ /** What each HomeKit state means where the household assigned nothing, as the adapter resolves it. */
23
+ const DEFAULT_ARMING_MODES = { home: 'home', away: 'away', off: 'disarmed' };
24
+ const ARMING_SLOTS = [
25
+ { slot: 'home', label: 'armingSlotHome' },
26
+ { slot: 'away', label: 'armingSlotAway' },
27
+ { slot: 'night', label: 'armingSlotNight' },
28
+ { slot: 'off', label: 'armingSlotOff' },
29
+ ];
30
+
31
+ /**
32
+ * The guard-mode map: four states HomeKit names against the nine a station reports, one pairing per row.
33
+ *
34
+ * Folded shut, because the four defaults are what nearly every household keeps and the panel has a camera's
35
+ * worth of settings above it. The summary carries the whole map as text, so the answer is readable without
36
+ * opening anything, and a household that assigned a state of its own finds it already open.
37
+ *
38
+ * A row is the whole control — the state names itself and the select beside it says what it means. An
39
+ * unassigned state shows a dash, which is what keeps Night out of HomeKit until it is given a mode.
40
+ */
41
+ function armingModeControl(device, assigned, messages) {
42
+ const summary = ARMING_SLOTS.map(
43
+ ({ slot, label }) => `${messages[label]} → ${EUFY_MODES.find((mode) => mode.value === assigned[slot])?.label ?? '—'}`,
44
+ ).join(' · ');
45
+ const rows = ARMING_SLOTS.map(({ slot, label }) => {
46
+ const value = assigned[slot] ?? '';
47
+ const id = `arming-${escapeHtml(device.serial)}-${slot}`;
48
+ const options = EUFY_MODES.map(
49
+ (mode) =>
50
+ `<option value="${mode.value}"${value === mode.value ? ' selected' : ''}>${escapeHtml(mode.label)}</option>`,
51
+ ).join('');
52
+ return `<tr><th scope="row"><label for="${id}">${escapeHtml(messages[label])}</label></th><td><select id="${id}" data-preference="armingModes" data-slot="${slot}" data-serial="${escapeHtml(device.serial)}" data-original="${escapeHtml(value)}"><option value=""${value === '' ? ' selected' : ''}>—</option>${options}</select></td></tr>`;
53
+ }).join('');
54
+ const assignedItself = ARMING_SLOTS.some(({ slot }) => assigned[slot] !== DEFAULT_ARMING_MODES[slot]);
55
+ return `<details class="arming-setting" data-setting${assignedItself ? ' open' : ''}${device.represented ? '' : ' hidden'} data-requires-representation><summary><span class="setting-label">${escapeHtml(messages.preferenceArmingModes)}</span><span class="arming-summary">${escapeHtml(summary)}</span></summary><table class="arming-table"><thead><tr><th scope="col">HomeKit</th><th scope="col">eufy</th></tr></thead><tbody>${rows}</tbody></table><p class="snapshot-help">${escapeHtml(messages.armingModesHelp)}</p></details>`;
56
+ }
57
+
11
58
  function preferenceControl(device, key, preference, messages) {
12
59
  const labels = {
13
60
  represented: messages.preferenceRepresented,
14
61
  audio: messages.preferenceAudio,
15
62
  snapshotMode: messages.preferenceSnapshotMode,
16
63
  };
64
+ if (key === 'armingModes') {
65
+ return armingModeControl(device, preference.armingModes, messages);
66
+ }
17
67
  const represented = preference.represented;
18
68
  const dependent = key === 'represented' ? '' : ` data-requires-representation${represented ? '' : ' hidden'}`;
19
69
  if (key === 'snapshotMode') {
@@ -29,7 +79,7 @@
29
79
 
30
80
  function markPendingPreference(control, key, value) {
31
81
  if (!control?.classList || control.dataset.original === undefined) return;
32
- const current = key === 'snapshotMode' ? String(value) : String(Boolean(value));
82
+ const current = key === 'snapshotMode' || key === 'armingModes' ? String(value) : String(Boolean(value));
33
83
  const setting = control.closest('[data-setting]');
34
84
  const changed = current !== control.dataset.original;
35
85
  if (key === 'snapshotMode') {
@@ -261,6 +311,10 @@
261
311
  const existing = getConfig();
262
312
  if (!existing || !serial || !key) return;
263
313
  const defaults = { represented: true, audio: true, snapshotMode: 'Refresh' };
314
+ if (key === 'armingModes') {
315
+ await saveArmingMode(control, serial, existing, saveConfig, getMessages, elements);
316
+ return;
317
+ }
264
318
  const value = key === 'snapshotMode' ? control.value : control.checked;
265
319
  markPendingPreference(control, key, value);
266
320
  if (key === 'snapshotMode') {
@@ -295,6 +349,30 @@
295
349
  });
296
350
  }
297
351
 
352
+ /**
353
+ * Records one HomeKit state's guard mode, keeping the map sparse: a state set back to the plugin's own choice
354
+ * is removed rather than pinned, so a later default reaches a household that never overrode it.
355
+ */
356
+ async function saveArmingMode(control, serial, existing, saveConfig, getMessages, elements) {
357
+ const slot = control.dataset.slot;
358
+ const value = control.value;
359
+ markPendingPreference(control, 'armingModes', value);
360
+ const entityPreferences = { ...(existing.entityPreferences ?? {}) };
361
+ const preference = { ...(entityPreferences[serial] ?? {}) };
362
+ const armingModes = { ...(preference.armingModes ?? {}) };
363
+ if (value === '') delete armingModes[slot];
364
+ else armingModes[slot] = value;
365
+ if (Object.keys(armingModes).length === 0) delete preference.armingModes;
366
+ else preference.armingModes = armingModes;
367
+ if (Object.keys(preference).length === 0) delete entityPreferences[serial];
368
+ else entityPreferences[serial] = preference;
369
+ try {
370
+ await saveConfig({ ...existing, entityPreferences });
371
+ } catch {
372
+ elements.summary.textContent = getMessages().preferenceSaveFailed;
373
+ }
374
+ }
375
+
298
376
  /**
299
377
  * Fills the settings page for one device, which is where its controls live rather than on the tile itself.
300
378
  *
@@ -313,6 +391,7 @@
313
391
  represented: preferences[device.serial]?.represented ?? true,
314
392
  audio: preferences[device.serial]?.audio ?? true,
315
393
  snapshotMode: preferences[device.serial]?.snapshotMode ?? 'Refresh',
394
+ armingModes: { ...DEFAULT_ARMING_MODES, ...preferences[device.serial]?.armingModes },
316
395
  };
317
396
  const controls = device.preferences
318
397
  .map((key) => preferenceControl(device, key, preference, messages))