@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.
- 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 +951 -334
- 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 +81 -38
- 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 +160 -82
- package/homebridge-ui/public/i18n/fr.json +161 -83
- package/homebridge-ui/public/index.html +225 -96
- package/homebridge-ui/public/js/app.js +814 -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,126 @@ 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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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);
|
|
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
|
|
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
|
|
483
|
-
*
|
|
484
|
-
* reads every collected log. Exporting spends it,
|
|
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
|
-
|
|
488
|
-
if (!caseId || diagnosticsReviewedCaseId === caseId
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
423
|
+
renderDiagnostics(diagnosticsState);
|
|
424
|
+
diagnosticsResultHeading.focus?.();
|
|
530
425
|
} catch {
|
|
531
|
-
|
|
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
|
-
|
|
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?.();
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
484
|
+
for (const tile of diagnosticsTiles) {
|
|
485
|
+
tile.addEventListener('click', () => startDiagnosticsCapture(tile.dataset.diagnosticsTile));
|
|
486
|
+
}
|
|
616
487
|
|
|
617
|
-
diagnosticsStartAnother.addEventListener('click',
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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(
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
});
|