@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.4 → 5.0.0-beta.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CONTRIBUTING.md +9 -17
  2. package/config.schema.json +60 -0
  3. package/dist/account/ownership.js +16 -16
  4. package/dist/account/ownership.js.map +1 -1
  5. package/dist/account/persistence.js +24 -74
  6. package/dist/account/persistence.js.map +1 -1
  7. package/dist/account/temporary-authentication.js +5 -3
  8. package/dist/account/temporary-authentication.js.map +1 -1
  9. package/dist/configuration.js +50 -13
  10. package/dist/configuration.js.map +1 -1
  11. package/dist/device/member-evidence.js +11 -4
  12. package/dist/device/member-evidence.js.map +1 -1
  13. package/dist/device/member-trust.js +14 -32
  14. package/dist/device/member-trust.js.map +1 -1
  15. package/dist/device/snapshot.js +6 -18
  16. package/dist/device/snapshot.js.map +1 -1
  17. package/dist/diagnostics.js +951 -334
  18. package/dist/diagnostics.js.map +1 -1
  19. package/dist/homekit/adapter.js +4 -1
  20. package/dist/homekit/adapter.js.map +1 -1
  21. package/dist/homekit/adapters/battery.js +3 -3
  22. package/dist/homekit/adapters/battery.js.map +1 -1
  23. package/dist/homekit/adapters/camera-controls.js +15 -37
  24. package/dist/homekit/adapters/camera-controls.js.map +1 -1
  25. package/dist/homekit/adapters/camera-streaming.js +74 -48
  26. package/dist/homekit/adapters/camera-streaming.js.map +1 -1
  27. package/dist/homekit/adapters/contact.js +5 -5
  28. package/dist/homekit/adapters/contact.js.map +1 -1
  29. package/dist/homekit/adapters/doorbell.js +3 -15
  30. package/dist/homekit/adapters/doorbell.js.map +1 -1
  31. package/dist/homekit/adapters/lock.js +72 -221
  32. package/dist/homekit/adapters/lock.js.map +1 -1
  33. package/dist/homekit/adapters/motion.js +3 -3
  34. package/dist/homekit/adapters/motion.js.map +1 -1
  35. package/dist/homekit/adapters/package-sensor.js +63 -0
  36. package/dist/homekit/adapters/package-sensor.js.map +1 -0
  37. package/dist/homekit/adapters/registry.js +2 -0
  38. package/dist/homekit/adapters/registry.js.map +1 -1
  39. package/dist/homekit/adapters/security-system.js +106 -250
  40. package/dist/homekit/adapters/security-system.js.map +1 -1
  41. package/dist/homekit/adapters/siren.js +5 -33
  42. package/dist/homekit/adapters/siren.js.map +1 -1
  43. package/dist/homekit/adapters/smart-light.js +19 -217
  44. package/dist/homekit/adapters/smart-light.js.map +1 -1
  45. package/dist/homekit/coverage-matrix.js +8 -67
  46. package/dist/homekit/coverage-matrix.js.map +1 -1
  47. package/dist/homekit/device-control.js +249 -13
  48. package/dist/homekit/device-control.js.map +1 -1
  49. package/dist/homekit/reconciler.js +25 -9
  50. package/dist/homekit/reconciler.js.map +1 -1
  51. package/dist/media/last-successful-image.js +8 -23
  52. package/dist/media/last-successful-image.js.map +1 -1
  53. package/dist/media/live-preview.js +217 -0
  54. package/dist/media/live-preview.js.map +1 -0
  55. package/dist/media/live-stream.js +689 -160
  56. package/dist/media/live-stream.js.map +1 -1
  57. package/dist/media/recording.js +41 -118
  58. package/dist/media/recording.js.map +1 -1
  59. package/dist/media/snapshot.js +22 -17
  60. package/dist/media/snapshot.js.map +1 -1
  61. package/dist/media/station-live-sessions.js +40 -51
  62. package/dist/media/station-live-sessions.js.map +1 -1
  63. package/dist/platform.js +61 -10
  64. package/dist/platform.js.map +1 -1
  65. package/dist/runtime/channel.js +227 -40
  66. package/dist/runtime/channel.js.map +1 -1
  67. package/dist/runtime/device-settings.js +88 -0
  68. package/dist/runtime/device-settings.js.map +1 -0
  69. package/dist/runtime/owner.js +30 -5
  70. package/dist/runtime/owner.js.map +1 -1
  71. package/dist/runtime/sdk-client.js +21 -17
  72. package/dist/runtime/sdk-client.js.map +1 -1
  73. package/dist/runtime/tracker.js +8 -38
  74. package/dist/runtime/tracker.js.map +1 -1
  75. package/dist/storage.js +1 -1
  76. package/dist/storage.js.map +1 -1
  77. package/dist/ui/dashboard.js +36 -16
  78. package/dist/ui/dashboard.js.map +1 -1
  79. package/dist/ui/runtime-channel-client.js +155 -184
  80. package/dist/ui/runtime-channel-client.js.map +1 -1
  81. package/dist/ui/server.js +81 -38
  82. package/dist/ui/server.js.map +1 -1
  83. package/homebridge-ui/public/app.css +531 -239
  84. package/homebridge-ui/public/assets/icons/auto_fix_high.svg +1 -0
  85. package/homebridge-ui/public/assets/icons/battery_0.svg +1 -0
  86. package/homebridge-ui/public/assets/icons/battery_1.svg +1 -0
  87. package/homebridge-ui/public/assets/icons/battery_2.svg +1 -0
  88. package/homebridge-ui/public/assets/icons/battery_3.svg +1 -0
  89. package/homebridge-ui/public/assets/icons/battery_4.svg +1 -0
  90. package/homebridge-ui/public/assets/icons/battery_5.svg +1 -0
  91. package/homebridge-ui/public/assets/icons/battery_6.svg +1 -0
  92. package/homebridge-ui/public/assets/icons/battery_full.svg +1 -0
  93. package/homebridge-ui/public/assets/icons/contact_support.svg +1 -0
  94. package/homebridge-ui/public/assets/icons/delete.svg +1 -0
  95. package/homebridge-ui/public/assets/icons/download.svg +1 -0
  96. package/homebridge-ui/public/assets/icons/extension_off.svg +1 -0
  97. package/homebridge-ui/public/assets/icons/info.svg +1 -0
  98. package/homebridge-ui/public/assets/icons/key_off.svg +1 -0
  99. package/homebridge-ui/public/assets/icons/keyboard_arrow_left.svg +1 -0
  100. package/homebridge-ui/public/assets/icons/keyboard_arrow_right.svg +1 -0
  101. package/homebridge-ui/public/assets/icons/keyboard_double_arrow_left.svg +1 -0
  102. package/homebridge-ui/public/assets/icons/keyboard_double_arrow_right.svg +1 -0
  103. package/homebridge-ui/public/assets/icons/movie_off.svg +1 -0
  104. package/homebridge-ui/public/assets/icons/play_arrow.svg +1 -0
  105. package/homebridge-ui/public/assets/icons/refresh.svg +1 -0
  106. package/homebridge-ui/public/assets/icons/satellite_alt.svg +1 -0
  107. package/homebridge-ui/public/assets/icons/search_off.svg +1 -0
  108. package/homebridge-ui/public/assets/icons/sensors_off.svg +1 -0
  109. package/homebridge-ui/public/assets/icons/settings_photo_camera.svg +1 -0
  110. package/homebridge-ui/public/assets/icons/solar_power.svg +1 -0
  111. package/homebridge-ui/public/assets/icons/stop.svg +1 -0
  112. package/homebridge-ui/public/assets/icons/videocam_off.svg +1 -0
  113. package/homebridge-ui/public/i18n/en.json +160 -82
  114. package/homebridge-ui/public/i18n/fr.json +161 -83
  115. package/homebridge-ui/public/index.html +225 -96
  116. package/homebridge-ui/public/js/app.js +814 -435
  117. package/homebridge-ui/public/js/dashboard.js +148 -31
  118. package/i18n/runtime/en.json +10 -3
  119. package/package.json +10 -11
  120. package/homebridge-ui/public/js/legacy-settings.js +0 -37
  121. package/homebridge-ui/public/js/profile-wizard.js +0 -61
@@ -19,9 +19,15 @@ const challengeSubmit = document.querySelector('[data-challenge-submit]');
19
19
  const dashboard = document.querySelector('[data-dashboard]');
20
20
  const dashboardState = document.querySelector('[data-dashboard-state]');
21
21
  const dashboardTitle = document.querySelector('[data-dashboard-title]');
22
- const dashboardBadge = document.querySelector('[data-dashboard-badge]');
23
22
  const dashboardSummary = document.querySelector('[data-dashboard-summary]');
24
23
  const dashboardAuthenticate = document.querySelector('[data-dashboard-authenticate]');
24
+ const reauthDialog = document.querySelector('[data-reauth-dialog]');
25
+ const reauthAction = document.querySelector('[data-reauth-action]');
26
+ const attentionDialog = document.querySelector('[data-attention-dialog]');
27
+ const attentionTitle = document.querySelector('[data-attention-title]');
28
+ const attentionSummary = document.querySelector('[data-attention-summary]');
29
+ const attentionDiagnose = document.querySelector('[data-attention-diagnose]');
30
+ const attentionChoose = document.querySelector('[data-attention-choose]');
25
31
  const deviceGroups = document.querySelector('[data-device-groups]');
26
32
  const updatePending = document.querySelector('[data-update-pending]');
27
33
  const updatePendingSummary = document.querySelector('[data-update-pending-summary]');
@@ -31,48 +37,34 @@ const pageTitle = document.querySelector('[data-page-title]');
31
37
  const legacyNotice = document.querySelector('[data-legacy-notice]');
32
38
  const legacySettings = document.querySelector('[data-legacy-settings]');
33
39
  const legacyAcknowledge = document.querySelector('[data-legacy-acknowledge]');
40
+ const legacyStatus = document.querySelector('[data-legacy-status]');
34
41
  const menuDiagnostics = document.querySelector('[data-menu-diagnostics]');
35
42
  const menuAdvanced = document.querySelector('[data-menu-advanced]');
36
43
  const diagnosticsPanel = document.querySelector('[data-diagnostics]');
37
44
  const mastheadDiagnostics = document.querySelector('[data-masthead-diagnostics]');
38
45
  const diagnosticsClose = document.querySelector('[data-diagnostics-close]');
39
46
  const diagnosticsWizardPanel = document.querySelector('[data-diagnostics-wizard]');
40
- const diagnosticsQuestion = document.querySelector('[data-diagnostics-question]');
41
47
  const diagnosticsQuestionText = document.querySelector('[data-diagnostics-question-text]');
42
48
  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]');
47
- const diagnosticsFrequency = document.querySelector('[data-diagnostics-frequency]');
48
- const diagnosticsFrequencyHeading = document.querySelector('#diagnostics-frequency-heading');
49
- const diagnosticsFrequencyNow = document.querySelector('[data-diagnostics-frequency-answer="now"]');
50
- const diagnosticsFrequencyIntermittent = document.querySelector('[data-diagnostics-frequency-answer="intermittent"]');
51
- const diagnosticsFrequencyBack = document.querySelector('[data-diagnostics-frequency-back]');
52
- const diagnosticsMatch = document.querySelector('[data-diagnostics-match]');
53
- const diagnosticsReject = document.querySelector('[data-diagnostics-reject]');
54
- const diagnosticsAuthorize = document.querySelector('[data-diagnostics-authorize]');
49
+ const diagnosticsCancel = document.querySelector('[data-diagnostics-cancel]');
55
50
  const diagnosticsReproduction = document.querySelector('[data-diagnostics-reproduction]');
56
51
  const diagnosticsStatus = document.querySelector('[data-diagnostics-status]');
57
52
  const diagnosticsIssue = document.querySelector('[data-diagnostics-issue]');
58
- const diagnosticsIssueHint = document.querySelector('[data-diagnostics-issue-hint]');
53
+ const diagnosticsExistingIssue = document.querySelector('[data-diagnostics-existing-issue]');
59
54
  const diagnosticsResult = document.querySelector('[data-diagnostics-result]');
60
55
  const diagnosticsActions = document.querySelector('[data-diagnostics-actions]');
61
- const diagnosticsGuidanceTitle = document.querySelector('[data-diagnostics-guidance-title]');
62
- const diagnosticsModeSummary = document.querySelector('[data-diagnostics-mode-summary]');
63
56
  const diagnosticsGuidance = document.querySelector('[data-diagnostics-guidance]');
64
57
  const diagnosticsPhaseTitle = document.querySelector('[data-diagnostics-phase-title]');
65
- const diagnosticsGuidanceBeforeSection = document.querySelector('[data-diagnostics-guidance-before-section]');
66
- const diagnosticsGuidanceBefore = document.querySelector('[data-diagnostics-guidance-before]');
67
58
  const diagnosticsGuidanceAction = document.querySelector('[data-diagnostics-guidance-action]');
68
- const diagnosticsManifest = document.querySelector('[data-diagnostics-manifest]');
69
- const diagnosticsReviewConfirm = document.querySelector('[data-diagnostics-review-confirm]');
70
- const diagnosticsReviewConfirmLabel = document.querySelector('[data-diagnostics-review-confirm-label]');
59
+ const diagnosticsCaptureNote = document.querySelector('[data-diagnostics-capture-note]');
60
+ const diagnosticsHandoff = document.querySelector('[data-diagnostics-handoff]');
61
+ const diagnosticsHandoffNote = document.querySelector('[data-diagnostics-handoff-note]');
62
+ const diagnosticsResultStatus = document.querySelector('[data-diagnostics-result-status]');
71
63
  const diagnosticsExport = document.querySelector('[data-diagnostics-export]');
72
64
  const diagnosticsResultHeading = document.querySelector('[data-diagnostics-result-heading]');
73
65
  const diagnosticsStartAnother = document.querySelector('[data-diagnostics-start-another]');
66
+ const diagnosticsDownloadAgain = document.querySelector('[data-diagnostics-download-again]');
74
67
  const advancedPanel = document.querySelector('[data-advanced-settings]');
75
- const dashboardDiagnose = document.querySelector('[data-dashboard-diagnose]');
76
68
  const devicePanel = document.querySelector('[data-device-settings]');
77
69
  const deviceClose = document.querySelector('[data-device-close]');
78
70
  const deviceSettingsTitle = document.querySelector('[data-device-settings-title]');
@@ -82,6 +74,11 @@ const advancedClose = document.querySelector('[data-advanced-close]');
82
74
  const advancedPolling = document.querySelector('[data-advanced-polling]');
83
75
  const advancedConcurrentMedia = document.querySelector('[data-advanced-concurrent-media]');
84
76
  const advancedFfmpeg = document.querySelector('[data-advanced-ffmpeg]');
77
+ const advancedSmallVideoPackets = document.querySelector('[data-advanced-small-video-packets]');
78
+ const advancedLiveVideoPassthrough = document.querySelector('[data-advanced-live-video-passthrough]');
79
+ const advancedHardwareVideoEncoder = document.querySelector('[data-advanced-hardware-video-encoder]');
80
+ const advancedHdLiveVideo = document.querySelector('[data-advanced-hd-live-video]');
81
+ const advancedIgnoreHomeBitRate = document.querySelector('[data-advanced-ignore-home-bit-rate]');
85
82
  const warmUpAvailable = document.querySelector('[data-warm-up-available]');
86
83
  const warmUpChosen = document.querySelector('[data-warm-up-chosen]');
87
84
  const warmUpAdd = document.querySelector('[data-warm-up-add]');
@@ -89,6 +86,34 @@ const warmUpAddAll = document.querySelector('[data-warm-up-add-all]');
89
86
  const warmUpRemove = document.querySelector('[data-warm-up-remove]');
90
87
  const warmUpRemoveAll = document.querySelector('[data-warm-up-remove-all]');
91
88
  const advancedStatus = document.querySelector('[data-advanced-status]');
89
+ const menuWizard = document.querySelector('[data-menu-wizard]');
90
+ const wizardPanel = document.querySelector('[data-setup-wizard]');
91
+ const wizardClose = document.querySelector('[data-wizard-close]');
92
+ const wizardProgress = document.querySelector('[data-wizard-progress]');
93
+ const wizardTitle = document.querySelector('[data-wizard-title]');
94
+ const wizardEyebrow = document.querySelector('[data-wizard-eyebrow]');
95
+ const wizardRestart = document.querySelector('[data-wizard-restart]');
96
+ const wizardLive = document.querySelector('[data-wizard-live]');
97
+ const wizardLiveImage = document.querySelector('[data-wizard-live-image]');
98
+ const wizardLiveHomeKit = document.querySelector('[data-wizard-live-homekit]');
99
+ const wizardLiveCamera = document.querySelector('[data-wizard-live-camera]');
100
+ const wizardLiveStatus = document.querySelector('[data-wizard-live-status]');
101
+ const wizardLivePlay = document.querySelector('[data-wizard-live-play]');
102
+ const wizardLiveStop = document.querySelector('[data-wizard-live-stop]');
103
+ const wizardLiveSpinner = document.querySelector('[data-wizard-live-spinner]');
104
+ const wizardLiveHomeKitSpinner = document.querySelector('[data-wizard-live-homekit-spinner]');
105
+ const wizardLiveControls = document.querySelector('[data-wizard-live-controls]');
106
+ const wizardLiveSave = document.querySelector('[data-wizard-live-save]');
107
+ const wizardLiveEncoder = document.querySelector('[data-wizard-live-encoder]');
108
+ const wizardLiveDiagnose = document.querySelector('[data-wizard-live-diagnose]');
109
+ const wizardLiveDiagnoseStart = document.querySelector('[data-wizard-live-diagnose-start]');
110
+ const wizardSettings = document.querySelector('[data-wizard-settings]');
111
+ const wizardControls = document.querySelector('[data-wizard-controls]');
112
+ const wizardFinish = document.querySelector('[data-wizard-finish]');
113
+ const wizardMap = document.querySelector('[data-wizard-map]');
114
+ const wizardStage = document.querySelector('[data-wizard-stage]');
115
+ const wizardHomeKit = document.querySelector('[data-wizard-homekit]');
116
+ const wizardEufy = document.querySelector('[data-wizard-eufy]');
92
117
 
93
118
  let messages = {};
94
119
  let pluginConfig = [];
@@ -100,28 +125,26 @@ let legacyAcknowledged = false;
100
125
  let diagnosticsState = { status: 'inactive', missingEvidence: [] };
101
126
  /** Whether the reporter already downloaded this session's archive, which is what the report offer follows. */
102
127
  let diagnosticsArchiveDownloaded = false;
103
- /** The session whose manifest is on screen, so it is fetched once rather than on every render. */
128
+ /** The session whose archive is reviewed and ready to export, so the review is fetched once rather than per render. */
104
129
  let diagnosticsReviewedCaseId = '';
105
- /** Guards the one fetch, so a burst of renders does not assemble the manifest several times over. */
106
- let diagnosticsReviewPending = false;
130
+ /** The one fetch in flight, so a burst of renders does not assemble the archive several times over. */
131
+ let diagnosticsReviewRequest;
107
132
  let diagnosticsReviewId = '';
108
- let diagnosticsStartingAnother = false;
109
133
  let panelReturn;
134
+ /** The problem the dashboard last drew, which the dashboard's diagnostics action points at while there is one. */
135
+ let dashboardAttention;
110
136
  let dashboardPanelTrigger;
111
137
  const dashboardView = window.HomebridgeEufyDashboard;
112
- const legacySettingsView = window.HomebridgeEufyLegacySettings;
113
- const diagnosticsWizard = window.HomebridgeEufyDiagnosticsWizard;
114
- let diagnosticsWizardState = diagnosticsWizard.start();
115
138
  const dashboardElements = {
116
139
  dashboard,
117
140
  title: dashboardTitle,
118
- diagnose: dashboardDiagnose,
119
141
  summary: dashboardSummary,
120
142
  authenticate: dashboardAuthenticate,
121
143
  groups: deviceGroups,
122
144
  deviceSettingsTitle,
123
145
  deviceSettingsEyebrow,
124
146
  deviceSettingsControls,
147
+ preferenceContainers: [deviceSettingsControls, wizardControls],
125
148
  updatePending,
126
149
  updatePendingSummary,
127
150
  updatePendingVersion,
@@ -131,16 +154,23 @@ const dashboardElements = {
131
154
  masthead,
132
155
  };
133
156
 
157
+ const REQUEST_TIMED_OUT = 'Request timed out';
158
+
159
+ /**
160
+ * A request the page stops waiting for after `timeoutMs`. The timeout error carries the request as `late`, since
161
+ * the plugin may still carry it out after the page gave up.
162
+ */
134
163
  function requestWithinDeadline(path, body, timeoutMs = 320000) {
135
164
  let timer;
165
+ const request = homebridge.request(path, body);
136
166
  const timeout = new Promise((_, reject) => {
137
- timer = setTimeout(() => reject(new Error('Request timed out')), timeoutMs);
167
+ timer = setTimeout(() => reject(Object.assign(new Error(REQUEST_TIMED_OUT), { late: request })), timeoutMs);
138
168
  });
139
- return Promise.race([homebridge.request(path, body), timeout]).finally(() => clearTimeout(timer));
169
+ return Promise.race([request, timeout]).finally(() => clearTimeout(timer));
140
170
  }
141
171
 
142
172
  function isDashboardUiReproducing(state = diagnosticsState) {
143
- return diagnosticsWizard.backgroundActive(state);
173
+ return state.status === 'reproducing' && state.profile === 'dashboard-ui';
144
174
  }
145
175
 
146
176
  async function recordActiveUiEvent(event) {
@@ -152,6 +182,11 @@ function recordActiveUiEventBestEffort(event) {
152
182
  void recordActiveUiEvent(event).catch(() => undefined);
153
183
  }
154
184
 
185
+ /** A country code that is not two letters is refused where it is typed, in the page's own words. */
186
+ countryInput.addEventListener('input', () => {
187
+ countryInput.setCustomValidity(countryInput.validity.patternMismatch ? (messages.countryInvalid ?? '') : '');
188
+ });
189
+
155
190
  acknowledgement.addEventListener('change', () => {
156
191
  continueButton.disabled = !acknowledgement.checked;
157
192
  });
@@ -210,125 +245,29 @@ function setBusy(busy) {
210
245
  }
211
246
 
212
247
  const diagnosticsProfiles = {
213
- 'startup-authentication': {
214
- title: 'diagnosticsProfileStartup',
215
- summary: 'diagnosticsStartupSummary',
216
- before: 'diagnosticsStartupBefore',
217
- action: 'diagnosticsStartupAction',
218
- },
219
- 'device-representation': {
220
- title: 'diagnosticsProfileDevices',
221
- summary: 'diagnosticsDevicesSummary',
222
- before: 'diagnosticsDevicesBefore',
223
- action: 'diagnosticsDevicesAction',
224
- },
225
- 'control-state': {
226
- title: 'diagnosticsProfileControl',
227
- summary: 'diagnosticsControlSummary',
228
- before: 'diagnosticsControlBefore',
229
- action: 'diagnosticsControlAction',
230
- },
231
- 'live-media': {
232
- title: 'diagnosticsProfileLiveMedia',
233
- summary: 'diagnosticsLiveSummary',
234
- before: 'diagnosticsLiveBefore',
235
- action: 'diagnosticsLiveAction',
236
- },
237
- 'hksv-recording': {
238
- title: 'diagnosticsProfileRecording',
239
- summary: 'diagnosticsRecordingSummary',
240
- before: 'diagnosticsRecordingBefore',
241
- action: 'diagnosticsRecordingAction',
242
- },
243
- 'dashboard-ui': {
244
- title: 'diagnosticsProfileDashboard',
245
- summary: 'diagnosticsDashboardSummary',
246
- before: 'diagnosticsDashboardBefore',
247
- action: 'diagnosticsDashboardAction',
248
- },
249
- other: {
250
- title: 'diagnosticsProfileOther',
251
- summary: 'diagnosticsOtherSummary',
252
- before: 'diagnosticsOtherBefore',
253
- action: 'diagnosticsOtherAction',
254
- },
248
+ 'startup-authentication': { title: 'diagnosticsProfileStartup', action: 'diagnosticsStartupAction' },
249
+ 'device-representation': { title: 'diagnosticsProfileDevices', action: 'diagnosticsDevicesAction' },
250
+ 'control-state': { title: 'diagnosticsProfileControl', action: 'diagnosticsControlAction' },
251
+ 'live-media': { title: 'diagnosticsProfileLiveMedia', action: 'diagnosticsLiveAction' },
252
+ 'hksv-recording': { title: 'diagnosticsProfileRecording', action: 'diagnosticsRecordingAction' },
253
+ 'dashboard-ui': { title: 'diagnosticsProfileDashboard', action: 'diagnosticsDashboardAction' },
254
+ other: { title: 'diagnosticsProfileOther', action: 'diagnosticsOtherAction' },
255
255
  };
256
256
 
257
- function renderDiagnosticsGuidance(profile) {
258
- const guidance = diagnosticsProfiles[profile] ?? diagnosticsProfiles.other;
259
- diagnosticsGuidanceTitle.textContent = messages[guidance.title] ?? '';
260
- diagnosticsPhaseTitle.textContent = messages[guidance.title] ?? '';
261
- diagnosticsGuidanceBefore.textContent = messages[guidance.before] ?? '';
262
- diagnosticsGuidanceAction.textContent = messages[guidance.action] ?? '';
263
- }
264
-
265
257
  /**
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
- * The list is asked for when it is empty rather than relying on the devices view having been opened first, and
273
- * a request that goes unanswered leaves the step able to say every device, which is a complete answer.
258
+ * Which screen a session puts on the panel: its file while one can be downloaded, its capture while one runs, and
259
+ * otherwise the question, since picking an area replaces the session with a capture of that area.
274
260
  */
275
- async function renderDiagnosticsDeviceList() {
276
- if (dashboardDevices.length === 0) {
277
- try {
278
- const snapshot = await requestWithinDeadline('/dashboard', { representationPreferences: {} }, 12000);
279
- dashboardDevices = snapshot.devices ?? [];
280
- } catch {
281
- dashboardDevices = [];
282
- }
283
- }
284
- diagnosticsDeviceList.replaceChildren();
285
- for (const device of dashboardDevices) {
286
- const tile = document.createElement('button');
287
- tile.type = 'button';
288
- tile.dataset.serial = device.serial;
289
- tile.setAttribute('aria-pressed', 'false');
290
- const art = document.createElement('img');
291
- art.alt = '';
292
- if (device.artwork) {
293
- art.className = 'device-photo';
294
- art.loading = 'lazy';
295
- art.src = device.artwork;
296
- } else {
297
- art.src = 'assets/icons/inventory.svg';
298
- }
299
- const name = document.createElement('span');
300
- name.textContent = device.name;
301
- tile.append(art, name);
302
- tile.addEventListener('click', () => {
303
- tile.setAttribute('aria-pressed', tile.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
304
- diagnosticsDevicesChosen.disabled = chosenDiagnosticsDevices().length === 0;
305
- });
306
- diagnosticsDeviceList.append(tile);
307
- }
308
- diagnosticsDevicesChosen.disabled = true;
261
+ function diagnosticsScreen(session) {
262
+ if (session.partialExportAvailable) return 'review';
263
+ if (session.status === 'reproducing') return 'reproduce';
264
+ return 'choose';
309
265
  }
310
266
 
311
- /** The serials pressed in the device step, in the order the grid drew them. */
312
- function chosenDiagnosticsDevices() {
313
- return [...diagnosticsDeviceList.querySelectorAll('[data-serial]')]
314
- .filter((tile) => tile.getAttribute('aria-pressed') === 'true')
315
- .map((tile) => tile.dataset.serial);
316
- }
317
-
318
- function renderDiagnosticsWizard() {
319
- diagnosticsQuestion.hidden = diagnosticsWizardState.mode !== 'tiles';
320
- diagnosticsDevices.hidden = diagnosticsWizardState.mode !== 'devices';
321
- diagnosticsFrequency.hidden = diagnosticsWizardState.mode !== 'frequency';
322
- diagnosticsMatch.hidden = diagnosticsWizardState.mode !== 'match';
323
- if (diagnosticsWizardState.mode === 'match') {
324
- renderDiagnosticsGuidance(diagnosticsWizardState.profile);
325
- diagnosticsModeSummary.textContent =
326
- messages[
327
- diagnosticsWizardState.reproductionMode === 'intermittent'
328
- ? 'diagnosticsModeIntermittentSummary'
329
- : 'diagnosticsModeNowSummary'
330
- ] ?? '';
331
- }
267
+ function renderDiagnosticsGuidance(profile) {
268
+ const guidance = diagnosticsProfiles[profile] ?? diagnosticsProfiles.other;
269
+ diagnosticsPhaseTitle.textContent = messages[guidance.title] ?? '';
270
+ diagnosticsGuidanceAction.textContent = messages[guidance.action] ?? '';
332
271
  }
333
272
 
334
273
  /**
@@ -340,175 +279,126 @@ function renderDiagnosticsWizard() {
340
279
  */
341
280
  function setIssueStepReachable(url) {
342
281
  diagnosticsIssue.href = url;
343
- diagnosticsIssue.setAttribute('aria-disabled', url ? 'false' : 'true');
344
- diagnosticsIssueHint.textContent = messages[url ? 'diagnosticsIssueOpensTab' : 'diagnosticsIssueNeedsArchive'] ?? '';
282
+ diagnosticsExistingIssue.href = url.replace(/\/issues\/new.*$/, '/issues?q=is%3Aissue+involves%3A%40me');
283
+ }
284
+
285
+ /**
286
+ * Leaves the archive dialog once its file is handed over, which is what ends the session: the plugin keeps it until
287
+ * then so a download that never arrived can be tried again.
288
+ */
289
+ async function endDiagnosticsCase() {
290
+ try {
291
+ renderDiagnostics(await requestWithinDeadline('/diagnostics/cancel', undefined, 12000));
292
+ } catch (error) {
293
+ await redrawDiagnosticsAfterFailure('inactive', error);
294
+ }
295
+ diagnosticsQuestionText.focus?.();
345
296
  }
346
297
 
347
298
  function renderDiagnostics(state) {
348
299
  if (state.supportCaseId !== diagnosticsState.supportCaseId) diagnosticsArchiveDownloaded = false;
349
300
  diagnosticsState = state;
350
- const screen = diagnosticsWizard.screen(state, diagnosticsStartingAnother);
301
+ const screen = diagnosticsScreen(state);
351
302
  const choosing = screen === 'choose';
352
303
  const reviewing = screen === 'review';
353
- const reproductionMode = state.reproductionMode ?? 'now';
354
- const collecting = isDashboardUiReproducing(state);
355
- for (const entry of [mastheadDiagnostics, menuDiagnostics]) {
356
- if (collecting) entry.dataset.collecting = 'true';
357
- else delete entry.dataset.collecting;
358
- entry.setAttribute(
359
- 'aria-label',
360
- messages[collecting ? 'diagnosticsCollectingFinishHere' : 'menuDiagnostics'] ?? '',
361
- );
362
- }
304
+ renderDiagnosticsEntries();
363
305
  diagnosticsWizardPanel.hidden = !choosing;
364
306
  diagnosticsGuidance.hidden = screen !== 'reproduce';
365
- diagnosticsGuidanceBeforeSection.hidden = state.status === 'reproducing';
366
307
  diagnosticsActions.hidden = screen !== 'reproduce';
367
- diagnosticsReproduction.disabled = !['authorized', 'reproducing'].includes(state.status);
368
- diagnosticsReproduction.textContent =
369
- reproductionMode === 'intermittent'
370
- ? state.status === 'reproducing'
371
- ? messages.diagnosticsIntermittentIssueHappened
372
- : messages.diagnosticsIntermittentStartWaiting
373
- : state.status === 'reproducing'
374
- ? messages.diagnosticsNowFinish
375
- : messages.diagnosticsStartRecording;
376
- diagnosticsIssue.hidden = true;
308
+ diagnosticsReproduction.disabled = state.status !== 'reproducing';
377
309
  setIssueStepReachable(diagnosticsArchiveDownloaded ? (state.issueUrl ?? '') : '');
378
- diagnosticsResult.hidden = !reviewing;
310
+ const offering = (reviewing || diagnosticsArchiveDownloaded) && !diagnosticsPanel.hidden;
311
+ if (offering && !diagnosticsResult.open) diagnosticsResult.showModal?.();
312
+ if (!offering && diagnosticsResult.open) diagnosticsResult.close?.();
313
+ diagnosticsResultHeading.textContent =
314
+ messages[
315
+ diagnosticsArchiveDownloaded
316
+ ? 'diagnosticsArchiveDownloaded'
317
+ : state.reproductionEndedAt && state.reproductionEndedAt === state.expiresAt
318
+ ? 'diagnosticsCaptureTimedOut'
319
+ : 'diagnosticsEvidenceReady'
320
+ ] ?? '';
379
321
  const reviewed = reviewing && diagnosticsReviewedCaseId === (state.supportCaseId ?? '');
380
- diagnosticsManifest.hidden = !reviewed;
381
- diagnosticsReviewConfirmLabel.hidden = !reviewed;
382
- diagnosticsExport.hidden = !reviewed;
383
- diagnosticsIssue.hidden = !reviewed;
322
+ diagnosticsExport.hidden = !reviewed || diagnosticsArchiveDownloaded;
323
+ diagnosticsHandoff.hidden = !diagnosticsArchiveDownloaded;
324
+ diagnosticsHandoffNote.hidden = !diagnosticsArchiveDownloaded;
325
+ if (!offering) diagnosticsResultStatus.textContent = '';
326
+ else if (!diagnosticsArchiveDownloaded && state.missingEvidence?.length) {
327
+ diagnosticsResultStatus.textContent = messages.diagnosticsMissingEvidence ?? '';
328
+ }
329
+ diagnosticsStartAnother.hidden = !diagnosticsArchiveDownloaded;
330
+ diagnosticsDownloadAgain.hidden = !diagnosticsArchiveDownloaded;
384
331
  if (!reviewed) {
385
- diagnosticsReviewConfirm.checked = false;
386
332
  diagnosticsExport.disabled = true;
387
333
  diagnosticsReviewId = '';
388
- if (reviewing) void ensureArchiveReview(state.supportCaseId ?? '');
334
+ if (reviewing && !diagnosticsArchiveDownloaded) void ensureArchiveReview(state.supportCaseId ?? '');
389
335
  }
390
336
  if (screen === 'reproduce') {
391
337
  renderDiagnosticsGuidance(state.profile);
392
- if (reproductionMode === 'intermittent') {
393
- diagnosticsGuidanceBeforeSection.hidden = true;
394
- diagnosticsGuidanceAction.textContent =
395
- messages[
396
- state.status === 'reproducing'
397
- ? 'diagnosticsIntermittentWaitingGuidance'
398
- : 'diagnosticsIntermittentReadyGuidance'
399
- ] ?? '';
400
- }
338
+ const stopsAt = new Date(state.expiresAt).toLocaleString(shell.lang || undefined, {
339
+ weekday: 'long',
340
+ hour: 'numeric',
341
+ minute: '2-digit',
342
+ });
343
+ diagnosticsCaptureNote.textContent = (messages.diagnosticsCaptureKeepsRunning ?? '').replace('{time}', stopsAt);
401
344
  }
402
- if (choosing) renderDiagnosticsWizard();
403
- const statusKey = reviewing
404
- ? state.missingEvidence?.length
405
- ? 'diagnosticsMissingEvidence'
406
- : 'diagnosticsComplete'
407
- : {
408
- inactive: 'diagnosticsInactive',
409
- authorized:
410
- reproductionMode === 'intermittent' ? 'diagnosticsIntermittentAuthorized' : 'diagnosticsAuthorized',
411
- reproducing:
412
- reproductionMode === 'intermittent' ? 'diagnosticsIntermittentReproducing' : 'diagnosticsReproducing',
413
- complete: state.missingEvidence?.length ? 'diagnosticsMissingEvidence' : 'diagnosticsComplete',
414
- expired: 'diagnosticsExpired',
415
- }[state.status];
416
- diagnosticsStatus.textContent = (
417
- choosing && (state.status === 'inactive' || diagnosticsStartingAnother) ? '' : (messages[statusKey] ?? '')
418
- ).replace('{evidence}', state.missingEvidence?.join(', ') ?? '');
345
+ diagnosticsStatus.textContent = '';
419
346
  }
420
347
 
421
348
  /**
422
- * The catalogue entry that says, in a reader's words, what a never-collected class is.
423
- *
424
- * A class with no entry falls back to its own identifier, which is visible rather than absent, so a newly
425
- * declared class shows up as something to translate instead of quietly disappearing from the list.
349
+ * Marks both diagnostics actions while a capture runs, whichever area it is on, and otherwise the dashboard's own
350
+ * while it shows a problem.
426
351
  */
427
- const NEVER_COLLECTED_LABELS = {
428
- 'credentials-and-authentication': 'excludedCredentials',
429
- 'tokens-cookies-and-authorization': 'excludedTokens',
430
- 'session-and-push-stores': 'excludedSessions',
431
- 'private-and-symmetric-keys': 'excludedKeys',
432
- 'unconstrained-internal-objects': 'excludedInternalData',
433
- 'camera-images-talkback-and-raw-media': 'excludedMedia',
434
- };
435
-
436
- function renderArchiveManifest(manifest) {
437
- diagnosticsManifest.replaceChildren();
438
- const summary = document.createElement('p');
439
- const mode =
440
- messages[manifest.reproductionMode === 'intermittent' ? 'diagnosticsModeIntermittent' : 'diagnosticsModeNow'] ??
441
- manifest.reproductionMode;
442
- summary.textContent = `${manifest.archiveFormat} v${manifest.version} · ${manifest.keyId} · ${messages.diagnosticsArchiveMode ?? ''} ${mode} · ${(messages.diagnosticsArchiveExpires ?? '').replace('{expiresAt}', new Date(manifest.archiveExpiresAt).toLocaleString(shell.lang || 'en'))}`;
443
- const evidence = document.createElement('ul');
444
- for (const item of manifest.evidence ?? []) {
445
- const row = document.createElement('li');
446
- const size = item.bytes === undefined ? '' : ` · ${item.bytes} B`;
447
- const truncated = item.truncated ? ` · ${messages.diagnosticsArchiveTruncated ?? ''}` : '';
448
- const missing = item.missingReason ? ` · ${item.missingReason}` : '';
449
- const fields = (item.fields ?? []).map((field) => `${field.field}: ${field.privacyClass}`).join(', ');
450
- row.textContent = `${item.evidence} · ${item.privacyClass} · ${item.status}${missing}${size}${truncated}${fields ? ` · ${messages.diagnosticsArchiveFields ?? ''} ${fields}` : ''}`;
451
- evidence.append(row);
452
- }
453
- const detail = document.createElement('details');
454
- const detailLabel = document.createElement('summary');
455
- detailLabel.textContent = messages.diagnosticsArchiveDetail ?? '';
456
- detail.append(detailLabel, summary, evidence);
457
- const exclusions = document.createElement('p');
458
- const neverCollected = (manifest.excludedClasses ?? []).map(
459
- (excluded) => messages[NEVER_COLLECTED_LABELS[excluded]] ?? excluded,
460
- );
461
- exclusions.textContent = `${messages.diagnosticsArchiveExcluded ?? ''} ${neverCollected.join(', ')}.`;
462
- diagnosticsManifest.append(detail, exclusions);
463
- const uncovered = (manifest.evidence ?? []).filter((item) => item.coversReproduction === false);
464
- if (uncovered.length > 0) {
465
- const gap = document.createElement('p');
466
- gap.textContent = (messages.diagnosticsArchiveCoverageGap ?? '')
467
- .replace('{classes}', uncovered.map((item) => item.evidence).join(', '))
468
- .replace(
469
- '{retainedFrom}',
470
- new Date(
471
- Math.min(...uncovered.map((item) => Date.parse(item.retainedFrom))),
472
- ).toLocaleString(shell.lang || 'en'),
473
- );
474
- diagnosticsManifest.append(gap);
352
+ function renderDiagnosticsEntries() {
353
+ const collecting = diagnosticsState.status === 'reproducing';
354
+ for (const entry of [mastheadDiagnostics, menuDiagnostics]) {
355
+ const attending = !collecting && entry === menuDiagnostics && dashboardAttention !== undefined;
356
+ if (collecting) entry.dataset.collecting = 'true';
357
+ else delete entry.dataset.collecting;
358
+ if (attending) entry.dataset.attention = 'true';
359
+ else delete entry.dataset.attention;
360
+ entry.setAttribute(
361
+ 'aria-label',
362
+ messages[collecting ? 'diagnosticsCollectingFinishHere' : attending ? 'diagnosticsAttention' : 'menuDiagnostics'] ??
363
+ '',
364
+ );
475
365
  }
476
- diagnosticsManifest.hidden = false;
477
366
  }
478
367
 
479
368
  /**
480
- * The manifest for the session on screen, fetched once.
369
+ * The reviewed archive for the session on screen, fetched once.
481
370
  *
482
- * A completed reproduction that the reporter is looking at is a file they came for, so the manifest is
483
- * read without being asked for. It is read once per session rather than per render, because assembling it
484
- * reads every collected log. Exporting spends it, so the session is forgotten again and a second download
485
- * asks for a fresh one.
371
+ * A completed reproduction that the reporter is looking at is a file they came for, so the archive is
372
+ * reviewed without being asked for. It is reviewed once per session rather than per render, because
373
+ * assembling it reads every collected log. Exporting spends it, and ends the session with it.
486
374
  */
487
- async function ensureArchiveReview(caseId) {
488
- if (!caseId || diagnosticsReviewedCaseId === caseId || diagnosticsReviewPending) return;
489
- diagnosticsReviewPending = true;
490
- try {
491
- const review = await requestWithinDeadline('/diagnostics/archive/review', undefined, 12000);
492
- diagnosticsReviewId = review.reviewId;
493
- diagnosticsReviewedCaseId = caseId;
494
- renderArchiveManifest(review.manifest);
495
- diagnosticsReviewConfirmLabel.hidden = false;
496
- diagnosticsExport.hidden = false;
497
- diagnosticsExport.disabled = !diagnosticsReviewConfirm.checked;
498
- diagnosticsIssue.hidden = false;
499
- } catch {
500
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
501
- } finally {
502
- diagnosticsReviewPending = false;
503
- }
375
+ function ensureArchiveReview(caseId) {
376
+ if (!caseId || diagnosticsReviewedCaseId === caseId) return Promise.resolve();
377
+ diagnosticsReviewRequest ??= (async () => {
378
+ try {
379
+ const review = await requestWithinDeadline('/diagnostics/archive/review', undefined, 12000);
380
+ diagnosticsReviewId = review.reviewId;
381
+ diagnosticsReviewedCaseId = caseId;
382
+ diagnosticsExport.hidden = false;
383
+ diagnosticsExport.disabled = false;
384
+ } catch {
385
+ diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
386
+ } finally {
387
+ diagnosticsReviewRequest = undefined;
388
+ }
389
+ })();
390
+ return diagnosticsReviewRequest;
504
391
  }
505
392
 
506
- diagnosticsReviewConfirm.addEventListener('change', () => {
507
- diagnosticsExport.disabled = !diagnosticsReviewConfirm.checked || !diagnosticsReviewId;
508
- });
509
-
510
- diagnosticsExport.addEventListener('click', async () => {
511
- if (!diagnosticsReviewConfirm.checked || !diagnosticsReviewId) return;
393
+ /**
394
+ * Hands the archive of the session on screen over as a download.
395
+ *
396
+ * Finishing a reproduction calls this without being asked, since the file is what the reporter finished for; the
397
+ * dialog's own button is the way to try again after a failed attempt.
398
+ */
399
+ async function downloadDiagnosticsArchive() {
400
+ await ensureArchiveReview(diagnosticsState.supportCaseId ?? '');
401
+ if (!diagnosticsReviewId) return;
512
402
  diagnosticsExport.disabled = true;
513
403
  try {
514
404
  const exported = await requestWithinDeadline(
@@ -519,138 +409,119 @@ diagnosticsExport.addEventListener('click', async () => {
519
409
  const download = document.createElement('a');
520
410
  download.href = `data:${exported.mediaType};base64,${exported.archive}`;
521
411
  download.download = exported.filename;
412
+ diagnosticsHandoffNote.textContent = (messages.diagnosticsArchiveHandoff ?? '').replace(
413
+ '{filename}',
414
+ exported.filename,
415
+ );
416
+ diagnosticsResultStatus.textContent = '';
522
417
  document.body.appendChild(download);
523
418
  download.click();
524
419
  document.body.removeChild(download);
525
420
  diagnosticsReviewId = '';
526
421
  diagnosticsReviewedCaseId = '';
527
- diagnosticsReviewConfirm.checked = false;
528
422
  diagnosticsArchiveDownloaded = true;
529
- setIssueStepReachable(diagnosticsState.issueUrl ?? '');
423
+ renderDiagnostics(diagnosticsState);
424
+ diagnosticsResultHeading.focus?.();
530
425
  } catch {
531
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
426
+ // The plugin spends a review on any attempt, so trying again asks for a fresh one.
427
+ diagnosticsReviewId = '';
428
+ diagnosticsReviewedCaseId = '';
429
+ diagnosticsExport.disabled = false;
430
+ diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
532
431
  }
533
- });
432
+ }
534
433
 
535
- diagnosticsAuthorize.addEventListener('click', async () => {
536
- diagnosticsAuthorize.disabled = true;
537
- let focusAuthorizeAfter = false;
538
- const previousStatus = diagnosticsState.status;
539
- try {
540
- const authorized = await requestWithinDeadline(
541
- '/diagnostics/authorize',
542
- {
543
- profile: diagnosticsWizardState.profile,
544
- reproductionMode: diagnosticsWizardState.reproductionMode,
545
- ...(diagnosticsWizardState.devices === undefined ? {} : { affectedDevices: diagnosticsWizardState.devices }),
546
- },
547
- 12000,
548
- );
549
- diagnosticsStartingAnother = false;
550
- renderDiagnostics(authorized);
551
- if (diagnosticsState.status === 'authorized') diagnosticsGuidance.focus?.();
552
- } catch {
553
- try {
554
- const refreshed = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
555
- if (refreshed.status !== previousStatus) diagnosticsStartingAnother = false;
556
- renderDiagnostics(refreshed);
557
- if (refreshed.status === previousStatus) {
558
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
559
- focusAuthorizeAfter = true;
560
- } else if (refreshed.status === 'authorized') diagnosticsGuidance.focus?.();
561
- } catch {
562
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
563
- }
564
- } finally {
565
- diagnosticsAuthorize.disabled = false;
566
- if (focusAuthorizeAfter) diagnosticsAuthorize.focus?.();
434
+ diagnosticsExport.addEventListener('click', downloadDiagnosticsArchive);
435
+ diagnosticsDownloadAgain.addEventListener('click', downloadDiagnosticsArchive);
436
+
437
+ /**
438
+ * Draws the session as the plugin holds it after `error`, and says so unless it reached `expected`, inside the
439
+ * download dialog while that covers the panel. A request that timed out may still land, so its late answer draws
440
+ * the session again.
441
+ */
442
+ async function redrawDiagnosticsAfterFailure(expected, error) {
443
+ const refreshed = await settleDiagnostics().catch(() => undefined);
444
+ if (refreshed?.status !== expected) {
445
+ (diagnosticsResult.open ? diagnosticsResultStatus : diagnosticsStatus).textContent = messages.diagnosticsFailed ?? '';
567
446
  }
568
- });
447
+ error?.late?.then(settleDiagnostics).catch(() => undefined);
448
+ }
569
449
 
570
- for (const tile of diagnosticsTiles) {
571
- tile.addEventListener('click', async () => {
572
- diagnosticsWizardState = diagnosticsWizard.select(diagnosticsWizardState, tile.dataset.diagnosticsTile);
573
- renderDiagnosticsWizard();
574
- if (diagnosticsWizardState.mode === 'devices') await renderDiagnosticsDeviceList();
575
- else diagnosticsFrequencyHeading.focus?.();
576
- });
450
+ /**
451
+ * Draws the session the plugin holds. One opened but never started is what a failed or late pick leaves behind;
452
+ * nothing on screen stands for it and it keeps detailed logging on, so it is cancelled rather than drawn.
453
+ */
454
+ async function settleDiagnostics() {
455
+ let state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
456
+ if (state.status === 'authorized') state = await requestWithinDeadline('/diagnostics/cancel', undefined, 12000);
457
+ renderDiagnostics(state);
458
+ return state;
577
459
  }
578
460
 
579
- diagnosticsDevicesEvery.addEventListener('click', () => {
580
- const tiles = [...diagnosticsDeviceList.querySelectorAll('[data-serial]')];
581
- for (const tile of tiles) tile.setAttribute('aria-pressed', 'true');
582
- diagnosticsDevicesChosen.disabled = tiles.length === 0;
583
- if (tiles.length === 0) {
584
- diagnosticsWizardState = diagnosticsWizard.chooseDevices(diagnosticsWizardState, 'all');
585
- renderDiagnosticsWizard();
586
- diagnosticsFrequencyHeading.focus?.();
461
+ /**
462
+ * Captures the area picked. The area is the only answer a capture needs, so the pick opens the session and starts
463
+ * it in one go.
464
+ */
465
+ async function startDiagnosticsCapture(profile) {
466
+ for (const tile of diagnosticsTiles) tile.disabled = true;
467
+ try {
468
+ await requestWithinDeadline('/diagnostics/authorize', { profile }, 12000);
469
+ const state = await requestWithinDeadline('/diagnostics/reproduction/start', undefined, 12000);
470
+ renderDiagnostics(state);
471
+ if (isDashboardUiReproducing(state)) {
472
+ await recordActiveUiEvent('background-started').catch(() => undefined);
473
+ closeDashboardPanel();
474
+ return;
475
+ }
476
+ diagnosticsPhaseTitle.focus?.();
477
+ } catch (error) {
478
+ await redrawDiagnosticsAfterFailure('reproducing', error);
479
+ } finally {
480
+ for (const tile of diagnosticsTiles) tile.disabled = false;
587
481
  }
588
- });
589
-
590
- diagnosticsDevicesChosen.addEventListener('click', () => {
591
- diagnosticsWizardState = diagnosticsWizard.chooseDevices(diagnosticsWizardState, chosenDiagnosticsDevices());
592
- renderDiagnosticsWizard();
593
- diagnosticsFrequencyHeading.focus?.();
594
- });
595
-
596
- function chooseDiagnosticsReproductionMode(reproductionMode) {
597
- diagnosticsWizardState = diagnosticsWizard.chooseReproductionMode(diagnosticsWizardState, reproductionMode);
598
- renderDiagnosticsWizard();
599
- diagnosticsGuidanceTitle.focus?.();
600
482
  }
601
483
 
602
- diagnosticsFrequencyNow.addEventListener('click', () => chooseDiagnosticsReproductionMode('now'));
603
- diagnosticsFrequencyIntermittent.addEventListener('click', () => chooseDiagnosticsReproductionMode('intermittent'));
604
-
605
- diagnosticsFrequencyBack.addEventListener('click', () => {
606
- diagnosticsWizardState = diagnosticsWizard.backFromFrequency(diagnosticsWizardState);
607
- renderDiagnosticsWizard();
608
- diagnosticsQuestionText.focus?.();
609
- });
610
-
611
- diagnosticsReject.addEventListener('click', () => {
612
- diagnosticsWizardState = diagnosticsWizard.reject(diagnosticsWizardState);
613
- renderDiagnosticsWizard();
614
- diagnosticsQuestionText.focus?.();
615
- });
484
+ for (const tile of diagnosticsTiles) {
485
+ tile.addEventListener('click', () => startDiagnosticsCapture(tile.dataset.diagnosticsTile));
486
+ }
616
487
 
617
- diagnosticsStartAnother.addEventListener('click', () => {
618
- diagnosticsStartingAnother = true;
619
- diagnosticsWizardState = diagnosticsWizard.start();
620
- renderDiagnostics(diagnosticsState);
621
- diagnosticsQuestionText.focus?.();
488
+ diagnosticsStartAnother.addEventListener('click', endDiagnosticsCase);
489
+ /** Escape leaves the archive dialog only once its file is downloaded, and leaving it finishes the session. */
490
+ diagnosticsResult.addEventListener('cancel', (event) => {
491
+ event.preventDefault();
492
+ if (diagnosticsArchiveDownloaded) endDiagnosticsCase();
622
493
  });
623
494
 
624
495
  diagnosticsReproduction.addEventListener('click', async () => {
625
496
  diagnosticsReproduction.disabled = true;
626
- const previousStatus = diagnosticsState.status;
627
- const path =
628
- diagnosticsState.status === 'reproducing' ? '/diagnostics/reproduction/end' : '/diagnostics/reproduction/start';
629
497
  try {
630
- let state = await requestWithinDeadline(path, undefined, 12000);
498
+ let state = await requestWithinDeadline('/diagnostics/reproduction/end', undefined, 12000);
631
499
  if (state.status === 'complete' && state.missingEvidence?.length) {
632
500
  await new Promise((resolve) => setTimeout(resolve, 250));
633
501
  state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
634
502
  }
635
503
  renderDiagnostics(state);
636
- if (previousStatus === 'authorized' && isDashboardUiReproducing(state)) {
637
- await recordActiveUiEvent('background-started').catch(() => undefined);
638
- closeDashboardPanel();
639
- return;
640
- }
641
- if (state.status === 'complete' || state.partialExportAvailable) diagnosticsResultHeading.focus?.();
642
- } catch {
643
- try {
644
- const refreshed = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
645
- renderDiagnostics(refreshed);
646
- if (refreshed.status === previousStatus) diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
647
- if (refreshed.status === 'complete' || refreshed.partialExportAvailable) diagnosticsResultHeading.focus?.();
648
- else if (refreshed.status === 'authorized' || refreshed.status === 'reproducing') diagnosticsGuidance.focus?.();
649
- } catch {
650
- diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
651
- }
504
+ } catch (error) {
505
+ await redrawDiagnosticsAfterFailure('complete', error);
506
+ } finally {
507
+ diagnosticsReproduction.disabled = diagnosticsState.status !== 'reproducing';
508
+ }
509
+ if (diagnosticsState.partialExportAvailable) {
510
+ diagnosticsResultHeading.focus?.();
511
+ await downloadDiagnosticsArchive();
512
+ }
513
+ });
514
+
515
+ /** Cancelling a capture deletes its session without a file, and puts the areas back on the panel. */
516
+ diagnosticsCancel.addEventListener('click', async () => {
517
+ diagnosticsCancel.disabled = true;
518
+ try {
519
+ renderDiagnostics(await requestWithinDeadline('/diagnostics/cancel', undefined, 12000));
520
+ diagnosticsQuestionText.focus?.();
521
+ } catch (error) {
522
+ await redrawDiagnosticsAfterFailure('inactive', error);
652
523
  } finally {
653
- diagnosticsReproduction.disabled = !['authorized', 'reproducing'].includes(diagnosticsState.status);
524
+ diagnosticsCancel.disabled = false;
654
525
  }
655
526
  });
656
527
 
@@ -678,7 +549,7 @@ function openDashboardPanel(panel, trigger) {
678
549
  dashboardState.hidden = insideDashboard;
679
550
  dashboardSummary.hidden = insideDashboard;
680
551
  deviceGroups.hidden = insideDashboard;
681
- for (const other of [diagnosticsPanel, advancedPanel, devicePanel]) {
552
+ for (const other of [diagnosticsPanel, advancedPanel, devicePanel, wizardPanel]) {
682
553
  other.hidden = other !== panel;
683
554
  }
684
555
  panel.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
@@ -690,6 +561,8 @@ function closeDashboardPanel() {
690
561
  diagnosticsPanel.hidden = true;
691
562
  advancedPanel.hidden = true;
692
563
  devicePanel.hidden = true;
564
+ wizardPanel.hidden = true;
565
+ stopWizardLive();
693
566
  dashboardState.hidden = false;
694
567
  dashboardSummary.hidden = false;
695
568
  deviceGroups.hidden = false;
@@ -707,21 +580,46 @@ function closeDashboardPanel() {
707
580
  }
708
581
  }
709
582
 
710
- mastheadDiagnostics.addEventListener('click', () => menuDiagnostics.click());
711
-
712
- // The dashboard's own offer of a run goes the same way the menu does, so there is one path into the wizard.
713
- dashboardDiagnose.addEventListener('click', () => menuDiagnostics.click());
714
-
715
- menuDiagnostics.addEventListener('click', async () => {
583
+ /**
584
+ * Opens the diagnostics panel. A preset is an area the reporter already chose to diagnose from a problem the
585
+ * dashboard named beside the privacy note, so a panel with nothing under way captures it at once. Opened over the
586
+ * sign-in screen, the panel asks like any other and puts focus on the startup area.
587
+ */
588
+ async function openDiagnostics(preset) {
589
+ const signingIn = !setupContent.hidden;
716
590
  openDashboardPanel(diagnosticsPanel, menuDiagnostics);
717
- // Drawn on opening, because the wizard's first question is only ever put on the page by this call.
718
- renderDiagnosticsWizard();
719
591
  try {
720
592
  renderDiagnostics(await requestWithinDeadline('/diagnostics/status', undefined, 12000));
721
593
  } catch {
722
594
  diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
723
595
  recordActiveUiEventBestEffort('request-failed');
596
+ return;
597
+ }
598
+ if (diagnosticsScreen(diagnosticsState) !== 'choose') return;
599
+ if (preset) await startDiagnosticsCapture(preset.profile);
600
+ else if (signingIn) {
601
+ diagnosticsTiles.find((tile) => tile.dataset.diagnosticsTile === 'startup-authentication')?.focus?.();
602
+ }
603
+ }
604
+
605
+ mastheadDiagnostics.addEventListener('click', () => openDiagnostics());
606
+
607
+ /** While the dashboard shows a problem and no session is under way, the action names it before opening the wizard. */
608
+ menuDiagnostics.addEventListener('click', () => {
609
+ if (!menuDiagnostics.dataset.attention || diagnosticsScreen(diagnosticsState) !== 'choose') {
610
+ return openDiagnostics();
724
611
  }
612
+ attentionTitle.textContent = dashboardAttention.title;
613
+ attentionSummary.textContent = dashboardAttention.summary;
614
+ attentionDialog.showModal?.();
615
+ });
616
+ attentionDiagnose.addEventListener('click', () => {
617
+ attentionDialog.close?.();
618
+ return openDiagnostics(dashboardAttention);
619
+ });
620
+ attentionChoose.addEventListener('click', () => {
621
+ attentionDialog.close?.();
622
+ return openDiagnostics();
725
623
  });
726
624
  menuAdvanced.addEventListener('click', async () => {
727
625
  const config = configuredBlock() ?? {};
@@ -731,6 +629,11 @@ menuAdvanced.addEventListener('click', async () => {
731
629
  warmUpMarked = new Set();
732
630
  renderWarmUp();
733
631
  advancedFfmpeg.value = config.ffmpegPath ?? '';
632
+ advancedSmallVideoPackets.checked = config.smallVideoPackets === true;
633
+ advancedLiveVideoPassthrough.checked = config.liveVideoPassthrough === true;
634
+ advancedHardwareVideoEncoder.checked = config.hardwareVideoEncoder !== false;
635
+ advancedHdLiveVideo.checked = config.hdLiveVideo === true;
636
+ advancedIgnoreHomeBitRate.checked = config.ignoreHomeBitRate === true;
734
637
  openDashboardPanel(advancedPanel, menuAdvanced);
735
638
  /**
736
639
  * The panel asks for its own candidates rather than relying on the devices view having been opened first.
@@ -748,9 +651,8 @@ menuAdvanced.addEventListener('click', async () => {
748
651
  }
749
652
  });
750
653
  diagnosticsClose.addEventListener('click', () => {
751
- diagnosticsStartingAnother = false;
752
- renderDiagnostics(diagnosticsState);
753
654
  closeDashboardPanel();
655
+ renderDiagnostics(diagnosticsState);
754
656
  });
755
657
  advancedClose.addEventListener('click', () => {
756
658
  closeDashboardPanel();
@@ -787,6 +689,16 @@ async function updateAdvancedSettings() {
787
689
  else next.maxConcurrentMediaSessions = maxConcurrentMediaSessions;
788
690
  if (ffmpegPath) next.ffmpegPath = ffmpegPath;
789
691
  else delete next.ffmpegPath;
692
+ if (advancedSmallVideoPackets.checked) next.smallVideoPackets = true;
693
+ else delete next.smallVideoPackets;
694
+ if (advancedLiveVideoPassthrough.checked) next.liveVideoPassthrough = true;
695
+ else delete next.liveVideoPassthrough;
696
+ if (advancedHardwareVideoEncoder.checked) delete next.hardwareVideoEncoder;
697
+ else next.hardwareVideoEncoder = false;
698
+ if (advancedHdLiveVideo.checked) next.hdLiveVideo = true;
699
+ else delete next.hdLiveVideo;
700
+ if (advancedIgnoreHomeBitRate.checked) next.ignoreHomeBitRate = true;
701
+ else delete next.ignoreHomeBitRate;
790
702
  // The default is what the plugin applies when the key is absent, so storing it would only pin today's default.
791
703
  if (warmUpSelection.length === 1 && warmUpSelection[0] === 'doorbellPress') delete next.warmUpEvents;
792
704
  else next.warmUpEvents = [...warmUpSelection];
@@ -802,6 +714,11 @@ async function updateAdvancedSettings() {
802
714
  advancedPolling.addEventListener('change', updateAdvancedSettings);
803
715
  advancedConcurrentMedia.addEventListener('change', updateAdvancedSettings);
804
716
  advancedFfmpeg.addEventListener('change', updateAdvancedSettings);
717
+ advancedSmallVideoPackets.addEventListener('change', updateAdvancedSettings);
718
+ advancedLiveVideoPassthrough.addEventListener('change', updateAdvancedSettings);
719
+ advancedHardwareVideoEncoder.addEventListener('change', updateAdvancedSettings);
720
+ advancedHdLiveVideo.addEventListener('change', updateAdvancedSettings);
721
+ advancedIgnoreHomeBitRate.addEventListener('change', updateAdvancedSettings);
805
722
  warmUpAdd.addEventListener('click', () => moveWarmUp('available'));
806
723
  warmUpAddAll.addEventListener('click', () => moveWarmUp('available', true));
807
724
  warmUpRemove.addEventListener('click', () => moveWarmUp('chosen'));
@@ -920,7 +837,10 @@ function configSignature(config) {
920
837
  async function updateConfig(block) {
921
838
  const nextConfig = pluginConfig.map((candidate) => (candidate.platform === 'HomebridgeEufy' ? block : candidate));
922
839
  if (!nextConfig.includes(block)) nextConfig.push(block);
923
- await homebridge.updatePluginConfig(nextConfig);
840
+ // Homebridge UI merges each block into the one it holds, so a key the block dropped is sent as undefined to clear it.
841
+ const cleared = Object.keys(configuredBlock() ?? {}).filter((key) => !(key in block));
842
+ const sent = { ...Object.fromEntries(cleared.map((key) => [key, undefined])), ...block };
843
+ await homebridge.updatePluginConfig(nextConfig.map((candidate) => (candidate === block ? sent : candidate)));
924
844
  pluginConfig = nextConfig;
925
845
  if (configSignature(nextConfig) === savedConfigSignature) homebridge.disableSaveButton();
926
846
  else homebridge.enableSaveButton();
@@ -933,15 +853,422 @@ dashboardView.bindPreferences(dashboardElements, configuredBlock, updateConfig,
933
853
  openDashboardPanel(devicePanel, trigger);
934
854
  });
935
855
 
936
- dashboardAuthenticate.addEventListener('click', () => {
856
+ /*
857
+ * The setup wizard walks every device once a sign-in has a running plugin to show it: each camera live, the eufy
858
+ * settings the plugin can change, and the HomeKit choices. A sign-in always leaves Homebridge to restart, so it
859
+ * marks the wizard pending in this browser and the next page opened on a ready plugin starts it.
860
+ */
861
+ let dashboardPluginState;
862
+ let wizardSteps = [];
863
+ let wizardIndex = 0;
864
+ let wizardLiveSerial;
865
+ let wizardLivePictures = 0;
866
+ const wizardElements = {
867
+ deviceSettingsTitle: wizardTitle,
868
+ deviceSettingsEyebrow: wizardEyebrow,
869
+ deviceSettingsControls: wizardControls,
870
+ };
871
+ /** The eufy settings the wizard offers, each with the label its control is named by. */
872
+ const EUFY_SETTING_LABELS = {
873
+ motionDetection: 'wizardMotionDetection',
874
+ nightVision: 'wizardNightVision',
875
+ notificationStyle: 'wizardNotificationStyle',
876
+ };
877
+
878
+ /** Whether a plugin is running to stream and change settings: a degraded one still holds its devices. */
879
+ function wizardPluginUp() {
880
+ return dashboardPluginState === 'ready' || dashboardPluginState === 'degraded';
881
+ }
882
+
883
+ /** The browser key marking a sign-in whose devices have not been walked through yet. */
884
+ const WIZARD_PENDING_KEY = 'homebridge-eufy.setup-wizard-pending';
885
+
886
+ function wizardPending(pending) {
887
+ try {
888
+ if (pending === undefined) return localStorage.getItem(WIZARD_PENDING_KEY) === 'pending';
889
+ if (pending) localStorage.setItem(WIZARD_PENDING_KEY, 'pending');
890
+ else localStorage.removeItem(WIZARD_PENDING_KEY);
891
+ } catch {
892
+ return false;
893
+ }
894
+ return pending;
895
+ }
896
+
897
+ /**
898
+ * The devices the wizard walks through, in the map's order so the arrows follow it: each HomeBase then what hangs
899
+ * off it, then the devices that stand alone. Every camera is one, and every other device with a HomeKit choice to
900
+ * make; a device the plugin only diagnoses has nothing to set.
901
+ */
902
+ function setupWizardSteps(devices) {
903
+ const walkable = (devices ?? []).filter(
904
+ (device) => !device.diagnosticOnly && (device.deviceClass === 'camera' || device.preferences?.length > 0),
905
+ );
906
+ const { bases, standalone } = setupWizardTopology(walkable, devices);
907
+ return [
908
+ ...bases.flatMap(({ base, members }) => [...(base && walkable.includes(base) ? [base] : []), ...members]),
909
+ ...standalone,
910
+ ];
911
+ }
912
+
913
+ /**
914
+ * The household as the map draws it: each HomeBase with the devices that hang off it, then the devices that are
915
+ * their own station. A base is named by its own entry where the dashboard lists one; a device whose base it does
916
+ * not list stays with that base all the same, under no name.
917
+ */
918
+ function setupWizardTopology(steps, devices) {
919
+ const bases = new Map();
920
+ const standalone = [];
921
+ for (const device of devices ?? []) {
922
+ if (device.deviceClass === 'homebase') bases.set(device.serial, { base: device, members: [] });
923
+ }
924
+ for (const device of steps) {
925
+ if (device.deviceClass === 'homebase') continue;
926
+ if (!device.station) {
927
+ standalone.push(device);
928
+ continue;
929
+ }
930
+ if (!bases.has(device.station)) bases.set(device.station, { base: undefined, members: [] });
931
+ bases.get(device.station).members.push(device);
932
+ }
933
+ return { bases: [...bases.values()], standalone };
934
+ }
935
+
936
+ async function openSetupWizard(trigger) {
937
+ wizardSteps = setupWizardSteps(dashboardDevices);
938
+ wizardIndex = 0;
939
+ openDashboardPanel(wizardPanel, trigger);
940
+ await renderWizardStep();
941
+ }
942
+
943
+ /**
944
+ * The advanced form's live video settings by name. A camera may keep its own value of those in
945
+ * {@link WIZARD_LIVE_CAMERA}; the others are general, so the card's toggle for one is the advanced form's own.
946
+ */
947
+ const wizardLiveSettings = {
948
+ liveVideoPassthrough: advancedLiveVideoPassthrough,
949
+ ignoreHomeBitRate: advancedIgnoreHomeBitRate,
950
+ hardwareVideoEncoder: advancedHardwareVideoEncoder,
951
+ smallVideoPackets: advancedSmallVideoPackets,
952
+ };
953
+ const WIZARD_LIVE_CAMERA = ['liveVideoPassthrough', 'ignoreHomeBitRate'];
954
+
955
+ /** The ask and settings the preview's HomeKit side is coded with, as its controls stand. Never saved. */
956
+ function wizardLiveAdaptation() {
957
+ const fields = wizardLiveControls.elements;
958
+ const settings = Object.keys(wizardLiveSettings).map((name) => [name, fields[name].checked]);
959
+ return { ask: fields.ask.value, ...Object.fromEntries(settings) };
960
+ }
961
+
962
+ function stopWizardLive() {
963
+ wizardLiveHomeKitSpinner.hidden = true;
964
+ wizardLiveEncoder.textContent = messages.wizardLiveHomeKit ?? '';
965
+ wizardLiveDiagnose.hidden = true;
966
+ if (!wizardLiveSerial) return;
967
+ wizardLiveSerial = undefined;
968
+ wizardLivePictures = 0;
969
+ wizardLivePlay.hidden = false;
970
+ wizardLiveStop.hidden = true;
971
+ wizardLiveSpinner.hidden = true;
972
+ wizardLiveStatus.textContent = '';
973
+ void requestWithinDeadline('/device/live/stop', {}, 12000).catch(() => undefined);
974
+ }
975
+
976
+ /**
977
+ * Shows a camera's box with its last picture behind the play button, so the step costs nothing until asked.
978
+ * A camera with no picture yet shows the empty box.
979
+ */
980
+ async function showWizardCamera(serial, enabled) {
981
+ wizardLive.hidden = false;
982
+ wizardLiveImage.hidden = true;
983
+ const own = configuredBlock()?.entityPreferences?.[serial] ?? {};
984
+ for (const [name, input] of Object.entries(wizardLiveSettings)) {
985
+ wizardLiveControls.elements[name].checked = own[name] ?? input.checked;
986
+ }
987
+ // A camera switched off in the eufy app has nothing to stream, so it offers no play, only the reason.
988
+ wizardLivePlay.hidden = enabled === false;
989
+ wizardLiveStatus.textContent = enabled === false ? (messages.deviceStatusSwitchedOff ?? '') : '';
990
+ let answer;
991
+ try {
992
+ answer = await requestWithinDeadline('/device/image', { serial }, 12000);
993
+ } catch {
994
+ answer = undefined;
995
+ }
996
+ if (!answer?.image || wizardSteps[wizardIndex]?.serial !== serial || wizardLiveSerial) return;
997
+ wizardLiveImage.src = `data:image/jpeg;base64,${answer.image}`;
998
+ wizardLiveImage.hidden = false;
999
+ }
1000
+
1001
+ function startWizardLive(serial) {
1002
+ stopWizardLive();
1003
+ wizardLiveSerial = serial;
1004
+ wizardLivePlay.hidden = true;
1005
+ wizardLiveSpinner.hidden = false;
1006
+ wizardLiveStatus.textContent = messages.wizardLiveStarting ?? '';
1007
+ const body = { serial, adaptation: wizardLiveAdaptation() };
1008
+ void requestWithinDeadline('/device/live/start', body, 12000).catch(() => endWizardLive(serial));
1009
+ }
1010
+
1011
+ /** Ends the stream on screen, saying why HomeKit got no picture: coded video that decoded to nothing, or no video. */
1012
+ function endWizardLive(serial, unplayable) {
1013
+ if (serial !== wizardLiveSerial) return;
1014
+ const failure = unplayable ? messages.wizardLiveUnplayable : wizardLivePictures ? '' : messages.wizardLiveFailed;
1015
+ wizardLiveSerial = undefined;
1016
+ wizardLivePictures = 0;
1017
+ wizardLiveStatus.textContent = failure ?? '';
1018
+ wizardLiveDiagnose.hidden = !failure;
1019
+ wizardLivePlay.hidden = false;
1020
+ wizardLiveStop.hidden = true;
1021
+ wizardLiveSpinner.hidden = true;
1022
+ wizardLiveHomeKitSpinner.hidden = true;
1023
+ }
1024
+
1025
+ homebridge.addEventListener('device-live', (event) => {
1026
+ if (!event.data || event.data.serial !== wizardLiveSerial) return;
1027
+ wizardLivePictures += 1;
1028
+ // The stop is offered once there is video to stop; until then the spinner says it is on its way.
1029
+ wizardLiveSpinner.hidden = true;
1030
+ wizardLiveStop.hidden = false;
1031
+ const homekit = event.data.side === 'homekit';
1032
+ // The first picture clears the connecting text; after that only the HomeKit side changes what the status says.
1033
+ if (homekit || wizardLivePictures === 1) wizardLiveStatus.textContent = '';
1034
+ if (homekit) wizardLiveHomeKitSpinner.hidden = true;
1035
+ // An empty HomeKit picture is a run that coded nothing watchable while the camera plays on.
1036
+ if (homekit && !event.data.image) {
1037
+ wizardLiveHomeKit.hidden = true;
1038
+ wizardLiveStatus.textContent = messages.wizardLiveUnplayable ?? '';
1039
+ wizardLiveDiagnose.hidden = false;
1040
+ return;
1041
+ }
1042
+ const image = homekit ? wizardLiveHomeKit : wizardLiveImage;
1043
+ image.src = `data:image/jpeg;base64,${event.data.image}`;
1044
+ image.hidden = false;
1045
+ if (!homekit || typeof event.data.kbps !== 'number') return;
1046
+ // The rate is the encoder's own output, the video HomeKit would be sent, not these pictures.
1047
+ const encoder = event.data.encoder === 'copy' ? messages.wizardLiveCamera : event.data.encoder;
1048
+ wizardLiveEncoder.textContent = (messages.wizardLiveEncoder ?? '')
1049
+ .replace('{encoder}', encoder ?? '')
1050
+ .replace('{kbps}', String(event.data.kbps));
1051
+ });
1052
+ // Only the HomeKit side restarts: its half waits under a spinner while the camera plays on.
1053
+ wizardLiveControls.addEventListener('change', (event) => {
1054
+ const name = event.target?.name;
1055
+ if (wizardLiveSettings[name] && !WIZARD_LIVE_CAMERA.includes(name)) {
1056
+ wizardLiveSettings[name].checked = event.target.checked;
1057
+ void updateAdvancedSettings();
1058
+ }
1059
+ if (!wizardLiveSerial) return;
1060
+ wizardLiveHomeKit.hidden = false;
1061
+ wizardLiveHomeKitSpinner.hidden = false;
1062
+ wizardLiveStatus.textContent = '';
1063
+ wizardLiveDiagnose.hidden = true;
1064
+ const body = { serial: wizardLiveSerial, adaptation: wizardLiveAdaptation() };
1065
+ void requestWithinDeadline('/device/live/adapt', body, 12000).catch(() => undefined);
1066
+ });
1067
+ // Keeps this camera's own ticks, as its preference wherever they differ from the general setting.
1068
+ wizardLiveSave.addEventListener('click', async () => {
1069
+ const serial = wizardSteps[wizardIndex]?.serial;
1070
+ const existing = configuredBlock();
1071
+ if (!serial || !existing) return;
1072
+ const entityPreferences = { ...(existing.entityPreferences ?? {}) };
1073
+ const preference = { ...(entityPreferences[serial] ?? {}) };
1074
+ for (const name of WIZARD_LIVE_CAMERA) {
1075
+ const checked = wizardLiveControls.elements[name].checked;
1076
+ if (checked === wizardLiveSettings[name].checked) delete preference[name];
1077
+ else preference[name] = checked;
1078
+ }
1079
+ if (Object.keys(preference).length === 0) delete entityPreferences[serial];
1080
+ else entityPreferences[serial] = preference;
1081
+ try {
1082
+ await updateConfig({ ...existing, entityPreferences });
1083
+ } catch {
1084
+ wizardLiveStatus.textContent = messages.preferenceSaveFailed ?? '';
1085
+ }
1086
+ });
1087
+ wizardLiveDiagnoseStart.addEventListener('click', () => openDiagnostics({ profile: 'live-media' }));
1088
+ homebridge.addEventListener('device-live-end', (event) => endWizardLive(event.data?.serial, event.data?.unplayable));
1089
+
1090
+ /** One control per eufy setting the device states and can change, named and valued the way the camera describes it. */
1091
+ function eufySettingControl(setting) {
1092
+ const label = messages[EUFY_SETTING_LABELS[setting.setting]] ?? setting.setting;
1093
+ const wrapper = document.createElement('label');
1094
+ wrapper.className = typeof setting.value === 'boolean' ? 'toggle' : 'wizard-setting';
1095
+ wrapper.dataset.setting = '';
1096
+ const name = document.createElement('span');
1097
+ name.className = 'setting-label';
1098
+ name.textContent = label;
1099
+ let control;
1100
+ if (typeof setting.value === 'boolean') {
1101
+ control = document.createElement('input');
1102
+ control.type = 'checkbox';
1103
+ control.checked = setting.value;
1104
+ wrapper.append(control, name);
1105
+ } else {
1106
+ control = document.createElement('select');
1107
+ for (const [value, text] of Object.entries(setting.labels ?? { [setting.value]: String(setting.value) })) {
1108
+ const option = document.createElement('option');
1109
+ option.value = value;
1110
+ option.textContent = text;
1111
+ option.selected = Number(value) === setting.value;
1112
+ control.append(option);
1113
+ }
1114
+ wrapper.append(name, control);
1115
+ }
1116
+ control.dataset.eufySetting = setting.setting;
1117
+ return wrapper;
1118
+ }
1119
+
1120
+ async function renderWizardSettings(serial) {
1121
+ let answer;
1122
+ try {
1123
+ answer = await requestWithinDeadline('/device/settings', { serial }, 12000);
1124
+ } catch {
1125
+ answer = undefined;
1126
+ }
1127
+ if (wizardSteps[wizardIndex]?.serial !== serial) return;
1128
+ const writable = (answer?.settings ?? []).filter((setting) => setting.writable);
1129
+ if (writable.length === 0) return;
1130
+ const status = document.createElement('p');
1131
+ status.className = 'preference-note';
1132
+ status.setAttribute('role', 'status');
1133
+ status.dataset.wizardSettingsStatus = '';
1134
+ wizardSettings.replaceChildren(...writable.map(eufySettingControl), status);
1135
+ wizardEufy.hidden = false;
1136
+ }
1137
+
1138
+ /** One device on the map: a button that opens its step, marked as the current one where it is. */
1139
+ function wizardMapEntry(device, current) {
1140
+ const entry = document.createElement('button');
1141
+ entry.type = 'button';
1142
+ entry.className = 'wizard-map-device';
1143
+ entry.textContent = device.name;
1144
+ entry.dataset.serial = device.serial;
1145
+ if (device.serial === current) entry.setAttribute('aria-current', 'step');
1146
+ return entry;
1147
+ }
1148
+
1149
+ /**
1150
+ * Draws the household: each HomeBase with the devices that hang off it, then the cameras that stand alone. A base
1151
+ * is a step too where it has HomeKit choices, so its name opens it like any other device.
1152
+ */
1153
+ function renderWizardMap(current) {
1154
+ const { bases, standalone } = setupWizardTopology(wizardSteps, dashboardDevices);
1155
+ const group = (title, members, base) => {
1156
+ const section = document.createElement('div');
1157
+ section.className = 'wizard-map-group';
1158
+ const heading = base && wizardSteps.includes(base) ? wizardMapEntry(base, current) : document.createElement('p');
1159
+ heading.classList?.add('wizard-map-base');
1160
+ heading.textContent = title;
1161
+ const list = document.createElement('div');
1162
+ list.className = 'wizard-map-members';
1163
+ list.append(...members.map((device) => wizardMapEntry(device, current)));
1164
+ section.append(heading, list);
1165
+ return section;
1166
+ };
1167
+ wizardMap.replaceChildren(
1168
+ ...bases.map(({ base, members }) => group(base?.name ?? messages.wizardMapBase ?? '', members, base)),
1169
+ ...(standalone.length > 0 ? [group(messages.wizardMapStandalone ?? '', standalone)] : []),
1170
+ );
1171
+ }
1172
+
1173
+ wizardSettings.addEventListener('change', async (event) => {
1174
+ const control = event.target;
1175
+ const setting = control?.dataset?.eufySetting;
1176
+ const device = wizardSteps[wizardIndex];
1177
+ if (!setting || !device) return;
1178
+ const value = control.type === 'checkbox' ? control.checked : Number(control.value);
1179
+ const status = wizardSettings.querySelector('[data-wizard-settings-status]');
1180
+ control.disabled = true;
1181
+ status.textContent = messages.wizardSettingSending ?? '';
1182
+ try {
1183
+ const answer = await requestWithinDeadline('/device/settings/write', { serial: device.serial, setting, value }, 30000);
1184
+ // A write that reached the camera is not yet a camera that changed, so the step says sent rather than done.
1185
+ status.textContent = answer?.ok ? (messages.wizardSettingSent ?? '') : (messages.wizardSettingFailed ?? '');
1186
+ } catch {
1187
+ status.textContent = messages.wizardSettingFailed ?? '';
1188
+ } finally {
1189
+ control.disabled = false;
1190
+ }
1191
+ });
1192
+
1193
+ async function renderWizardStep() {
1194
+ stopWizardLive();
1195
+ wizardLiveHomeKit.hidden = true;
1196
+ wizardSettings.replaceChildren();
1197
+ wizardControls.replaceChildren();
1198
+ wizardEufy.hidden = true;
1199
+ const restart = !wizardPluginUp();
1200
+ const empty = restart || wizardSteps.length === 0;
1201
+ wizardRestart.hidden = !restart;
1202
+ wizardMap.hidden = empty;
1203
+ wizardStage.hidden = empty;
1204
+ wizardHomeKit.hidden = empty;
1205
+ wizardFinish.textContent = restart ? (messages.wizardClose ?? '') : (messages.wizardFinish ?? '');
1206
+ if (empty) {
1207
+ wizardProgress.textContent = '';
1208
+ wizardTitle.textContent = messages.wizardTitle ?? '';
1209
+ wizardEyebrow.textContent = '';
1210
+ return;
1211
+ }
1212
+ const device = wizardSteps[wizardIndex];
1213
+ renderWizardMap(device.serial);
1214
+ dashboardView.renderDeviceSettings(device, configuredBlock() ?? {}, messages, wizardElements);
1215
+ wizardHomeKit.hidden = !(device.preferences?.length > 0);
1216
+ wizardProgress.textContent = (messages.wizardProgress ?? '')
1217
+ .replace('{current}', String(wizardIndex + 1))
1218
+ .replace('{total}', String(wizardSteps.length));
1219
+ // Only a camera has a picture to show; any other device's step is its settings alone.
1220
+ wizardLive.hidden = device.deviceClass !== 'camera';
1221
+ wizardLiveControls.hidden = wizardLive.hidden;
1222
+ if (!wizardLive.hidden) void showWizardCamera(device.serial, device.enabled);
1223
+ await renderWizardSettings(device.serial);
1224
+ }
1225
+
1226
+ function goToWizardStep(index) {
1227
+ if (index < 0 || index >= wizardSteps.length || index === wizardIndex) return;
1228
+ wizardIndex = index;
1229
+ void renderWizardStep();
1230
+ }
1231
+
1232
+ menuWizard.addEventListener('click', () => openSetupWizard(menuWizard));
1233
+ wizardClose.addEventListener('click', () => closeDashboardPanel());
1234
+ wizardLiveStop.addEventListener('click', () => stopWizardLive());
1235
+ wizardLivePlay.addEventListener('click', () => {
1236
+ const device = wizardSteps[wizardIndex];
1237
+ if (device) startWizardLive(device.serial);
1238
+ });
1239
+ wizardMap.addEventListener('click', (event) => {
1240
+ const serial = event.target?.closest?.('[data-serial]')?.dataset?.serial;
1241
+ goToWizardStep(wizardSteps.findIndex((device) => device.serial === serial));
1242
+ });
1243
+ // Closing a wizard that waits for a restart keeps it due; finishing one that walked the devices does not.
1244
+ wizardFinish.addEventListener('click', () => {
1245
+ if (wizardPluginUp()) wizardPending(false);
1246
+ closeDashboardPanel();
1247
+ });
1248
+ window.addEventListener?.('pagehide', () => stopWizardLive());
1249
+
1250
+ function openAuthentication() {
937
1251
  diagnosticsPanel.hidden = true;
938
1252
  advancedPanel.hidden = true;
1253
+ devicePanel.hidden = true;
1254
+ wizardPanel.hidden = true;
1255
+ stopWizardLive();
939
1256
  dashboard.hidden = true;
940
1257
  setupContent.hidden = false;
941
1258
  masthead.hidden = false;
942
1259
  pageTitle.textContent = messages.pageTitle;
943
1260
  accountInput.focus?.();
944
1261
  recordActiveUiEventBestEffort('authentication-opened');
1262
+ }
1263
+
1264
+ dashboardAuthenticate.addEventListener('click', openAuthentication);
1265
+
1266
+ // A saved session the plugin can no longer use stops every device, so nothing on the page works until the user
1267
+ // signs in again. The dialog says so in front of everything else, and Escape does not dismiss it.
1268
+ reauthDialog.addEventListener('cancel', (event) => event.preventDefault());
1269
+ reauthAction.addEventListener('click', () => {
1270
+ reauthDialog.close?.();
1271
+ openAuthentication();
945
1272
  });
946
1273
 
947
1274
  legacyAcknowledge.addEventListener('click', async () => {
@@ -951,12 +1278,19 @@ legacyAcknowledge.addEventListener('click', async () => {
951
1278
  try {
952
1279
  await updateConfig({ ...existing, discardedV4Settings: legacyNames, discardedV4Acknowledged: true });
953
1280
  } catch {
1281
+ legacyStatus.textContent = messages.preferenceSaveFailed ?? '';
954
1282
  return;
955
1283
  }
956
1284
  }
957
1285
  legacyNotice.hidden = true;
958
1286
  });
959
1287
 
1288
+ /**
1289
+ * Writes the signed-in account into the Homebridge configuration, reporting whether it was saved.
1290
+ *
1291
+ * The Save button is enabled before the attempt and never disabled by it, so a write that fails leaves the user
1292
+ * the one control that retries it.
1293
+ */
960
1294
  async function saveAuthenticatedConfig() {
961
1295
  homebridge.enableSaveButton();
962
1296
  try {
@@ -965,7 +1299,9 @@ async function saveAuthenticatedConfig() {
965
1299
  pluginConfig = [pendingConfig];
966
1300
  savedConfigSignature = configSignature(pluginConfig);
967
1301
  homebridge.enableSaveButton();
1302
+ return true;
968
1303
  } catch {
1304
+ return false;
969
1305
  } finally {
970
1306
  passwordInput.value = '';
971
1307
  pendingConfig = undefined;
@@ -985,18 +1321,36 @@ async function showDashboard() {
985
1321
  warmUpCandidates = Array.isArray(snapshot.warmUpCandidates) ? snapshot.warmUpCandidates : [];
986
1322
  // Kept beside the render that drew them, so opening a tile finds the device that tile stands for.
987
1323
  dashboardDevices = snapshot.devices ?? [];
988
- dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
1324
+ dashboardPluginState = snapshot.state;
1325
+ dashboardAttention = dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
1326
+ if (snapshot.state === 'authentication-required' && snapshot.devices?.length > 0 && !reauthDialog.open) {
1327
+ reauthDialog.showModal?.();
1328
+ }
989
1329
  // The image pass runs beside the rendered dashboard, so its failure is dropped here rather than escaping.
990
1330
  void dashboardView
991
1331
  .applyDeviceImages(dashboardElements, (serial) => requestWithinDeadline('/device/image', { serial }, 12000))
992
1332
  .catch(() => undefined);
993
1333
  } catch {
994
- dashboardView.render({ state: 'missing', devices: [] }, configuredBlock() ?? {}, messages, dashboardElements);
1334
+ dashboardAttention = dashboardView.render(
1335
+ { state: 'unreachable', devices: [] },
1336
+ configuredBlock() ?? {},
1337
+ messages,
1338
+ dashboardElements,
1339
+ );
995
1340
  recordActiveUiEventBestEffort('request-failed');
996
1341
  }
1342
+ renderDiagnosticsEntries();
997
1343
  recordActiveUiEventBestEffort('dashboard-opened');
998
1344
  }
999
1345
 
1346
+ /** The message each unsuccessful sign-in outcome is stated with; any other outcome is a failed sign-in. */
1347
+ const AUTH_OUTCOMES = {
1348
+ blocked: 'dashboardOwnerConflictSummary',
1349
+ 'plugin-running': 'authPluginRunning',
1350
+ 'commit-failed': 'authCommitFailed',
1351
+ 'timed-out': 'authTimedOut',
1352
+ };
1353
+
1000
1354
  async function handleResult(result) {
1001
1355
  if (result.status === 'captcha') {
1002
1356
  challenge = 'captcha';
@@ -1005,7 +1359,7 @@ async function handleResult(result) {
1005
1359
  challengeImage.hidden = false;
1006
1360
  challengeLabel.textContent = messages.captchaLabel ?? '';
1007
1361
  challengeForm.hidden = false;
1008
- authStatus.textContent = '';
1362
+ authStatus.textContent = result.retry ? (messages.captchaRetry ?? '') : '';
1009
1363
  return;
1010
1364
  }
1011
1365
  if (result.status === 'two-factor') {
@@ -1014,7 +1368,7 @@ async function handleResult(result) {
1014
1368
  challengeImage.hidden = true;
1015
1369
  challengeLabel.textContent = messages.twoFactorLabel ?? '';
1016
1370
  challengeForm.hidden = false;
1017
- authStatus.textContent = result.method;
1371
+ authStatus.textContent = messages.twoFactorSent ?? '';
1018
1372
  return;
1019
1373
  }
1020
1374
 
@@ -1022,38 +1376,30 @@ async function handleResult(result) {
1022
1376
  if (result.status === 'restart-required') {
1023
1377
  authForm.hidden = true;
1024
1378
  authStatus.textContent = messages.authSuccess ?? '';
1025
- await saveAuthenticatedConfig();
1026
1379
  // The devices this sign-in discovered are already recorded, so the flow ends on them rather than on a notice.
1027
- await showDashboard();
1028
- } else if (result.status === 'blocked' || result.status === 'plugin-running') {
1029
- authForm.hidden = false;
1030
- authStatus.textContent = messages.authBlocked ?? '';
1031
- } else if (result.status === 'timed-out') {
1032
- authForm.hidden = false;
1033
- authStatus.textContent = messages.authTimedOut ?? '';
1380
+ if (await saveAuthenticatedConfig()) {
1381
+ await showDashboard();
1382
+ wizardPending(true);
1383
+ await openSetupWizard();
1384
+ } else authStatus.textContent = messages.authSaveFailed ?? '';
1034
1385
  } else {
1035
1386
  authForm.hidden = false;
1036
- authStatus.textContent = messages.authFailed ?? '';
1387
+ authStatus.textContent = messages[AUTH_OUTCOMES[result.status] ?? 'authFailed'] ?? '';
1037
1388
  }
1038
1389
  }
1039
1390
 
1391
+ /** A sign-in request that did not come back: the page's own deadline elapsing is said to be a timeout. */
1392
+ function reportAuthRequestFailure(error) {
1393
+ authStatus.textContent = messages[error?.message === REQUEST_TIMED_OUT ? 'authTimedOut' : 'authFailed'] ?? '';
1394
+ recordActiveUiEventBestEffort('request-failed');
1395
+ }
1396
+
1040
1397
  authForm.addEventListener('submit', async (event) => {
1041
1398
  event.preventDefault();
1399
+ // A sign-in replaces the account and nothing else: every other key of the block, the child bridge included, is kept.
1042
1400
  const existing = pluginConfig.find((block) => block.platform === 'HomebridgeEufy') ?? {};
1043
- const retained = Object.fromEntries(
1044
- [
1045
- 'pollingIntervalMinutes',
1046
- 'sessionWarmUp',
1047
- 'ffmpegPath',
1048
- 'entityPreferences',
1049
- 'discardedV4Settings',
1050
- 'discardedV4Acknowledged',
1051
- ]
1052
- .filter((key) => Object.hasOwn(existing, key))
1053
- .map((key) => [key, existing[key]]),
1054
- );
1055
1401
  pendingConfig = {
1056
- ...retained,
1402
+ ...existing,
1057
1403
  platform: 'HomebridgeEufy',
1058
1404
  username: accountInput.value.trim(),
1059
1405
  password: passwordInput.value,
@@ -1065,9 +1411,8 @@ authForm.addEventListener('submit', async (event) => {
1065
1411
  setBusy(true);
1066
1412
  try {
1067
1413
  await handleResult(await requestWithinDeadline('/auth/start', { configuration: pendingConfig }));
1068
- } catch {
1069
- authStatus.textContent = messages.authFailed ?? '';
1070
- recordActiveUiEventBestEffort('request-failed');
1414
+ } catch (error) {
1415
+ reportAuthRequestFailure(error);
1071
1416
  } finally {
1072
1417
  setBusy(false);
1073
1418
  }
@@ -1081,9 +1426,8 @@ challengeForm.addEventListener('submit', async (event) => {
1081
1426
  setBusy(true);
1082
1427
  try {
1083
1428
  await handleResult(await requestWithinDeadline(path, body));
1084
- } catch {
1085
- authStatus.textContent = messages.authFailed ?? '';
1086
- recordActiveUiEventBestEffort('request-failed');
1429
+ } catch (error) {
1430
+ reportAuthRequestFailure(error);
1087
1431
  } finally {
1088
1432
  setBusy(false);
1089
1433
  }
@@ -1117,6 +1461,40 @@ if (updateRestart) {
1117
1461
  });
1118
1462
  }
1119
1463
 
1464
+ /** The V4 settings a legacy block sets to something other than its default, which V5 no longer reads. */
1465
+ function legacySettingNames(block) {
1466
+ const defaults = { CameraMaxLivestreamDuration: 30, hkHome: 1, hkAway: 0, hkNight: 3, hkOff: 63 };
1467
+ const effectful = (key, value) => {
1468
+ if (Object.hasOwn(defaults, key)) return value !== defaults[key];
1469
+ if (Array.isArray(value)) return value.length > 0;
1470
+ if (value && typeof value === 'object') return Object.keys(value).length > 0;
1471
+ return Boolean(value);
1472
+ };
1473
+ return [
1474
+ 'CameraMaxLivestreamDuration',
1475
+ 'autoSyncStation',
1476
+ 'cameras',
1477
+ 'cleanCache',
1478
+ 'debugLivestream',
1479
+ 'enableDetailedLogging',
1480
+ 'enableEmbeddedPKCS1Support',
1481
+ 'experimentalMode',
1482
+ 'hkAway',
1483
+ 'hkHome',
1484
+ 'hkNight',
1485
+ 'hkOff',
1486
+ 'ignoreDevices',
1487
+ 'ignoreMultipleDevicesWarning',
1488
+ 'ignoreStations',
1489
+ 'omitLogFiles',
1490
+ 'stations',
1491
+ 'syncStationModes',
1492
+ 'useEmbeddedPKCS1Support',
1493
+ ]
1494
+ .filter((key) => Object.hasOwn(block, key) && effectful(key, block[key]))
1495
+ .sort();
1496
+ }
1497
+
1120
1498
  homebridge.addEventListener('ready', async () => {
1121
1499
  homebridge.disableSaveButton();
1122
1500
  shell.dataset.theme = await homebridge.userCurrentLightingMode();
@@ -1140,7 +1518,7 @@ homebridge.addEventListener('ready', async () => {
1140
1518
  } else {
1141
1519
  const legacyBlock = pluginConfig.find((block) => block.platform === 'EufySecurity');
1142
1520
  if (legacyBlock) {
1143
- legacyNames = legacySettingsView.names(legacyBlock);
1521
+ legacyNames = legacySettingNames(legacyBlock);
1144
1522
  }
1145
1523
  }
1146
1524
  legacyNotice.hidden = legacyNames.length === 0 || legacyAcknowledged;
@@ -1156,5 +1534,6 @@ homebridge.addEventListener('ready', async () => {
1156
1534
  trustedDeviceInput.value = configured?.trustedDeviceName ?? 'Homebridge Eufy';
1157
1535
  if (configured) {
1158
1536
  await showDashboard();
1537
+ if (wizardPending() && wizardPluginUp()) await openSetupWizard();
1159
1538
  }
1160
1539
  });