@homebridge-plugins/homebridge-eufy-security 5.0.0-beta.4 → 5.0.0-beta.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +9 -17
- package/config.schema.json +60 -0
- package/dist/account/ownership.js +16 -16
- package/dist/account/ownership.js.map +1 -1
- package/dist/account/persistence.js +24 -74
- package/dist/account/persistence.js.map +1 -1
- package/dist/account/temporary-authentication.js +5 -3
- package/dist/account/temporary-authentication.js.map +1 -1
- package/dist/configuration.js +50 -13
- package/dist/configuration.js.map +1 -1
- package/dist/device/member-evidence.js +11 -4
- package/dist/device/member-evidence.js.map +1 -1
- package/dist/device/member-trust.js +14 -32
- package/dist/device/member-trust.js.map +1 -1
- package/dist/device/snapshot.js +6 -18
- package/dist/device/snapshot.js.map +1 -1
- package/dist/diagnostics.js +966 -337
- package/dist/diagnostics.js.map +1 -1
- package/dist/homekit/adapter.js +4 -1
- package/dist/homekit/adapter.js.map +1 -1
- package/dist/homekit/adapters/battery.js +3 -3
- package/dist/homekit/adapters/battery.js.map +1 -1
- package/dist/homekit/adapters/camera-controls.js +15 -37
- package/dist/homekit/adapters/camera-controls.js.map +1 -1
- package/dist/homekit/adapters/camera-streaming.js +74 -48
- package/dist/homekit/adapters/camera-streaming.js.map +1 -1
- package/dist/homekit/adapters/contact.js +5 -5
- package/dist/homekit/adapters/contact.js.map +1 -1
- package/dist/homekit/adapters/doorbell.js +3 -15
- package/dist/homekit/adapters/doorbell.js.map +1 -1
- package/dist/homekit/adapters/lock.js +72 -221
- package/dist/homekit/adapters/lock.js.map +1 -1
- package/dist/homekit/adapters/motion.js +3 -3
- package/dist/homekit/adapters/motion.js.map +1 -1
- package/dist/homekit/adapters/package-sensor.js +63 -0
- package/dist/homekit/adapters/package-sensor.js.map +1 -0
- package/dist/homekit/adapters/registry.js +2 -0
- package/dist/homekit/adapters/registry.js.map +1 -1
- package/dist/homekit/adapters/security-system.js +106 -250
- package/dist/homekit/adapters/security-system.js.map +1 -1
- package/dist/homekit/adapters/siren.js +5 -33
- package/dist/homekit/adapters/siren.js.map +1 -1
- package/dist/homekit/adapters/smart-light.js +19 -217
- package/dist/homekit/adapters/smart-light.js.map +1 -1
- package/dist/homekit/coverage-matrix.js +8 -67
- package/dist/homekit/coverage-matrix.js.map +1 -1
- package/dist/homekit/device-control.js +249 -13
- package/dist/homekit/device-control.js.map +1 -1
- package/dist/homekit/reconciler.js +25 -9
- package/dist/homekit/reconciler.js.map +1 -1
- package/dist/media/last-successful-image.js +8 -23
- package/dist/media/last-successful-image.js.map +1 -1
- package/dist/media/live-preview.js +217 -0
- package/dist/media/live-preview.js.map +1 -0
- package/dist/media/live-stream.js +689 -160
- package/dist/media/live-stream.js.map +1 -1
- package/dist/media/recording.js +41 -118
- package/dist/media/recording.js.map +1 -1
- package/dist/media/snapshot.js +22 -17
- package/dist/media/snapshot.js.map +1 -1
- package/dist/media/station-live-sessions.js +40 -51
- package/dist/media/station-live-sessions.js.map +1 -1
- package/dist/platform.js +61 -10
- package/dist/platform.js.map +1 -1
- package/dist/runtime/channel.js +227 -40
- package/dist/runtime/channel.js.map +1 -1
- package/dist/runtime/device-settings.js +88 -0
- package/dist/runtime/device-settings.js.map +1 -0
- package/dist/runtime/owner.js +30 -5
- package/dist/runtime/owner.js.map +1 -1
- package/dist/runtime/sdk-client.js +21 -17
- package/dist/runtime/sdk-client.js.map +1 -1
- package/dist/runtime/tracker.js +8 -38
- package/dist/runtime/tracker.js.map +1 -1
- package/dist/storage.js +1 -1
- package/dist/storage.js.map +1 -1
- package/dist/ui/dashboard.js +36 -16
- package/dist/ui/dashboard.js.map +1 -1
- package/dist/ui/runtime-channel-client.js +155 -184
- package/dist/ui/runtime-channel-client.js.map +1 -1
- package/dist/ui/server.js +94 -41
- package/dist/ui/server.js.map +1 -1
- package/homebridge-ui/public/app.css +531 -239
- package/homebridge-ui/public/assets/icons/auto_fix_high.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_0.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_1.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_2.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_3.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_4.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_5.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_6.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_full.svg +1 -0
- package/homebridge-ui/public/assets/icons/contact_support.svg +1 -0
- package/homebridge-ui/public/assets/icons/delete.svg +1 -0
- package/homebridge-ui/public/assets/icons/download.svg +1 -0
- package/homebridge-ui/public/assets/icons/extension_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/info.svg +1 -0
- package/homebridge-ui/public/assets/icons/key_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_arrow_left.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_arrow_right.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_double_arrow_left.svg +1 -0
- package/homebridge-ui/public/assets/icons/keyboard_double_arrow_right.svg +1 -0
- package/homebridge-ui/public/assets/icons/movie_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/play_arrow.svg +1 -0
- package/homebridge-ui/public/assets/icons/refresh.svg +1 -0
- package/homebridge-ui/public/assets/icons/satellite_alt.svg +1 -0
- package/homebridge-ui/public/assets/icons/search_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/sensors_off.svg +1 -0
- package/homebridge-ui/public/assets/icons/settings_photo_camera.svg +1 -0
- package/homebridge-ui/public/assets/icons/solar_power.svg +1 -0
- package/homebridge-ui/public/assets/icons/stop.svg +1 -0
- package/homebridge-ui/public/assets/icons/videocam_off.svg +1 -0
- package/homebridge-ui/public/i18n/en.json +161 -82
- package/homebridge-ui/public/i18n/fr.json +162 -83
- package/homebridge-ui/public/index.html +224 -96
- package/homebridge-ui/public/js/app.js +821 -435
- package/homebridge-ui/public/js/dashboard.js +148 -31
- package/i18n/runtime/en.json +10 -3
- package/package.json +10 -11
- package/homebridge-ui/public/js/legacy-settings.js +0 -37
- package/homebridge-ui/public/js/profile-wizard.js +0 -61
|
@@ -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
|
|
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
|
|
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
|
|
69
|
-
const
|
|
70
|
-
const
|
|
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
|
|
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
|
-
/**
|
|
106
|
-
let
|
|
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(
|
|
167
|
+
timer = setTimeout(() => reject(Object.assign(new Error(REQUEST_TIMED_OUT), { late: request })), timeoutMs);
|
|
138
168
|
});
|
|
139
|
-
return Promise.race([
|
|
169
|
+
return Promise.race([request, timeout]).finally(() => clearTimeout(timer));
|
|
140
170
|
}
|
|
141
171
|
|
|
142
172
|
function isDashboardUiReproducing(state = diagnosticsState) {
|
|
143
|
-
return
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
},
|
|
219
|
-
'
|
|
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
|
-
*
|
|
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
|
-
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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,133 @@ function renderDiagnosticsWizard() {
|
|
|
340
279
|
*/
|
|
341
280
|
function setIssueStepReachable(url) {
|
|
342
281
|
diagnosticsIssue.href = url;
|
|
343
|
-
|
|
344
|
-
|
|
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 =
|
|
301
|
+
const screen = diagnosticsScreen(state);
|
|
351
302
|
const choosing = screen === 'choose';
|
|
352
303
|
const reviewing = screen === 'review';
|
|
353
|
-
const
|
|
354
|
-
|
|
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
|
+
const nothingRecorded = isNothingRecorded(state);
|
|
305
|
+
renderDiagnosticsEntries();
|
|
363
306
|
diagnosticsWizardPanel.hidden = !choosing;
|
|
364
307
|
diagnosticsGuidance.hidden = screen !== 'reproduce';
|
|
365
|
-
diagnosticsGuidanceBeforeSection.hidden = state.status === 'reproducing';
|
|
366
308
|
diagnosticsActions.hidden = screen !== 'reproduce';
|
|
367
|
-
diagnosticsReproduction.disabled =
|
|
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;
|
|
309
|
+
diagnosticsReproduction.disabled = state.status !== 'reproducing';
|
|
377
310
|
setIssueStepReachable(diagnosticsArchiveDownloaded ? (state.issueUrl ?? '') : '');
|
|
378
|
-
|
|
311
|
+
const offering = (reviewing || diagnosticsArchiveDownloaded) && !diagnosticsPanel.hidden;
|
|
312
|
+
if (offering && !diagnosticsResult.open) diagnosticsResult.showModal?.();
|
|
313
|
+
if (!offering && diagnosticsResult.open) diagnosticsResult.close?.();
|
|
314
|
+
diagnosticsResultHeading.textContent =
|
|
315
|
+
messages[
|
|
316
|
+
diagnosticsArchiveDownloaded
|
|
317
|
+
? 'diagnosticsArchiveDownloaded'
|
|
318
|
+
: state.reproductionEndedAt && state.reproductionEndedAt === state.expiresAt
|
|
319
|
+
? 'diagnosticsCaptureTimedOut'
|
|
320
|
+
: 'diagnosticsEvidenceReady'
|
|
321
|
+
] ?? '';
|
|
379
322
|
const reviewed = reviewing && diagnosticsReviewedCaseId === (state.supportCaseId ?? '');
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
323
|
+
diagnosticsExport.hidden = !reviewed || diagnosticsArchiveDownloaded;
|
|
324
|
+
diagnosticsHandoff.hidden = !diagnosticsArchiveDownloaded;
|
|
325
|
+
diagnosticsHandoffNote.hidden = !diagnosticsArchiveDownloaded;
|
|
326
|
+
if (!offering) diagnosticsResultStatus.textContent = '';
|
|
327
|
+
else if (!diagnosticsArchiveDownloaded && state.missingEvidence?.length) {
|
|
328
|
+
diagnosticsResultStatus.textContent =
|
|
329
|
+
messages[nothingRecorded ? 'diagnosticsNothingRecorded' : 'diagnosticsMissingEvidence'] ?? '';
|
|
330
|
+
}
|
|
331
|
+
diagnosticsDownloadAgain.hidden = !diagnosticsArchiveDownloaded;
|
|
384
332
|
if (!reviewed) {
|
|
385
|
-
diagnosticsReviewConfirm.checked = false;
|
|
386
333
|
diagnosticsExport.disabled = true;
|
|
387
334
|
diagnosticsReviewId = '';
|
|
388
|
-
if (reviewing) void ensureArchiveReview(state.supportCaseId ?? '');
|
|
335
|
+
if (reviewing && !diagnosticsArchiveDownloaded) void ensureArchiveReview(state.supportCaseId ?? '');
|
|
389
336
|
}
|
|
390
337
|
if (screen === 'reproduce') {
|
|
391
338
|
renderDiagnosticsGuidance(state.profile);
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
: 'diagnosticsIntermittentReadyGuidance'
|
|
399
|
-
] ?? '';
|
|
400
|
-
}
|
|
339
|
+
const stopsAt = new Date(state.expiresAt).toLocaleString(shell.lang || undefined, {
|
|
340
|
+
weekday: 'long',
|
|
341
|
+
hour: 'numeric',
|
|
342
|
+
minute: '2-digit',
|
|
343
|
+
});
|
|
344
|
+
diagnosticsCaptureNote.textContent = (messages.diagnosticsCaptureKeepsRunning ?? '').replace('{time}', stopsAt);
|
|
401
345
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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(', ') ?? '');
|
|
346
|
+
diagnosticsStatus.textContent = '';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Whether a finished capture holds no record of any log it selected, so its file carries nothing to diagnose. */
|
|
350
|
+
function isNothingRecorded(state) {
|
|
351
|
+
return state.status === 'complete' && state.selectedEvidence?.length > 0 &&
|
|
352
|
+
state.missingEvidence?.length === state.selectedEvidence.length;
|
|
419
353
|
}
|
|
420
354
|
|
|
421
355
|
/**
|
|
422
|
-
*
|
|
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.
|
|
356
|
+
* Marks both diagnostics actions while a capture runs, whichever area it is on, and otherwise the dashboard's own
|
|
357
|
+
* while it shows a problem.
|
|
426
358
|
*/
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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);
|
|
359
|
+
function renderDiagnosticsEntries() {
|
|
360
|
+
const collecting = diagnosticsState.status === 'reproducing';
|
|
361
|
+
for (const entry of [mastheadDiagnostics, menuDiagnostics]) {
|
|
362
|
+
const attending = !collecting && entry === menuDiagnostics && dashboardAttention !== undefined;
|
|
363
|
+
if (collecting) entry.dataset.collecting = 'true';
|
|
364
|
+
else delete entry.dataset.collecting;
|
|
365
|
+
if (attending) entry.dataset.attention = 'true';
|
|
366
|
+
else delete entry.dataset.attention;
|
|
367
|
+
entry.setAttribute(
|
|
368
|
+
'aria-label',
|
|
369
|
+
messages[collecting ? 'diagnosticsCollectingFinishHere' : attending ? 'diagnosticsAttention' : 'menuDiagnostics'] ??
|
|
370
|
+
'',
|
|
371
|
+
);
|
|
475
372
|
}
|
|
476
|
-
diagnosticsManifest.hidden = false;
|
|
477
373
|
}
|
|
478
374
|
|
|
479
375
|
/**
|
|
480
|
-
* The
|
|
376
|
+
* The reviewed archive for the session on screen, fetched once.
|
|
481
377
|
*
|
|
482
|
-
* A completed reproduction that the reporter is looking at is a file they came for, so the
|
|
483
|
-
*
|
|
484
|
-
* reads every collected log. Exporting spends it,
|
|
485
|
-
* asks for a fresh one.
|
|
378
|
+
* A completed reproduction that the reporter is looking at is a file they came for, so the archive is
|
|
379
|
+
* reviewed without being asked for. It is reviewed once per session rather than per render, because
|
|
380
|
+
* assembling it reads every collected log. Exporting spends it, and ends the session with it.
|
|
486
381
|
*/
|
|
487
|
-
|
|
488
|
-
if (!caseId || diagnosticsReviewedCaseId === caseId
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
}
|
|
382
|
+
function ensureArchiveReview(caseId) {
|
|
383
|
+
if (!caseId || diagnosticsReviewedCaseId === caseId) return Promise.resolve();
|
|
384
|
+
diagnosticsReviewRequest ??= (async () => {
|
|
385
|
+
try {
|
|
386
|
+
const review = await requestWithinDeadline('/diagnostics/archive/review', undefined, 12000);
|
|
387
|
+
diagnosticsReviewId = review.reviewId;
|
|
388
|
+
diagnosticsReviewedCaseId = caseId;
|
|
389
|
+
diagnosticsExport.hidden = false;
|
|
390
|
+
diagnosticsExport.disabled = false;
|
|
391
|
+
} catch {
|
|
392
|
+
diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
|
|
393
|
+
} finally {
|
|
394
|
+
diagnosticsReviewRequest = undefined;
|
|
395
|
+
}
|
|
396
|
+
})();
|
|
397
|
+
return diagnosticsReviewRequest;
|
|
504
398
|
}
|
|
505
399
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
400
|
+
/**
|
|
401
|
+
* Hands the archive of the session on screen over as a download.
|
|
402
|
+
*
|
|
403
|
+
* Finishing a reproduction calls this without being asked, since the file is what the reporter finished for; the
|
|
404
|
+
* dialog's own button is the way to try again after a failed attempt.
|
|
405
|
+
*/
|
|
406
|
+
async function downloadDiagnosticsArchive() {
|
|
407
|
+
await ensureArchiveReview(diagnosticsState.supportCaseId ?? '');
|
|
408
|
+
if (!diagnosticsReviewId) return;
|
|
512
409
|
diagnosticsExport.disabled = true;
|
|
513
410
|
try {
|
|
514
411
|
const exported = await requestWithinDeadline(
|
|
@@ -519,138 +416,119 @@ diagnosticsExport.addEventListener('click', async () => {
|
|
|
519
416
|
const download = document.createElement('a');
|
|
520
417
|
download.href = `data:${exported.mediaType};base64,${exported.archive}`;
|
|
521
418
|
download.download = exported.filename;
|
|
419
|
+
diagnosticsHandoffNote.textContent = (messages.diagnosticsArchiveHandoff ?? '').replace(
|
|
420
|
+
'{filename}',
|
|
421
|
+
exported.filename,
|
|
422
|
+
);
|
|
423
|
+
diagnosticsResultStatus.textContent = '';
|
|
522
424
|
document.body.appendChild(download);
|
|
523
425
|
download.click();
|
|
524
426
|
document.body.removeChild(download);
|
|
525
427
|
diagnosticsReviewId = '';
|
|
526
428
|
diagnosticsReviewedCaseId = '';
|
|
527
|
-
diagnosticsReviewConfirm.checked = false;
|
|
528
429
|
diagnosticsArchiveDownloaded = true;
|
|
529
|
-
|
|
430
|
+
renderDiagnostics(diagnosticsState);
|
|
431
|
+
diagnosticsResultHeading.focus?.();
|
|
530
432
|
} catch {
|
|
531
|
-
|
|
433
|
+
// The plugin spends a review on any attempt, so trying again asks for a fresh one.
|
|
434
|
+
diagnosticsReviewId = '';
|
|
435
|
+
diagnosticsReviewedCaseId = '';
|
|
436
|
+
diagnosticsExport.disabled = false;
|
|
437
|
+
diagnosticsResultStatus.textContent = messages.diagnosticsFailed ?? '';
|
|
532
438
|
}
|
|
533
|
-
}
|
|
439
|
+
}
|
|
534
440
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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?.();
|
|
441
|
+
diagnosticsExport.addEventListener('click', downloadDiagnosticsArchive);
|
|
442
|
+
diagnosticsDownloadAgain.addEventListener('click', downloadDiagnosticsArchive);
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Draws the session as the plugin holds it after `error`, and says so unless it reached `expected`, inside the
|
|
446
|
+
* download dialog while that covers the panel. A request that timed out may still land, so its late answer draws
|
|
447
|
+
* the session again.
|
|
448
|
+
*/
|
|
449
|
+
async function redrawDiagnosticsAfterFailure(expected, error) {
|
|
450
|
+
const refreshed = await settleDiagnostics().catch(() => undefined);
|
|
451
|
+
if (refreshed?.status !== expected) {
|
|
452
|
+
(diagnosticsResult.open ? diagnosticsResultStatus : diagnosticsStatus).textContent = messages.diagnosticsFailed ?? '';
|
|
567
453
|
}
|
|
568
|
-
|
|
454
|
+
error?.late?.then(settleDiagnostics).catch(() => undefined);
|
|
455
|
+
}
|
|
569
456
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
457
|
+
/**
|
|
458
|
+
* Draws the session the plugin holds. One opened but never started is what a failed or late pick leaves behind;
|
|
459
|
+
* nothing on screen stands for it and it keeps detailed logging on, so it is cancelled rather than drawn.
|
|
460
|
+
*/
|
|
461
|
+
async function settleDiagnostics() {
|
|
462
|
+
let state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
|
|
463
|
+
if (state.status === 'authorized') state = await requestWithinDeadline('/diagnostics/cancel', undefined, 12000);
|
|
464
|
+
renderDiagnostics(state);
|
|
465
|
+
return state;
|
|
577
466
|
}
|
|
578
467
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
468
|
+
/**
|
|
469
|
+
* Captures the area picked. The area is the only answer a capture needs, so the pick opens the session and starts
|
|
470
|
+
* it in one go.
|
|
471
|
+
*/
|
|
472
|
+
async function startDiagnosticsCapture(profile) {
|
|
473
|
+
for (const tile of diagnosticsTiles) tile.disabled = true;
|
|
474
|
+
try {
|
|
475
|
+
await requestWithinDeadline('/diagnostics/authorize', { profile }, 12000);
|
|
476
|
+
const state = await requestWithinDeadline('/diagnostics/reproduction/start', undefined, 12000);
|
|
477
|
+
renderDiagnostics(state);
|
|
478
|
+
if (isDashboardUiReproducing(state)) {
|
|
479
|
+
await recordActiveUiEvent('background-started').catch(() => undefined);
|
|
480
|
+
closeDashboardPanel();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
diagnosticsPhaseTitle.focus?.();
|
|
484
|
+
} catch (error) {
|
|
485
|
+
await redrawDiagnosticsAfterFailure('reproducing', error);
|
|
486
|
+
} finally {
|
|
487
|
+
for (const tile of diagnosticsTiles) tile.disabled = false;
|
|
587
488
|
}
|
|
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
489
|
}
|
|
601
490
|
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
});
|
|
491
|
+
for (const tile of diagnosticsTiles) {
|
|
492
|
+
tile.addEventListener('click', () => startDiagnosticsCapture(tile.dataset.diagnosticsTile));
|
|
493
|
+
}
|
|
616
494
|
|
|
617
|
-
diagnosticsStartAnother.addEventListener('click',
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
495
|
+
diagnosticsStartAnother.addEventListener('click', endDiagnosticsCase);
|
|
496
|
+
/** Escape leaves the archive dialog only once its file is downloaded, and leaving it finishes the session. */
|
|
497
|
+
diagnosticsResult.addEventListener('cancel', (event) => {
|
|
498
|
+
event.preventDefault();
|
|
499
|
+
if (diagnosticsArchiveDownloaded) endDiagnosticsCase();
|
|
622
500
|
});
|
|
623
501
|
|
|
624
502
|
diagnosticsReproduction.addEventListener('click', async () => {
|
|
625
503
|
diagnosticsReproduction.disabled = true;
|
|
626
|
-
const previousStatus = diagnosticsState.status;
|
|
627
|
-
const path =
|
|
628
|
-
diagnosticsState.status === 'reproducing' ? '/diagnostics/reproduction/end' : '/diagnostics/reproduction/start';
|
|
629
504
|
try {
|
|
630
|
-
let state = await requestWithinDeadline(
|
|
505
|
+
let state = await requestWithinDeadline('/diagnostics/reproduction/end', undefined, 12000);
|
|
631
506
|
if (state.status === 'complete' && state.missingEvidence?.length) {
|
|
632
507
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
633
508
|
state = await requestWithinDeadline('/diagnostics/status', undefined, 12000);
|
|
634
509
|
}
|
|
635
510
|
renderDiagnostics(state);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
511
|
+
} catch (error) {
|
|
512
|
+
await redrawDiagnosticsAfterFailure('complete', error);
|
|
513
|
+
} finally {
|
|
514
|
+
diagnosticsReproduction.disabled = diagnosticsState.status !== 'reproducing';
|
|
515
|
+
}
|
|
516
|
+
if (diagnosticsState.partialExportAvailable && !isNothingRecorded(diagnosticsState)) {
|
|
517
|
+
diagnosticsResultHeading.focus?.();
|
|
518
|
+
await downloadDiagnosticsArchive();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
/** Cancelling a capture deletes its session without a file, and puts the areas back on the panel. */
|
|
523
|
+
diagnosticsCancel.addEventListener('click', async () => {
|
|
524
|
+
diagnosticsCancel.disabled = true;
|
|
525
|
+
try {
|
|
526
|
+
renderDiagnostics(await requestWithinDeadline('/diagnostics/cancel', undefined, 12000));
|
|
527
|
+
diagnosticsQuestionText.focus?.();
|
|
528
|
+
} catch (error) {
|
|
529
|
+
await redrawDiagnosticsAfterFailure('inactive', error);
|
|
652
530
|
} finally {
|
|
653
|
-
|
|
531
|
+
diagnosticsCancel.disabled = false;
|
|
654
532
|
}
|
|
655
533
|
});
|
|
656
534
|
|
|
@@ -678,7 +556,7 @@ function openDashboardPanel(panel, trigger) {
|
|
|
678
556
|
dashboardState.hidden = insideDashboard;
|
|
679
557
|
dashboardSummary.hidden = insideDashboard;
|
|
680
558
|
deviceGroups.hidden = insideDashboard;
|
|
681
|
-
for (const other of [diagnosticsPanel, advancedPanel, devicePanel]) {
|
|
559
|
+
for (const other of [diagnosticsPanel, advancedPanel, devicePanel, wizardPanel]) {
|
|
682
560
|
other.hidden = other !== panel;
|
|
683
561
|
}
|
|
684
562
|
panel.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
|
|
@@ -690,6 +568,8 @@ function closeDashboardPanel() {
|
|
|
690
568
|
diagnosticsPanel.hidden = true;
|
|
691
569
|
advancedPanel.hidden = true;
|
|
692
570
|
devicePanel.hidden = true;
|
|
571
|
+
wizardPanel.hidden = true;
|
|
572
|
+
stopWizardLive();
|
|
693
573
|
dashboardState.hidden = false;
|
|
694
574
|
dashboardSummary.hidden = false;
|
|
695
575
|
deviceGroups.hidden = false;
|
|
@@ -707,21 +587,46 @@ function closeDashboardPanel() {
|
|
|
707
587
|
}
|
|
708
588
|
}
|
|
709
589
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
590
|
+
/**
|
|
591
|
+
* Opens the diagnostics panel. A preset is an area the reporter already chose to diagnose from a problem the
|
|
592
|
+
* dashboard named beside the privacy note, so a panel with nothing under way captures it at once. Opened over the
|
|
593
|
+
* sign-in screen, the panel asks like any other and puts focus on the startup area.
|
|
594
|
+
*/
|
|
595
|
+
async function openDiagnostics(preset) {
|
|
596
|
+
const signingIn = !setupContent.hidden;
|
|
716
597
|
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
598
|
try {
|
|
720
599
|
renderDiagnostics(await requestWithinDeadline('/diagnostics/status', undefined, 12000));
|
|
721
600
|
} catch {
|
|
722
601
|
diagnosticsStatus.textContent = messages.diagnosticsFailed ?? '';
|
|
723
602
|
recordActiveUiEventBestEffort('request-failed');
|
|
603
|
+
return;
|
|
724
604
|
}
|
|
605
|
+
if (diagnosticsScreen(diagnosticsState) !== 'choose') return;
|
|
606
|
+
if (preset) await startDiagnosticsCapture(preset.profile);
|
|
607
|
+
else if (signingIn) {
|
|
608
|
+
diagnosticsTiles.find((tile) => tile.dataset.diagnosticsTile === 'startup-authentication')?.focus?.();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
mastheadDiagnostics.addEventListener('click', () => openDiagnostics());
|
|
613
|
+
|
|
614
|
+
/** While the dashboard shows a problem and no session is under way, the action names it before opening the wizard. */
|
|
615
|
+
menuDiagnostics.addEventListener('click', () => {
|
|
616
|
+
if (!menuDiagnostics.dataset.attention || diagnosticsScreen(diagnosticsState) !== 'choose') {
|
|
617
|
+
return openDiagnostics();
|
|
618
|
+
}
|
|
619
|
+
attentionTitle.textContent = dashboardAttention.title;
|
|
620
|
+
attentionSummary.textContent = dashboardAttention.summary;
|
|
621
|
+
attentionDialog.showModal?.();
|
|
622
|
+
});
|
|
623
|
+
attentionDiagnose.addEventListener('click', () => {
|
|
624
|
+
attentionDialog.close?.();
|
|
625
|
+
return openDiagnostics(dashboardAttention);
|
|
626
|
+
});
|
|
627
|
+
attentionChoose.addEventListener('click', () => {
|
|
628
|
+
attentionDialog.close?.();
|
|
629
|
+
return openDiagnostics();
|
|
725
630
|
});
|
|
726
631
|
menuAdvanced.addEventListener('click', async () => {
|
|
727
632
|
const config = configuredBlock() ?? {};
|
|
@@ -731,6 +636,11 @@ menuAdvanced.addEventListener('click', async () => {
|
|
|
731
636
|
warmUpMarked = new Set();
|
|
732
637
|
renderWarmUp();
|
|
733
638
|
advancedFfmpeg.value = config.ffmpegPath ?? '';
|
|
639
|
+
advancedSmallVideoPackets.checked = config.smallVideoPackets === true;
|
|
640
|
+
advancedLiveVideoPassthrough.checked = config.liveVideoPassthrough === true;
|
|
641
|
+
advancedHardwareVideoEncoder.checked = config.hardwareVideoEncoder !== false;
|
|
642
|
+
advancedHdLiveVideo.checked = config.hdLiveVideo === true;
|
|
643
|
+
advancedIgnoreHomeBitRate.checked = config.ignoreHomeBitRate === true;
|
|
734
644
|
openDashboardPanel(advancedPanel, menuAdvanced);
|
|
735
645
|
/**
|
|
736
646
|
* The panel asks for its own candidates rather than relying on the devices view having been opened first.
|
|
@@ -748,9 +658,8 @@ menuAdvanced.addEventListener('click', async () => {
|
|
|
748
658
|
}
|
|
749
659
|
});
|
|
750
660
|
diagnosticsClose.addEventListener('click', () => {
|
|
751
|
-
diagnosticsStartingAnother = false;
|
|
752
|
-
renderDiagnostics(diagnosticsState);
|
|
753
661
|
closeDashboardPanel();
|
|
662
|
+
renderDiagnostics(diagnosticsState);
|
|
754
663
|
});
|
|
755
664
|
advancedClose.addEventListener('click', () => {
|
|
756
665
|
closeDashboardPanel();
|
|
@@ -787,6 +696,16 @@ async function updateAdvancedSettings() {
|
|
|
787
696
|
else next.maxConcurrentMediaSessions = maxConcurrentMediaSessions;
|
|
788
697
|
if (ffmpegPath) next.ffmpegPath = ffmpegPath;
|
|
789
698
|
else delete next.ffmpegPath;
|
|
699
|
+
if (advancedSmallVideoPackets.checked) next.smallVideoPackets = true;
|
|
700
|
+
else delete next.smallVideoPackets;
|
|
701
|
+
if (advancedLiveVideoPassthrough.checked) next.liveVideoPassthrough = true;
|
|
702
|
+
else delete next.liveVideoPassthrough;
|
|
703
|
+
if (advancedHardwareVideoEncoder.checked) delete next.hardwareVideoEncoder;
|
|
704
|
+
else next.hardwareVideoEncoder = false;
|
|
705
|
+
if (advancedHdLiveVideo.checked) next.hdLiveVideo = true;
|
|
706
|
+
else delete next.hdLiveVideo;
|
|
707
|
+
if (advancedIgnoreHomeBitRate.checked) next.ignoreHomeBitRate = true;
|
|
708
|
+
else delete next.ignoreHomeBitRate;
|
|
790
709
|
// The default is what the plugin applies when the key is absent, so storing it would only pin today's default.
|
|
791
710
|
if (warmUpSelection.length === 1 && warmUpSelection[0] === 'doorbellPress') delete next.warmUpEvents;
|
|
792
711
|
else next.warmUpEvents = [...warmUpSelection];
|
|
@@ -802,6 +721,11 @@ async function updateAdvancedSettings() {
|
|
|
802
721
|
advancedPolling.addEventListener('change', updateAdvancedSettings);
|
|
803
722
|
advancedConcurrentMedia.addEventListener('change', updateAdvancedSettings);
|
|
804
723
|
advancedFfmpeg.addEventListener('change', updateAdvancedSettings);
|
|
724
|
+
advancedSmallVideoPackets.addEventListener('change', updateAdvancedSettings);
|
|
725
|
+
advancedLiveVideoPassthrough.addEventListener('change', updateAdvancedSettings);
|
|
726
|
+
advancedHardwareVideoEncoder.addEventListener('change', updateAdvancedSettings);
|
|
727
|
+
advancedHdLiveVideo.addEventListener('change', updateAdvancedSettings);
|
|
728
|
+
advancedIgnoreHomeBitRate.addEventListener('change', updateAdvancedSettings);
|
|
805
729
|
warmUpAdd.addEventListener('click', () => moveWarmUp('available'));
|
|
806
730
|
warmUpAddAll.addEventListener('click', () => moveWarmUp('available', true));
|
|
807
731
|
warmUpRemove.addEventListener('click', () => moveWarmUp('chosen'));
|
|
@@ -920,7 +844,10 @@ function configSignature(config) {
|
|
|
920
844
|
async function updateConfig(block) {
|
|
921
845
|
const nextConfig = pluginConfig.map((candidate) => (candidate.platform === 'HomebridgeEufy' ? block : candidate));
|
|
922
846
|
if (!nextConfig.includes(block)) nextConfig.push(block);
|
|
923
|
-
|
|
847
|
+
// Homebridge UI merges each block into the one it holds, so a key the block dropped is sent as undefined to clear it.
|
|
848
|
+
const cleared = Object.keys(configuredBlock() ?? {}).filter((key) => !(key in block));
|
|
849
|
+
const sent = { ...Object.fromEntries(cleared.map((key) => [key, undefined])), ...block };
|
|
850
|
+
await homebridge.updatePluginConfig(nextConfig.map((candidate) => (candidate === block ? sent : candidate)));
|
|
924
851
|
pluginConfig = nextConfig;
|
|
925
852
|
if (configSignature(nextConfig) === savedConfigSignature) homebridge.disableSaveButton();
|
|
926
853
|
else homebridge.enableSaveButton();
|
|
@@ -933,15 +860,422 @@ dashboardView.bindPreferences(dashboardElements, configuredBlock, updateConfig,
|
|
|
933
860
|
openDashboardPanel(devicePanel, trigger);
|
|
934
861
|
});
|
|
935
862
|
|
|
936
|
-
|
|
863
|
+
/*
|
|
864
|
+
* The setup wizard walks every device once a sign-in has a running plugin to show it: each camera live, the eufy
|
|
865
|
+
* settings the plugin can change, and the HomeKit choices. A sign-in always leaves Homebridge to restart, so it
|
|
866
|
+
* marks the wizard pending in this browser and the next page opened on a ready plugin starts it.
|
|
867
|
+
*/
|
|
868
|
+
let dashboardPluginState;
|
|
869
|
+
let wizardSteps = [];
|
|
870
|
+
let wizardIndex = 0;
|
|
871
|
+
let wizardLiveSerial;
|
|
872
|
+
let wizardLivePictures = 0;
|
|
873
|
+
const wizardElements = {
|
|
874
|
+
deviceSettingsTitle: wizardTitle,
|
|
875
|
+
deviceSettingsEyebrow: wizardEyebrow,
|
|
876
|
+
deviceSettingsControls: wizardControls,
|
|
877
|
+
};
|
|
878
|
+
/** The eufy settings the wizard offers, each with the label its control is named by. */
|
|
879
|
+
const EUFY_SETTING_LABELS = {
|
|
880
|
+
motionDetection: 'wizardMotionDetection',
|
|
881
|
+
nightVision: 'wizardNightVision',
|
|
882
|
+
notificationStyle: 'wizardNotificationStyle',
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
/** Whether a plugin is running to stream and change settings: a degraded one still holds its devices. */
|
|
886
|
+
function wizardPluginUp() {
|
|
887
|
+
return dashboardPluginState === 'ready' || dashboardPluginState === 'degraded';
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** The browser key marking a sign-in whose devices have not been walked through yet. */
|
|
891
|
+
const WIZARD_PENDING_KEY = 'homebridge-eufy.setup-wizard-pending';
|
|
892
|
+
|
|
893
|
+
function wizardPending(pending) {
|
|
894
|
+
try {
|
|
895
|
+
if (pending === undefined) return localStorage.getItem(WIZARD_PENDING_KEY) === 'pending';
|
|
896
|
+
if (pending) localStorage.setItem(WIZARD_PENDING_KEY, 'pending');
|
|
897
|
+
else localStorage.removeItem(WIZARD_PENDING_KEY);
|
|
898
|
+
} catch {
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
return pending;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* The devices the wizard walks through, in the map's order so the arrows follow it: each HomeBase then what hangs
|
|
906
|
+
* off it, then the devices that stand alone. Every camera is one, and every other device with a HomeKit choice to
|
|
907
|
+
* make; a device the plugin only diagnoses has nothing to set.
|
|
908
|
+
*/
|
|
909
|
+
function setupWizardSteps(devices) {
|
|
910
|
+
const walkable = (devices ?? []).filter(
|
|
911
|
+
(device) => !device.diagnosticOnly && (device.deviceClass === 'camera' || device.preferences?.length > 0),
|
|
912
|
+
);
|
|
913
|
+
const { bases, standalone } = setupWizardTopology(walkable, devices);
|
|
914
|
+
return [
|
|
915
|
+
...bases.flatMap(({ base, members }) => [...(base && walkable.includes(base) ? [base] : []), ...members]),
|
|
916
|
+
...standalone,
|
|
917
|
+
];
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* The household as the map draws it: each HomeBase with the devices that hang off it, then the devices that are
|
|
922
|
+
* their own station. A base is named by its own entry where the dashboard lists one; a device whose base it does
|
|
923
|
+
* not list stays with that base all the same, under no name.
|
|
924
|
+
*/
|
|
925
|
+
function setupWizardTopology(steps, devices) {
|
|
926
|
+
const bases = new Map();
|
|
927
|
+
const standalone = [];
|
|
928
|
+
for (const device of devices ?? []) {
|
|
929
|
+
if (device.deviceClass === 'homebase') bases.set(device.serial, { base: device, members: [] });
|
|
930
|
+
}
|
|
931
|
+
for (const device of steps) {
|
|
932
|
+
if (device.deviceClass === 'homebase') continue;
|
|
933
|
+
if (!device.station) {
|
|
934
|
+
standalone.push(device);
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
if (!bases.has(device.station)) bases.set(device.station, { base: undefined, members: [] });
|
|
938
|
+
bases.get(device.station).members.push(device);
|
|
939
|
+
}
|
|
940
|
+
return { bases: [...bases.values()], standalone };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function openSetupWizard(trigger) {
|
|
944
|
+
wizardSteps = setupWizardSteps(dashboardDevices);
|
|
945
|
+
wizardIndex = 0;
|
|
946
|
+
openDashboardPanel(wizardPanel, trigger);
|
|
947
|
+
await renderWizardStep();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* The advanced form's live video settings by name. A camera may keep its own value of those in
|
|
952
|
+
* {@link WIZARD_LIVE_CAMERA}; the others are general, so the card's toggle for one is the advanced form's own.
|
|
953
|
+
*/
|
|
954
|
+
const wizardLiveSettings = {
|
|
955
|
+
liveVideoPassthrough: advancedLiveVideoPassthrough,
|
|
956
|
+
ignoreHomeBitRate: advancedIgnoreHomeBitRate,
|
|
957
|
+
hardwareVideoEncoder: advancedHardwareVideoEncoder,
|
|
958
|
+
smallVideoPackets: advancedSmallVideoPackets,
|
|
959
|
+
};
|
|
960
|
+
const WIZARD_LIVE_CAMERA = ['liveVideoPassthrough', 'ignoreHomeBitRate'];
|
|
961
|
+
|
|
962
|
+
/** The ask and settings the preview's HomeKit side is coded with, as its controls stand. Never saved. */
|
|
963
|
+
function wizardLiveAdaptation() {
|
|
964
|
+
const fields = wizardLiveControls.elements;
|
|
965
|
+
const settings = Object.keys(wizardLiveSettings).map((name) => [name, fields[name].checked]);
|
|
966
|
+
return { ask: fields.ask.value, ...Object.fromEntries(settings) };
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function stopWizardLive() {
|
|
970
|
+
wizardLiveHomeKitSpinner.hidden = true;
|
|
971
|
+
wizardLiveEncoder.textContent = messages.wizardLiveHomeKit ?? '';
|
|
972
|
+
wizardLiveDiagnose.hidden = true;
|
|
973
|
+
if (!wizardLiveSerial) return;
|
|
974
|
+
wizardLiveSerial = undefined;
|
|
975
|
+
wizardLivePictures = 0;
|
|
976
|
+
wizardLivePlay.hidden = false;
|
|
977
|
+
wizardLiveStop.hidden = true;
|
|
978
|
+
wizardLiveSpinner.hidden = true;
|
|
979
|
+
wizardLiveStatus.textContent = '';
|
|
980
|
+
void requestWithinDeadline('/device/live/stop', {}, 12000).catch(() => undefined);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Shows a camera's box with its last picture behind the play button, so the step costs nothing until asked.
|
|
985
|
+
* A camera with no picture yet shows the empty box.
|
|
986
|
+
*/
|
|
987
|
+
async function showWizardCamera(serial, enabled) {
|
|
988
|
+
wizardLive.hidden = false;
|
|
989
|
+
wizardLiveImage.hidden = true;
|
|
990
|
+
const own = configuredBlock()?.entityPreferences?.[serial] ?? {};
|
|
991
|
+
for (const [name, input] of Object.entries(wizardLiveSettings)) {
|
|
992
|
+
wizardLiveControls.elements[name].checked = own[name] ?? input.checked;
|
|
993
|
+
}
|
|
994
|
+
// A camera switched off in the eufy app has nothing to stream, so it offers no play, only the reason.
|
|
995
|
+
wizardLivePlay.hidden = enabled === false;
|
|
996
|
+
wizardLiveStatus.textContent = enabled === false ? (messages.deviceStatusSwitchedOff ?? '') : '';
|
|
997
|
+
let answer;
|
|
998
|
+
try {
|
|
999
|
+
answer = await requestWithinDeadline('/device/image', { serial }, 12000);
|
|
1000
|
+
} catch {
|
|
1001
|
+
answer = undefined;
|
|
1002
|
+
}
|
|
1003
|
+
if (!answer?.image || wizardSteps[wizardIndex]?.serial !== serial || wizardLiveSerial) return;
|
|
1004
|
+
wizardLiveImage.src = `data:image/jpeg;base64,${answer.image}`;
|
|
1005
|
+
wizardLiveImage.hidden = false;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function startWizardLive(serial) {
|
|
1009
|
+
stopWizardLive();
|
|
1010
|
+
wizardLiveSerial = serial;
|
|
1011
|
+
wizardLivePlay.hidden = true;
|
|
1012
|
+
wizardLiveSpinner.hidden = false;
|
|
1013
|
+
wizardLiveStatus.textContent = messages.wizardLiveStarting ?? '';
|
|
1014
|
+
const body = { serial, adaptation: wizardLiveAdaptation() };
|
|
1015
|
+
void requestWithinDeadline('/device/live/start', body, 12000).catch(() => endWizardLive(serial));
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Ends the stream on screen, saying why HomeKit got no picture: coded video that decoded to nothing, or no video. */
|
|
1019
|
+
function endWizardLive(serial, unplayable) {
|
|
1020
|
+
if (serial !== wizardLiveSerial) return;
|
|
1021
|
+
const failure = unplayable ? messages.wizardLiveUnplayable : wizardLivePictures ? '' : messages.wizardLiveFailed;
|
|
1022
|
+
wizardLiveSerial = undefined;
|
|
1023
|
+
wizardLivePictures = 0;
|
|
1024
|
+
wizardLiveStatus.textContent = failure ?? '';
|
|
1025
|
+
wizardLiveDiagnose.hidden = !failure;
|
|
1026
|
+
wizardLivePlay.hidden = false;
|
|
1027
|
+
wizardLiveStop.hidden = true;
|
|
1028
|
+
wizardLiveSpinner.hidden = true;
|
|
1029
|
+
wizardLiveHomeKitSpinner.hidden = true;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
homebridge.addEventListener('device-live', (event) => {
|
|
1033
|
+
if (!event.data || event.data.serial !== wizardLiveSerial) return;
|
|
1034
|
+
wizardLivePictures += 1;
|
|
1035
|
+
// The stop is offered once there is video to stop; until then the spinner says it is on its way.
|
|
1036
|
+
wizardLiveSpinner.hidden = true;
|
|
1037
|
+
wizardLiveStop.hidden = false;
|
|
1038
|
+
const homekit = event.data.side === 'homekit';
|
|
1039
|
+
// The first picture clears the connecting text; after that only the HomeKit side changes what the status says.
|
|
1040
|
+
if (homekit || wizardLivePictures === 1) wizardLiveStatus.textContent = '';
|
|
1041
|
+
if (homekit) wizardLiveHomeKitSpinner.hidden = true;
|
|
1042
|
+
// An empty HomeKit picture is a run that coded nothing watchable while the camera plays on.
|
|
1043
|
+
if (homekit && !event.data.image) {
|
|
1044
|
+
wizardLiveHomeKit.hidden = true;
|
|
1045
|
+
wizardLiveStatus.textContent = messages.wizardLiveUnplayable ?? '';
|
|
1046
|
+
wizardLiveDiagnose.hidden = false;
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
const image = homekit ? wizardLiveHomeKit : wizardLiveImage;
|
|
1050
|
+
image.src = `data:image/jpeg;base64,${event.data.image}`;
|
|
1051
|
+
image.hidden = false;
|
|
1052
|
+
if (!homekit || typeof event.data.kbps !== 'number') return;
|
|
1053
|
+
// The rate is the encoder's own output, the video HomeKit would be sent, not these pictures.
|
|
1054
|
+
const encoder = event.data.encoder === 'copy' ? messages.wizardLiveCamera : event.data.encoder;
|
|
1055
|
+
wizardLiveEncoder.textContent = (messages.wizardLiveEncoder ?? '')
|
|
1056
|
+
.replace('{encoder}', encoder ?? '')
|
|
1057
|
+
.replace('{kbps}', String(event.data.kbps));
|
|
1058
|
+
});
|
|
1059
|
+
// Only the HomeKit side restarts: its half waits under a spinner while the camera plays on.
|
|
1060
|
+
wizardLiveControls.addEventListener('change', (event) => {
|
|
1061
|
+
const name = event.target?.name;
|
|
1062
|
+
if (wizardLiveSettings[name] && !WIZARD_LIVE_CAMERA.includes(name)) {
|
|
1063
|
+
wizardLiveSettings[name].checked = event.target.checked;
|
|
1064
|
+
void updateAdvancedSettings();
|
|
1065
|
+
}
|
|
1066
|
+
if (!wizardLiveSerial) return;
|
|
1067
|
+
wizardLiveHomeKit.hidden = false;
|
|
1068
|
+
wizardLiveHomeKitSpinner.hidden = false;
|
|
1069
|
+
wizardLiveStatus.textContent = '';
|
|
1070
|
+
wizardLiveDiagnose.hidden = true;
|
|
1071
|
+
const body = { serial: wizardLiveSerial, adaptation: wizardLiveAdaptation() };
|
|
1072
|
+
void requestWithinDeadline('/device/live/adapt', body, 12000).catch(() => undefined);
|
|
1073
|
+
});
|
|
1074
|
+
// Keeps this camera's own ticks, as its preference wherever they differ from the general setting.
|
|
1075
|
+
wizardLiveSave.addEventListener('click', async () => {
|
|
1076
|
+
const serial = wizardSteps[wizardIndex]?.serial;
|
|
1077
|
+
const existing = configuredBlock();
|
|
1078
|
+
if (!serial || !existing) return;
|
|
1079
|
+
const entityPreferences = { ...(existing.entityPreferences ?? {}) };
|
|
1080
|
+
const preference = { ...(entityPreferences[serial] ?? {}) };
|
|
1081
|
+
for (const name of WIZARD_LIVE_CAMERA) {
|
|
1082
|
+
const checked = wizardLiveControls.elements[name].checked;
|
|
1083
|
+
if (checked === wizardLiveSettings[name].checked) delete preference[name];
|
|
1084
|
+
else preference[name] = checked;
|
|
1085
|
+
}
|
|
1086
|
+
if (Object.keys(preference).length === 0) delete entityPreferences[serial];
|
|
1087
|
+
else entityPreferences[serial] = preference;
|
|
1088
|
+
try {
|
|
1089
|
+
await updateConfig({ ...existing, entityPreferences });
|
|
1090
|
+
} catch {
|
|
1091
|
+
wizardLiveStatus.textContent = messages.preferenceSaveFailed ?? '';
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
wizardLiveDiagnoseStart.addEventListener('click', () => openDiagnostics({ profile: 'live-media' }));
|
|
1095
|
+
homebridge.addEventListener('device-live-end', (event) => endWizardLive(event.data?.serial, event.data?.unplayable));
|
|
1096
|
+
|
|
1097
|
+
/** One control per eufy setting the device states and can change, named and valued the way the camera describes it. */
|
|
1098
|
+
function eufySettingControl(setting) {
|
|
1099
|
+
const label = messages[EUFY_SETTING_LABELS[setting.setting]] ?? setting.setting;
|
|
1100
|
+
const wrapper = document.createElement('label');
|
|
1101
|
+
wrapper.className = typeof setting.value === 'boolean' ? 'toggle' : 'wizard-setting';
|
|
1102
|
+
wrapper.dataset.setting = '';
|
|
1103
|
+
const name = document.createElement('span');
|
|
1104
|
+
name.className = 'setting-label';
|
|
1105
|
+
name.textContent = label;
|
|
1106
|
+
let control;
|
|
1107
|
+
if (typeof setting.value === 'boolean') {
|
|
1108
|
+
control = document.createElement('input');
|
|
1109
|
+
control.type = 'checkbox';
|
|
1110
|
+
control.checked = setting.value;
|
|
1111
|
+
wrapper.append(control, name);
|
|
1112
|
+
} else {
|
|
1113
|
+
control = document.createElement('select');
|
|
1114
|
+
for (const [value, text] of Object.entries(setting.labels ?? { [setting.value]: String(setting.value) })) {
|
|
1115
|
+
const option = document.createElement('option');
|
|
1116
|
+
option.value = value;
|
|
1117
|
+
option.textContent = text;
|
|
1118
|
+
option.selected = Number(value) === setting.value;
|
|
1119
|
+
control.append(option);
|
|
1120
|
+
}
|
|
1121
|
+
wrapper.append(name, control);
|
|
1122
|
+
}
|
|
1123
|
+
control.dataset.eufySetting = setting.setting;
|
|
1124
|
+
return wrapper;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async function renderWizardSettings(serial) {
|
|
1128
|
+
let answer;
|
|
1129
|
+
try {
|
|
1130
|
+
answer = await requestWithinDeadline('/device/settings', { serial }, 12000);
|
|
1131
|
+
} catch {
|
|
1132
|
+
answer = undefined;
|
|
1133
|
+
}
|
|
1134
|
+
if (wizardSteps[wizardIndex]?.serial !== serial) return;
|
|
1135
|
+
const writable = (answer?.settings ?? []).filter((setting) => setting.writable);
|
|
1136
|
+
if (writable.length === 0) return;
|
|
1137
|
+
const status = document.createElement('p');
|
|
1138
|
+
status.className = 'preference-note';
|
|
1139
|
+
status.setAttribute('role', 'status');
|
|
1140
|
+
status.dataset.wizardSettingsStatus = '';
|
|
1141
|
+
wizardSettings.replaceChildren(...writable.map(eufySettingControl), status);
|
|
1142
|
+
wizardEufy.hidden = false;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** One device on the map: a button that opens its step, marked as the current one where it is. */
|
|
1146
|
+
function wizardMapEntry(device, current) {
|
|
1147
|
+
const entry = document.createElement('button');
|
|
1148
|
+
entry.type = 'button';
|
|
1149
|
+
entry.className = 'wizard-map-device';
|
|
1150
|
+
entry.textContent = device.name;
|
|
1151
|
+
entry.dataset.serial = device.serial;
|
|
1152
|
+
if (device.serial === current) entry.setAttribute('aria-current', 'step');
|
|
1153
|
+
return entry;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Draws the household: each HomeBase with the devices that hang off it, then the cameras that stand alone. A base
|
|
1158
|
+
* is a step too where it has HomeKit choices, so its name opens it like any other device.
|
|
1159
|
+
*/
|
|
1160
|
+
function renderWizardMap(current) {
|
|
1161
|
+
const { bases, standalone } = setupWizardTopology(wizardSteps, dashboardDevices);
|
|
1162
|
+
const group = (title, members, base) => {
|
|
1163
|
+
const section = document.createElement('div');
|
|
1164
|
+
section.className = 'wizard-map-group';
|
|
1165
|
+
const heading = base && wizardSteps.includes(base) ? wizardMapEntry(base, current) : document.createElement('p');
|
|
1166
|
+
heading.classList?.add('wizard-map-base');
|
|
1167
|
+
heading.textContent = title;
|
|
1168
|
+
const list = document.createElement('div');
|
|
1169
|
+
list.className = 'wizard-map-members';
|
|
1170
|
+
list.append(...members.map((device) => wizardMapEntry(device, current)));
|
|
1171
|
+
section.append(heading, list);
|
|
1172
|
+
return section;
|
|
1173
|
+
};
|
|
1174
|
+
wizardMap.replaceChildren(
|
|
1175
|
+
...bases.map(({ base, members }) => group(base?.name ?? messages.wizardMapBase ?? '', members, base)),
|
|
1176
|
+
...(standalone.length > 0 ? [group(messages.wizardMapStandalone ?? '', standalone)] : []),
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
wizardSettings.addEventListener('change', async (event) => {
|
|
1181
|
+
const control = event.target;
|
|
1182
|
+
const setting = control?.dataset?.eufySetting;
|
|
1183
|
+
const device = wizardSteps[wizardIndex];
|
|
1184
|
+
if (!setting || !device) return;
|
|
1185
|
+
const value = control.type === 'checkbox' ? control.checked : Number(control.value);
|
|
1186
|
+
const status = wizardSettings.querySelector('[data-wizard-settings-status]');
|
|
1187
|
+
control.disabled = true;
|
|
1188
|
+
status.textContent = messages.wizardSettingSending ?? '';
|
|
1189
|
+
try {
|
|
1190
|
+
const answer = await requestWithinDeadline('/device/settings/write', { serial: device.serial, setting, value }, 30000);
|
|
1191
|
+
// A write that reached the camera is not yet a camera that changed, so the step says sent rather than done.
|
|
1192
|
+
status.textContent = answer?.ok ? (messages.wizardSettingSent ?? '') : (messages.wizardSettingFailed ?? '');
|
|
1193
|
+
} catch {
|
|
1194
|
+
status.textContent = messages.wizardSettingFailed ?? '';
|
|
1195
|
+
} finally {
|
|
1196
|
+
control.disabled = false;
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
async function renderWizardStep() {
|
|
1201
|
+
stopWizardLive();
|
|
1202
|
+
wizardLiveHomeKit.hidden = true;
|
|
1203
|
+
wizardSettings.replaceChildren();
|
|
1204
|
+
wizardControls.replaceChildren();
|
|
1205
|
+
wizardEufy.hidden = true;
|
|
1206
|
+
const restart = !wizardPluginUp();
|
|
1207
|
+
const empty = restart || wizardSteps.length === 0;
|
|
1208
|
+
wizardRestart.hidden = !restart;
|
|
1209
|
+
wizardMap.hidden = empty;
|
|
1210
|
+
wizardStage.hidden = empty;
|
|
1211
|
+
wizardHomeKit.hidden = empty;
|
|
1212
|
+
wizardFinish.textContent = restart ? (messages.wizardClose ?? '') : (messages.wizardFinish ?? '');
|
|
1213
|
+
if (empty) {
|
|
1214
|
+
wizardProgress.textContent = '';
|
|
1215
|
+
wizardTitle.textContent = messages.wizardTitle ?? '';
|
|
1216
|
+
wizardEyebrow.textContent = '';
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
const device = wizardSteps[wizardIndex];
|
|
1220
|
+
renderWizardMap(device.serial);
|
|
1221
|
+
dashboardView.renderDeviceSettings(device, configuredBlock() ?? {}, messages, wizardElements);
|
|
1222
|
+
wizardHomeKit.hidden = !(device.preferences?.length > 0);
|
|
1223
|
+
wizardProgress.textContent = (messages.wizardProgress ?? '')
|
|
1224
|
+
.replace('{current}', String(wizardIndex + 1))
|
|
1225
|
+
.replace('{total}', String(wizardSteps.length));
|
|
1226
|
+
// Only a camera has a picture to show; any other device's step is its settings alone.
|
|
1227
|
+
wizardLive.hidden = device.deviceClass !== 'camera';
|
|
1228
|
+
wizardLiveControls.hidden = wizardLive.hidden;
|
|
1229
|
+
if (!wizardLive.hidden) void showWizardCamera(device.serial, device.enabled);
|
|
1230
|
+
await renderWizardSettings(device.serial);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function goToWizardStep(index) {
|
|
1234
|
+
if (index < 0 || index >= wizardSteps.length || index === wizardIndex) return;
|
|
1235
|
+
wizardIndex = index;
|
|
1236
|
+
void renderWizardStep();
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
menuWizard.addEventListener('click', () => openSetupWizard(menuWizard));
|
|
1240
|
+
wizardClose.addEventListener('click', () => closeDashboardPanel());
|
|
1241
|
+
wizardLiveStop.addEventListener('click', () => stopWizardLive());
|
|
1242
|
+
wizardLivePlay.addEventListener('click', () => {
|
|
1243
|
+
const device = wizardSteps[wizardIndex];
|
|
1244
|
+
if (device) startWizardLive(device.serial);
|
|
1245
|
+
});
|
|
1246
|
+
wizardMap.addEventListener('click', (event) => {
|
|
1247
|
+
const serial = event.target?.closest?.('[data-serial]')?.dataset?.serial;
|
|
1248
|
+
goToWizardStep(wizardSteps.findIndex((device) => device.serial === serial));
|
|
1249
|
+
});
|
|
1250
|
+
// Closing a wizard that waits for a restart keeps it due; finishing one that walked the devices does not.
|
|
1251
|
+
wizardFinish.addEventListener('click', () => {
|
|
1252
|
+
if (wizardPluginUp()) wizardPending(false);
|
|
1253
|
+
closeDashboardPanel();
|
|
1254
|
+
});
|
|
1255
|
+
window.addEventListener?.('pagehide', () => stopWizardLive());
|
|
1256
|
+
|
|
1257
|
+
function openAuthentication() {
|
|
937
1258
|
diagnosticsPanel.hidden = true;
|
|
938
1259
|
advancedPanel.hidden = true;
|
|
1260
|
+
devicePanel.hidden = true;
|
|
1261
|
+
wizardPanel.hidden = true;
|
|
1262
|
+
stopWizardLive();
|
|
939
1263
|
dashboard.hidden = true;
|
|
940
1264
|
setupContent.hidden = false;
|
|
941
1265
|
masthead.hidden = false;
|
|
942
1266
|
pageTitle.textContent = messages.pageTitle;
|
|
943
1267
|
accountInput.focus?.();
|
|
944
1268
|
recordActiveUiEventBestEffort('authentication-opened');
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
dashboardAuthenticate.addEventListener('click', openAuthentication);
|
|
1272
|
+
|
|
1273
|
+
// A saved session the plugin can no longer use stops every device, so nothing on the page works until the user
|
|
1274
|
+
// signs in again. The dialog says so in front of everything else, and Escape does not dismiss it.
|
|
1275
|
+
reauthDialog.addEventListener('cancel', (event) => event.preventDefault());
|
|
1276
|
+
reauthAction.addEventListener('click', () => {
|
|
1277
|
+
reauthDialog.close?.();
|
|
1278
|
+
openAuthentication();
|
|
945
1279
|
});
|
|
946
1280
|
|
|
947
1281
|
legacyAcknowledge.addEventListener('click', async () => {
|
|
@@ -951,12 +1285,19 @@ legacyAcknowledge.addEventListener('click', async () => {
|
|
|
951
1285
|
try {
|
|
952
1286
|
await updateConfig({ ...existing, discardedV4Settings: legacyNames, discardedV4Acknowledged: true });
|
|
953
1287
|
} catch {
|
|
1288
|
+
legacyStatus.textContent = messages.preferenceSaveFailed ?? '';
|
|
954
1289
|
return;
|
|
955
1290
|
}
|
|
956
1291
|
}
|
|
957
1292
|
legacyNotice.hidden = true;
|
|
958
1293
|
});
|
|
959
1294
|
|
|
1295
|
+
/**
|
|
1296
|
+
* Writes the signed-in account into the Homebridge configuration, reporting whether it was saved.
|
|
1297
|
+
*
|
|
1298
|
+
* The Save button is enabled before the attempt and never disabled by it, so a write that fails leaves the user
|
|
1299
|
+
* the one control that retries it.
|
|
1300
|
+
*/
|
|
960
1301
|
async function saveAuthenticatedConfig() {
|
|
961
1302
|
homebridge.enableSaveButton();
|
|
962
1303
|
try {
|
|
@@ -965,7 +1306,9 @@ async function saveAuthenticatedConfig() {
|
|
|
965
1306
|
pluginConfig = [pendingConfig];
|
|
966
1307
|
savedConfigSignature = configSignature(pluginConfig);
|
|
967
1308
|
homebridge.enableSaveButton();
|
|
1309
|
+
return true;
|
|
968
1310
|
} catch {
|
|
1311
|
+
return false;
|
|
969
1312
|
} finally {
|
|
970
1313
|
passwordInput.value = '';
|
|
971
1314
|
pendingConfig = undefined;
|
|
@@ -985,18 +1328,36 @@ async function showDashboard() {
|
|
|
985
1328
|
warmUpCandidates = Array.isArray(snapshot.warmUpCandidates) ? snapshot.warmUpCandidates : [];
|
|
986
1329
|
// Kept beside the render that drew them, so opening a tile finds the device that tile stands for.
|
|
987
1330
|
dashboardDevices = snapshot.devices ?? [];
|
|
988
|
-
|
|
1331
|
+
dashboardPluginState = snapshot.state;
|
|
1332
|
+
dashboardAttention = dashboardView.render(snapshot, configuredBlock() ?? {}, messages, dashboardElements);
|
|
1333
|
+
if (snapshot.state === 'authentication-required' && snapshot.devices?.length > 0 && !reauthDialog.open) {
|
|
1334
|
+
reauthDialog.showModal?.();
|
|
1335
|
+
}
|
|
989
1336
|
// The image pass runs beside the rendered dashboard, so its failure is dropped here rather than escaping.
|
|
990
1337
|
void dashboardView
|
|
991
1338
|
.applyDeviceImages(dashboardElements, (serial) => requestWithinDeadline('/device/image', { serial }, 12000))
|
|
992
1339
|
.catch(() => undefined);
|
|
993
1340
|
} catch {
|
|
994
|
-
dashboardView.render(
|
|
1341
|
+
dashboardAttention = dashboardView.render(
|
|
1342
|
+
{ state: 'unreachable', devices: [] },
|
|
1343
|
+
configuredBlock() ?? {},
|
|
1344
|
+
messages,
|
|
1345
|
+
dashboardElements,
|
|
1346
|
+
);
|
|
995
1347
|
recordActiveUiEventBestEffort('request-failed');
|
|
996
1348
|
}
|
|
1349
|
+
renderDiagnosticsEntries();
|
|
997
1350
|
recordActiveUiEventBestEffort('dashboard-opened');
|
|
998
1351
|
}
|
|
999
1352
|
|
|
1353
|
+
/** The message each unsuccessful sign-in outcome is stated with; any other outcome is a failed sign-in. */
|
|
1354
|
+
const AUTH_OUTCOMES = {
|
|
1355
|
+
blocked: 'dashboardOwnerConflictSummary',
|
|
1356
|
+
'plugin-running': 'authPluginRunning',
|
|
1357
|
+
'commit-failed': 'authCommitFailed',
|
|
1358
|
+
'timed-out': 'authTimedOut',
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1000
1361
|
async function handleResult(result) {
|
|
1001
1362
|
if (result.status === 'captcha') {
|
|
1002
1363
|
challenge = 'captcha';
|
|
@@ -1005,7 +1366,7 @@ async function handleResult(result) {
|
|
|
1005
1366
|
challengeImage.hidden = false;
|
|
1006
1367
|
challengeLabel.textContent = messages.captchaLabel ?? '';
|
|
1007
1368
|
challengeForm.hidden = false;
|
|
1008
|
-
authStatus.textContent = '';
|
|
1369
|
+
authStatus.textContent = result.retry ? (messages.captchaRetry ?? '') : '';
|
|
1009
1370
|
return;
|
|
1010
1371
|
}
|
|
1011
1372
|
if (result.status === 'two-factor') {
|
|
@@ -1014,7 +1375,7 @@ async function handleResult(result) {
|
|
|
1014
1375
|
challengeImage.hidden = true;
|
|
1015
1376
|
challengeLabel.textContent = messages.twoFactorLabel ?? '';
|
|
1016
1377
|
challengeForm.hidden = false;
|
|
1017
|
-
authStatus.textContent =
|
|
1378
|
+
authStatus.textContent = messages.twoFactorSent ?? '';
|
|
1018
1379
|
return;
|
|
1019
1380
|
}
|
|
1020
1381
|
|
|
@@ -1022,38 +1383,30 @@ async function handleResult(result) {
|
|
|
1022
1383
|
if (result.status === 'restart-required') {
|
|
1023
1384
|
authForm.hidden = true;
|
|
1024
1385
|
authStatus.textContent = messages.authSuccess ?? '';
|
|
1025
|
-
await saveAuthenticatedConfig();
|
|
1026
1386
|
// The devices this sign-in discovered are already recorded, so the flow ends on them rather than on a notice.
|
|
1027
|
-
await
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
authForm.hidden = false;
|
|
1033
|
-
authStatus.textContent = messages.authTimedOut ?? '';
|
|
1387
|
+
if (await saveAuthenticatedConfig()) {
|
|
1388
|
+
await showDashboard();
|
|
1389
|
+
wizardPending(true);
|
|
1390
|
+
await openSetupWizard();
|
|
1391
|
+
} else authStatus.textContent = messages.authSaveFailed ?? '';
|
|
1034
1392
|
} else {
|
|
1035
1393
|
authForm.hidden = false;
|
|
1036
|
-
authStatus.textContent = messages.authFailed ?? '';
|
|
1394
|
+
authStatus.textContent = messages[AUTH_OUTCOMES[result.status] ?? 'authFailed'] ?? '';
|
|
1037
1395
|
}
|
|
1038
1396
|
}
|
|
1039
1397
|
|
|
1398
|
+
/** A sign-in request that did not come back: the page's own deadline elapsing is said to be a timeout. */
|
|
1399
|
+
function reportAuthRequestFailure(error) {
|
|
1400
|
+
authStatus.textContent = messages[error?.message === REQUEST_TIMED_OUT ? 'authTimedOut' : 'authFailed'] ?? '';
|
|
1401
|
+
recordActiveUiEventBestEffort('request-failed');
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1040
1404
|
authForm.addEventListener('submit', async (event) => {
|
|
1041
1405
|
event.preventDefault();
|
|
1406
|
+
// A sign-in replaces the account and nothing else: every other key of the block, the child bridge included, is kept.
|
|
1042
1407
|
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
1408
|
pendingConfig = {
|
|
1056
|
-
...
|
|
1409
|
+
...existing,
|
|
1057
1410
|
platform: 'HomebridgeEufy',
|
|
1058
1411
|
username: accountInput.value.trim(),
|
|
1059
1412
|
password: passwordInput.value,
|
|
@@ -1065,9 +1418,8 @@ authForm.addEventListener('submit', async (event) => {
|
|
|
1065
1418
|
setBusy(true);
|
|
1066
1419
|
try {
|
|
1067
1420
|
await handleResult(await requestWithinDeadline('/auth/start', { configuration: pendingConfig }));
|
|
1068
|
-
} catch {
|
|
1069
|
-
|
|
1070
|
-
recordActiveUiEventBestEffort('request-failed');
|
|
1421
|
+
} catch (error) {
|
|
1422
|
+
reportAuthRequestFailure(error);
|
|
1071
1423
|
} finally {
|
|
1072
1424
|
setBusy(false);
|
|
1073
1425
|
}
|
|
@@ -1081,9 +1433,8 @@ challengeForm.addEventListener('submit', async (event) => {
|
|
|
1081
1433
|
setBusy(true);
|
|
1082
1434
|
try {
|
|
1083
1435
|
await handleResult(await requestWithinDeadline(path, body));
|
|
1084
|
-
} catch {
|
|
1085
|
-
|
|
1086
|
-
recordActiveUiEventBestEffort('request-failed');
|
|
1436
|
+
} catch (error) {
|
|
1437
|
+
reportAuthRequestFailure(error);
|
|
1087
1438
|
} finally {
|
|
1088
1439
|
setBusy(false);
|
|
1089
1440
|
}
|
|
@@ -1117,6 +1468,40 @@ if (updateRestart) {
|
|
|
1117
1468
|
});
|
|
1118
1469
|
}
|
|
1119
1470
|
|
|
1471
|
+
/** The V4 settings a legacy block sets to something other than its default, which V5 no longer reads. */
|
|
1472
|
+
function legacySettingNames(block) {
|
|
1473
|
+
const defaults = { CameraMaxLivestreamDuration: 30, hkHome: 1, hkAway: 0, hkNight: 3, hkOff: 63 };
|
|
1474
|
+
const effectful = (key, value) => {
|
|
1475
|
+
if (Object.hasOwn(defaults, key)) return value !== defaults[key];
|
|
1476
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
1477
|
+
if (value && typeof value === 'object') return Object.keys(value).length > 0;
|
|
1478
|
+
return Boolean(value);
|
|
1479
|
+
};
|
|
1480
|
+
return [
|
|
1481
|
+
'CameraMaxLivestreamDuration',
|
|
1482
|
+
'autoSyncStation',
|
|
1483
|
+
'cameras',
|
|
1484
|
+
'cleanCache',
|
|
1485
|
+
'debugLivestream',
|
|
1486
|
+
'enableDetailedLogging',
|
|
1487
|
+
'enableEmbeddedPKCS1Support',
|
|
1488
|
+
'experimentalMode',
|
|
1489
|
+
'hkAway',
|
|
1490
|
+
'hkHome',
|
|
1491
|
+
'hkNight',
|
|
1492
|
+
'hkOff',
|
|
1493
|
+
'ignoreDevices',
|
|
1494
|
+
'ignoreMultipleDevicesWarning',
|
|
1495
|
+
'ignoreStations',
|
|
1496
|
+
'omitLogFiles',
|
|
1497
|
+
'stations',
|
|
1498
|
+
'syncStationModes',
|
|
1499
|
+
'useEmbeddedPKCS1Support',
|
|
1500
|
+
]
|
|
1501
|
+
.filter((key) => Object.hasOwn(block, key) && effectful(key, block[key]))
|
|
1502
|
+
.sort();
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1120
1505
|
homebridge.addEventListener('ready', async () => {
|
|
1121
1506
|
homebridge.disableSaveButton();
|
|
1122
1507
|
shell.dataset.theme = await homebridge.userCurrentLightingMode();
|
|
@@ -1140,7 +1525,7 @@ homebridge.addEventListener('ready', async () => {
|
|
|
1140
1525
|
} else {
|
|
1141
1526
|
const legacyBlock = pluginConfig.find((block) => block.platform === 'EufySecurity');
|
|
1142
1527
|
if (legacyBlock) {
|
|
1143
|
-
legacyNames =
|
|
1528
|
+
legacyNames = legacySettingNames(legacyBlock);
|
|
1144
1529
|
}
|
|
1145
1530
|
}
|
|
1146
1531
|
legacyNotice.hidden = legacyNames.length === 0 || legacyAcknowledged;
|
|
@@ -1156,5 +1541,6 @@ homebridge.addEventListener('ready', async () => {
|
|
|
1156
1541
|
trustedDeviceInput.value = configured?.trustedDeviceName ?? 'Homebridge Eufy';
|
|
1157
1542
|
if (configured) {
|
|
1158
1543
|
await showDashboard();
|
|
1544
|
+
if (wizardPending() && wizardPluginUp()) await openSetupWizard();
|
|
1159
1545
|
}
|
|
1160
1546
|
});
|