@homebridge-plugins/homebridge-eufy-security 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/FUNDING.yml +1 -0
  3. package/LICENSE +176 -0
  4. package/README.md +67 -0
  5. package/config.schema.json +6 -0
  6. package/dist/accessories/AutoSyncStationAccessory.js +156 -0
  7. package/dist/accessories/AutoSyncStationAccessory.js.map +1 -0
  8. package/dist/accessories/BaseAccessory.js +247 -0
  9. package/dist/accessories/BaseAccessory.js.map +1 -0
  10. package/dist/accessories/CameraAccessory.js +431 -0
  11. package/dist/accessories/CameraAccessory.js.map +1 -0
  12. package/dist/accessories/Device.js +67 -0
  13. package/dist/accessories/Device.js.map +1 -0
  14. package/dist/accessories/EntrySensorAccessory.js +48 -0
  15. package/dist/accessories/EntrySensorAccessory.js.map +1 -0
  16. package/dist/accessories/LockAccessory.js +142 -0
  17. package/dist/accessories/LockAccessory.js.map +1 -0
  18. package/dist/accessories/MotionSensorAccessory.js +48 -0
  19. package/dist/accessories/MotionSensorAccessory.js.map +1 -0
  20. package/dist/accessories/SmartDropAccessory.js +145 -0
  21. package/dist/accessories/SmartDropAccessory.js.map +1 -0
  22. package/dist/accessories/StationAccessory.js +371 -0
  23. package/dist/accessories/StationAccessory.js.map +1 -0
  24. package/dist/config.js +25 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/controller/LocalLivestreamManager.js +116 -0
  27. package/dist/controller/LocalLivestreamManager.js.map +1 -0
  28. package/dist/controller/recordingDelegate.js +208 -0
  29. package/dist/controller/recordingDelegate.js.map +1 -0
  30. package/dist/controller/snapshotDelegate.js +345 -0
  31. package/dist/controller/snapshotDelegate.js.map +1 -0
  32. package/dist/controller/streamingDelegate.js +345 -0
  33. package/dist/controller/streamingDelegate.js.map +1 -0
  34. package/dist/index.js +11 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/interfaces.js +2 -0
  37. package/dist/interfaces.js.map +1 -0
  38. package/dist/media/Snapshot-Unavailable.png +0 -0
  39. package/dist/media/Snapshot-Unavailable.xcf +0 -0
  40. package/dist/media/Snapshot-black.png +0 -0
  41. package/dist/media/camera-disabled.png +0 -0
  42. package/dist/media/camera-offline.png +0 -0
  43. package/dist/media/media/Snapshot-Unavailable.png +0 -0
  44. package/dist/media/media/Snapshot-Unavailable.xcf +0 -0
  45. package/dist/media/media/Snapshot-black.png +0 -0
  46. package/dist/media/media/camera-disabled.png +0 -0
  47. package/dist/media/media/camera-offline.png +0 -0
  48. package/dist/platform.js +716 -0
  49. package/dist/platform.js.map +1 -0
  50. package/dist/settings.js +38 -0
  51. package/dist/settings.js.map +1 -0
  52. package/dist/utils/Talkback.js +92 -0
  53. package/dist/utils/Talkback.js.map +1 -0
  54. package/dist/utils/accessoriesStore.js +206 -0
  55. package/dist/utils/accessoriesStore.js.map +1 -0
  56. package/dist/utils/configTypes.js +35 -0
  57. package/dist/utils/configTypes.js.map +1 -0
  58. package/dist/utils/ffmpeg.js +843 -0
  59. package/dist/utils/ffmpeg.js.map +1 -0
  60. package/dist/utils/interfaces.js +8 -0
  61. package/dist/utils/interfaces.js.map +1 -0
  62. package/dist/utils/utils.js +44 -0
  63. package/dist/utils/utils.js.map +1 -0
  64. package/dist/version.js +2 -0
  65. package/dist/version.js.map +1 -0
  66. package/eslint.config.mjs +18 -0
  67. package/homebridge-eufy-security.png +0 -0
  68. package/homebridge-ui/public/app.js +225 -0
  69. package/homebridge-ui/public/assets/devices/4g_lte_starlight_large.jpg +0 -0
  70. package/homebridge-ui/public/assets/devices/BATTERY_DOORBELL_C30.png +0 -0
  71. package/homebridge-ui/public/assets/devices/BATTERY_DOORBELL_C31.png +0 -0
  72. package/homebridge-ui/public/assets/devices/batterydoorbell1080p_large.jpg +0 -0
  73. package/homebridge-ui/public/assets/devices/batterydoorbell2kdual_large.jpg +0 -0
  74. package/homebridge-ui/public/assets/devices/batterydoorbell_e340_large.png +0 -0
  75. package/homebridge-ui/public/assets/devices/eufy-security-client.png +0 -0
  76. package/homebridge-ui/public/assets/devices/eufycam2_large.png +0 -0
  77. package/homebridge-ui/public/assets/devices/eufycam2c_large.jpg +0 -0
  78. package/homebridge-ui/public/assets/devices/eufycam2cpro_large.jpg +0 -0
  79. package/homebridge-ui/public/assets/devices/eufycam2pro_large.jpg +0 -0
  80. package/homebridge-ui/public/assets/devices/eufycam3_large.jpg +0 -0
  81. package/homebridge-ui/public/assets/devices/eufycam3c_large.jpg +0 -0
  82. package/homebridge-ui/public/assets/devices/eufycam3pro_large.png +0 -0
  83. package/homebridge-ui/public/assets/devices/eufycam_large.jpg +0 -0
  84. package/homebridge-ui/public/assets/devices/eufycame330_large.jpg +0 -0
  85. package/homebridge-ui/public/assets/devices/floodlight2_large.jpg +0 -0
  86. package/homebridge-ui/public/assets/devices/floodlight2pro_large.jpg +0 -0
  87. package/homebridge-ui/public/assets/devices/floodlight_large.jpg +0 -0
  88. package/homebridge-ui/public/assets/devices/floodlightcame340_large.jpg +0 -0
  89. package/homebridge-ui/public/assets/devices/garage_camera_t8452_large.jpg +0 -0
  90. package/homebridge-ui/public/assets/devices/homebase2_large.png +0 -0
  91. package/homebridge-ui/public/assets/devices/homebase3_large.png +0 -0
  92. package/homebridge-ui/public/assets/devices/homebase_large.jpg +0 -0
  93. package/homebridge-ui/public/assets/devices/homebasemini_large.jpg +0 -0
  94. package/homebridge-ui/public/assets/devices/indoorcamC210_large.png +0 -0
  95. package/homebridge-ui/public/assets/devices/indoorcamC220_large.png +0 -0
  96. package/homebridge-ui/public/assets/devices/indoorcamE30_large.png +0 -0
  97. package/homebridge-ui/public/assets/devices/indoorcamc120_large.png +0 -0
  98. package/homebridge-ui/public/assets/devices/indoorcammini_large.jpg +0 -0
  99. package/homebridge-ui/public/assets/devices/indoorcamp24_large.png +0 -0
  100. package/homebridge-ui/public/assets/devices/indoorcams350_large.jpg +0 -0
  101. package/homebridge-ui/public/assets/devices/keypad_large.png +0 -0
  102. package/homebridge-ui/public/assets/devices/minibase_chime_T8023_large.jpg +0 -0
  103. package/homebridge-ui/public/assets/devices/motionsensor_large.png +0 -0
  104. package/homebridge-ui/public/assets/devices/sensor_large.png +0 -0
  105. package/homebridge-ui/public/assets/devices/smartdrop_t8790_large.png +0 -0
  106. package/homebridge-ui/public/assets/devices/smartlock_t8500_large.png +0 -0
  107. package/homebridge-ui/public/assets/devices/smartlock_t8500_wifibridge_large.jpg +0 -0
  108. package/homebridge-ui/public/assets/devices/smartlock_t8503_large.png +0 -0
  109. package/homebridge-ui/public/assets/devices/smartlock_t8504_large.jpg +0 -0
  110. package/homebridge-ui/public/assets/devices/smartlock_t8510P_t8520P_large.png +0 -0
  111. package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8502_large.png +0 -0
  112. package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8506_large.png +0 -0
  113. package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8520_large.png +0 -0
  114. package/homebridge-ui/public/assets/devices/smartlock_touch_t8510_large.png +0 -0
  115. package/homebridge-ui/public/assets/devices/smartlock_touch_t8510_wifibridge_large.jpg +0 -0
  116. package/homebridge-ui/public/assets/devices/smartlock_video_t8530_large.png +0 -0
  117. package/homebridge-ui/public/assets/devices/smartlockwifibridge_t8021_large.jpg +0 -0
  118. package/homebridge-ui/public/assets/devices/smartsafe_s10_t7400_large.png +0 -0
  119. package/homebridge-ui/public/assets/devices/smartsafe_s12_t7401_large.png +0 -0
  120. package/homebridge-ui/public/assets/devices/smarttrack_card_t87B2_large.png +0 -0
  121. package/homebridge-ui/public/assets/devices/smarttrack_link_t87B0_large.png +0 -0
  122. package/homebridge-ui/public/assets/devices/solocamc210_large.jpg +0 -0
  123. package/homebridge-ui/public/assets/devices/solocamc35_large.png +0 -0
  124. package/homebridge-ui/public/assets/devices/solocame20_large.jpg +0 -0
  125. package/homebridge-ui/public/assets/devices/solocame30_large.png +0 -0
  126. package/homebridge-ui/public/assets/devices/solocame40_large.jpg +0 -0
  127. package/homebridge-ui/public/assets/devices/solocaml20_large.jpg +0 -0
  128. package/homebridge-ui/public/assets/devices/solocams220_large.jpg +0 -0
  129. package/homebridge-ui/public/assets/devices/solocams340_large.png +0 -0
  130. package/homebridge-ui/public/assets/devices/solocams40_large.jpg +0 -0
  131. package/homebridge-ui/public/assets/devices/soloindoorcamc24_large.jpg +0 -0
  132. package/homebridge-ui/public/assets/devices/solooutdoorcamc22_large.png +0 -0
  133. package/homebridge-ui/public/assets/devices/solooutdoorcamc24_large.jpg +0 -0
  134. package/homebridge-ui/public/assets/devices/unknown.png +0 -0
  135. package/homebridge-ui/public/assets/devices/walllight_s100_large.jpg +0 -0
  136. package/homebridge-ui/public/assets/devices/walllight_s120_large.jpg +0 -0
  137. package/homebridge-ui/public/assets/devices/wireddoorbell1080p_large.jpg +0 -0
  138. package/homebridge-ui/public/assets/devices/wireddoorbell2k_large.png +0 -0
  139. package/homebridge-ui/public/assets/devices/wireddoorbelldual_large.jpg +0 -0
  140. package/homebridge-ui/public/assets/icons/attach.svg +1 -0
  141. package/homebridge-ui/public/assets/icons/battery_0.svg +1 -0
  142. package/homebridge-ui/public/assets/icons/battery_1.svg +1 -0
  143. package/homebridge-ui/public/assets/icons/battery_2.svg +1 -0
  144. package/homebridge-ui/public/assets/icons/battery_3.svg +1 -0
  145. package/homebridge-ui/public/assets/icons/battery_4.svg +1 -0
  146. package/homebridge-ui/public/assets/icons/battery_5.svg +1 -0
  147. package/homebridge-ui/public/assets/icons/battery_6.svg +1 -0
  148. package/homebridge-ui/public/assets/icons/bolt.svg +1 -0
  149. package/homebridge-ui/public/assets/icons/bug-report.svg +1 -0
  150. package/homebridge-ui/public/assets/icons/copy.svg +1 -0
  151. package/homebridge-ui/public/assets/icons/delete.svg +1 -0
  152. package/homebridge-ui/public/assets/icons/download.svg +1 -0
  153. package/homebridge-ui/public/assets/icons/info.svg +1 -0
  154. package/homebridge-ui/public/assets/icons/inventory.svg +1 -0
  155. package/homebridge-ui/public/assets/icons/refresh.svg +1 -0
  156. package/homebridge-ui/public/assets/icons/satellite_alt.svg +1 -0
  157. package/homebridge-ui/public/assets/icons/settings.svg +1 -0
  158. package/homebridge-ui/public/assets/icons/settings_backup_restore.svg +1 -0
  159. package/homebridge-ui/public/assets/icons/solar_power.svg +1 -0
  160. package/homebridge-ui/public/assets/icons/warning.svg +1 -0
  161. package/homebridge-ui/public/components/device-card.js +162 -0
  162. package/homebridge-ui/public/components/guard-modes.js +88 -0
  163. package/homebridge-ui/public/components/number-input.js +121 -0
  164. package/homebridge-ui/public/components/select.js +73 -0
  165. package/homebridge-ui/public/components/toggle.js +68 -0
  166. package/homebridge-ui/public/index.html +27 -0
  167. package/homebridge-ui/public/services/api.js +214 -0
  168. package/homebridge-ui/public/services/config.js +144 -0
  169. package/homebridge-ui/public/style.css +775 -0
  170. package/homebridge-ui/public/utils/countries.js +73 -0
  171. package/homebridge-ui/public/utils/device-images.js +89 -0
  172. package/homebridge-ui/public/utils/helpers.js +87 -0
  173. package/homebridge-ui/public/views/dashboard.js +226 -0
  174. package/homebridge-ui/public/views/device-detail.js +610 -0
  175. package/homebridge-ui/public/views/diagnostics.js +296 -0
  176. package/homebridge-ui/public/views/login.js +636 -0
  177. package/homebridge-ui/public/views/settings.js +192 -0
  178. package/homebridge-ui/public/views/unsupported-detail.js +296 -0
  179. package/homebridge-ui/server.js +1327 -0
  180. package/media/Snapshot-Unavailable.png +0 -0
  181. package/media/Snapshot-Unavailable.xcf +0 -0
  182. package/media/Snapshot-black.png +0 -0
  183. package/media/camera-disabled.png +0 -0
  184. package/media/camera-offline.png +0 -0
  185. package/package.json +64 -0
@@ -0,0 +1,636 @@
1
+ /**
2
+ * Login View — multi-step wizard for Eufy Security authentication.
3
+ * Steps: Welcome → Credentials → TFA (if needed) → Captcha (if needed) → Discovery
4
+ */
5
+ // eslint-disable-next-line no-unused-vars
6
+ const LoginView = {
7
+ STEP: { WELCOME: 0, CREDENTIALS: 1, TFA: 2, CAPTCHA: 3, DISCOVERY: 4 },
8
+
9
+ _currentStep: 0,
10
+ _captchaData: null,
11
+ _credentials: null,
12
+ _container: null,
13
+ /** @type {object|null} Login options to be consumed by _renderDiscovery for inline auth */
14
+ _loginOptions: null,
15
+
16
+ render(container) {
17
+ this._container = container;
18
+ this._currentStep = this.STEP.WELCOME;
19
+ this._renderStep();
20
+ },
21
+
22
+ _renderStep() {
23
+ const c = this._container;
24
+ c.innerHTML = '';
25
+
26
+ const wrap = document.createElement('div');
27
+ wrap.className = 'login-card';
28
+
29
+ switch (this._currentStep) {
30
+ case this.STEP.WELCOME:
31
+ this._renderWelcome(wrap);
32
+ break;
33
+ case this.STEP.CREDENTIALS:
34
+ this._renderCredentials(wrap);
35
+ break;
36
+ case this.STEP.TFA:
37
+ this._renderTFA(wrap);
38
+ break;
39
+ case this.STEP.CAPTCHA:
40
+ this._renderCaptcha(wrap);
41
+ break;
42
+ case this.STEP.DISCOVERY:
43
+ this._renderDiscovery(wrap);
44
+ break;
45
+ }
46
+
47
+ c.appendChild(wrap);
48
+ },
49
+
50
+ // ===== Step 0: Welcome =====
51
+ _renderWelcome(wrap) {
52
+ this._sectionTitle(wrap, 'Welcome');
53
+ const body = wrap;
54
+ body.insertAdjacentHTML('beforeend', `
55
+ <div class="welcome-banner">
56
+ <div class="welcome-banner__title">Eufy Security for HomeKit</div>
57
+ <div class="welcome-banner__text">
58
+ Connect your Eufy Security devices to Apple HomeKit through Homebridge.
59
+ You'll need your Eufy account credentials to get started.
60
+ </div>
61
+ </div>
62
+ <div class="alert alert-warning mt-3" role="alert" style="font-size: 0.85rem;">
63
+ <div class="form-check">
64
+ <input class="form-check-input" type="checkbox" id="ack-guest-admin">
65
+ <label class="form-check-label" for="ack-guest-admin">
66
+ <strong>Important:</strong> Use a <strong>dedicated guest admin account</strong> — not your primary Eufy account.
67
+ <a href="https://github.com/homebridge-plugins/homebridge-eufy-security/wiki/Create-a-dedicated-admin-account-for-Homebridge-Eufy-Security-Plugin" target="_blank">Learn more</a>
68
+ </label>
69
+ </div>
70
+ </div>
71
+ <div class="alert alert-info mt-2" role="alert" style="font-size: 0.85rem;">
72
+ <div class="form-check">
73
+ <input class="form-check-input" type="checkbox" id="ack-stop-plugin">
74
+ <label class="form-check-label" for="ack-stop-plugin">
75
+ If the plugin is currently running, please <strong>stop it first</strong> before logging in here. Both cannot use the same credentials simultaneously.
76
+ </label>
77
+ </div>
78
+ </div>
79
+ <div id="node-version-warning" class="d-none"></div>
80
+ <div class="d-flex flex-wrap gap-2 mt-2 welcome-actions">
81
+ <button class="btn btn-primary flex-fill" id="btn-start" disabled>Continue to Login</button>
82
+ <button class="btn btn-outline-success flex-fill d-none" id="btn-reconnect" disabled>
83
+ <span class="spinner-border spinner-border-sm d-none me-1" id="reconnect-spinner"></span>
84
+ Refresh Devices
85
+ </button>
86
+ </div>
87
+ `);
88
+
89
+ const ack1 = body.querySelector('#ack-guest-admin');
90
+ const ack2 = body.querySelector('#ack-stop-plugin');
91
+ const btnStart = body.querySelector('#btn-start');
92
+ const nodeWarningContainer = body.querySelector('#node-version-warning');
93
+ let ack3 = null; // Node version checkbox, only if affected
94
+
95
+ const updateBtn = () => {
96
+ const allChecked = ack1.checked && ack2.checked && (!ack3 || ack3.checked);
97
+ btnStart.disabled = !allChecked;
98
+ };
99
+ ack1.addEventListener('change', updateBtn);
100
+ ack2.addEventListener('change', updateBtn);
101
+
102
+ // Check Node.js version and conditionally show warning
103
+ App.checkNodeVersion().then(() => {
104
+ const warning = App.state.nodeVersionWarning;
105
+ if (warning && warning.affected) {
106
+ nodeWarningContainer.className = 'alert alert-danger mt-2';
107
+ nodeWarningContainer.setAttribute('role', 'alert');
108
+ nodeWarningContainer.style.fontSize = '0.85rem';
109
+
110
+ const formCheck = document.createElement('div');
111
+ formCheck.className = 'form-check';
112
+
113
+ const input = document.createElement('input');
114
+ input.className = 'form-check-input';
115
+ input.type = 'checkbox';
116
+ input.id = 'ack-node-version';
117
+ formCheck.appendChild(input);
118
+
119
+ const label = document.createElement('label');
120
+ label.className = 'form-check-label';
121
+ label.htmlFor = 'ack-node-version';
122
+
123
+ const strongPrefix = document.createElement('strong');
124
+ strongPrefix.textContent = 'Streaming unavailable:';
125
+ label.appendChild(strongPrefix);
126
+ label.appendChild(document.createTextNode(' Node.js '));
127
+ const strongVer = document.createElement('strong');
128
+ strongVer.textContent = warning.nodeVersion;
129
+ label.appendChild(strongVer);
130
+ label.appendChild(document.createTextNode(' — '));
131
+ Helpers.appendNodeVersionWarning(label);
132
+
133
+ formCheck.appendChild(label);
134
+ nodeWarningContainer.appendChild(formCheck);
135
+ ack3 = nodeWarningContainer.querySelector('#ack-node-version');
136
+ ack3.addEventListener('change', updateBtn);
137
+ // Also update refresh button state when node version checkbox changes
138
+ ack3.addEventListener('change', () => {
139
+ const reconnectBtn = body.querySelector('#btn-reconnect');
140
+ if (reconnectBtn && !reconnectBtn.classList.contains('d-none')) {
141
+ const allChecked = ack1.checked && ack2.checked && (!ack3 || ack3.checked);
142
+ reconnectBtn.disabled = !allChecked;
143
+ }
144
+ });
145
+ updateBtn();
146
+ }
147
+ });
148
+
149
+ btnStart.addEventListener('click', () => {
150
+ this._currentStep = this.STEP.CREDENTIALS;
151
+ this._renderStep();
152
+ });
153
+
154
+ // Check for persistent cache and conditionally show "Refresh Devices" button
155
+ const btnReconnect = body.querySelector('#btn-reconnect');
156
+ Api.checkCache().then((result) => {
157
+ if (result && result.valid) {
158
+ btnReconnect.classList.remove('d-none');
159
+ // Highlight Refresh as the primary action, demote Continue to Login
160
+ btnReconnect.classList.remove('btn-outline-success');
161
+ btnReconnect.classList.add('btn-success');
162
+ btnStart.classList.remove('btn-primary');
163
+ btnStart.classList.add('btn-outline-primary');
164
+ const updateReconnectBtn = () => {
165
+ const allChecked = ack1.checked && ack2.checked && (!ack3 || ack3.checked);
166
+ btnReconnect.disabled = !allChecked;
167
+ };
168
+ ack1.addEventListener('change', updateReconnectBtn);
169
+ ack2.addEventListener('change', updateReconnectBtn);
170
+ updateReconnectBtn();
171
+ }
172
+ }).catch(() => { /* cache check failed — hide refresh button */ });
173
+
174
+ btnReconnect.addEventListener('click', async () => {
175
+ btnReconnect.disabled = true;
176
+ btnStart.disabled = true;
177
+
178
+ try {
179
+ // Get saved credentials from plugin config
180
+ const config = await Config.get();
181
+ if (!config.username || !config.password) {
182
+ throw new Error('No saved credentials found. Please log in manually.');
183
+ }
184
+
185
+ // Go straight to discovery — auth will happen there
186
+ this._credentials = null; // reconnect uses existing config
187
+ this._loginOptions = {
188
+ username: config.username,
189
+ password: config.password,
190
+ country: config.country || 'US',
191
+ deviceName: config.deviceName || '',
192
+ reconnect: true,
193
+ };
194
+ this._currentStep = this.STEP.DISCOVERY;
195
+ this._renderStep();
196
+ } catch (e) {
197
+ btnReconnect.disabled = false;
198
+ btnStart.disabled = false;
199
+ const errMsg = e.message || 'Failed to load credentials.';
200
+ btnReconnect.classList.add('eufy-tooltip');
201
+ btnReconnect.setAttribute('data-tooltip', errMsg);
202
+ btnReconnect.classList.add('btn-outline-danger');
203
+ setTimeout(() => {
204
+ btnReconnect.classList.remove('eufy-tooltip');
205
+ btnReconnect.removeAttribute('data-tooltip');
206
+ btnReconnect.classList.remove('btn-outline-danger');
207
+ }, 6000);
208
+ }
209
+ });
210
+ },
211
+
212
+ // ===== Step 1: Credentials =====
213
+ _renderCredentials(wrap) {
214
+ this._sectionTitle(wrap, 'Sign In');
215
+ const body = wrap;
216
+
217
+ body.insertAdjacentHTML('beforeend', `
218
+ <div class="mb-3">
219
+ <label for="login-email" class="form-label">Email Address</label>
220
+ <input type="email" class="form-control" id="login-email" placeholder="your-eufy-email@example.com" required>
221
+ </div>
222
+ <div class="mb-3">
223
+ <label for="login-password" class="form-label">Password</label>
224
+ <input type="password" class="form-control" id="login-password" placeholder="Password" required>
225
+ </div>
226
+ <div class="mb-3">
227
+ <label for="login-country" class="form-label">Country</label>
228
+ <select class="form-select" id="login-country"></select>
229
+ </div>
230
+ <div class="mb-3">
231
+ <label for="login-device" class="form-label">Device Name</label>
232
+ <input type="text" class="form-control" id="login-device" value="" placeholder="e.g. My Homebridge">
233
+ <div class="form-text">A name to identify this Homebridge instance to Eufy. Can be left blank.</div>
234
+ </div>
235
+ <div id="login-error" class="alert alert-danger d-none" role="alert"></div>
236
+ <button class="btn btn-primary w-100" id="btn-login" type="button">
237
+ <span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span>
238
+ Sign In
239
+ </button>
240
+ `);
241
+
242
+ // Populate country dropdown
243
+ const countrySelect = body.querySelector('#login-country');
244
+ Object.entries(COUNTRIES).forEach(([code, name]) => {
245
+ const opt = document.createElement('option');
246
+ opt.value = code;
247
+ opt.textContent = name;
248
+ if (code === 'US') opt.selected = true;
249
+ countrySelect.appendChild(opt);
250
+ });
251
+
252
+ // Pre-fill from existing config if available
253
+ this._prefillCredentials(body);
254
+
255
+ // Submit
256
+ body.querySelector('#btn-login').addEventListener('click', () => this._doLogin(body));
257
+
258
+ // Enter key support
259
+ body.querySelectorAll('input').forEach((input) => {
260
+ input.addEventListener('keydown', (e) => {
261
+ if (e.key === 'Enter') this._doLogin(body);
262
+ });
263
+ });
264
+ },
265
+
266
+ async _prefillCredentials(body) {
267
+ try {
268
+ const config = await Config.get();
269
+ if (config.username) body.querySelector('#login-email').value = config.username;
270
+ if (config.password) body.querySelector('#login-password').value = config.password;
271
+ if (config.country) body.querySelector('#login-country').value = config.country;
272
+ if (config.deviceName) body.querySelector('#login-device').value = config.deviceName;
273
+ } catch (e) {
274
+ // Ignore — no config yet
275
+ }
276
+ },
277
+
278
+ async _doLogin(body) {
279
+ const email = body.querySelector('#login-email').value.trim();
280
+ const password = body.querySelector('#login-password').value;
281
+ const country = body.querySelector('#login-country').value;
282
+ const deviceName = body.querySelector('#login-device').value.trim() || '';
283
+
284
+ if (!email || !password) {
285
+ this._showError(body, 'Please enter your email and password.');
286
+ return;
287
+ }
288
+
289
+ // Stash credentials in memory — save only after full auth succeeds
290
+ this._credentials = { username: email, password: password, country: country, deviceName: deviceName };
291
+
292
+ // Go straight to discovery — auth will happen there
293
+ this._loginOptions = {
294
+ username: email,
295
+ password: password,
296
+ country: country,
297
+ deviceName: deviceName,
298
+ };
299
+ this._currentStep = this.STEP.DISCOVERY;
300
+ this._renderStep();
301
+ },
302
+
303
+ // ===== Step 2: TFA =====
304
+ _renderTFA(wrap) {
305
+ this._sectionTitle(wrap, 'Two-Factor Authentication');
306
+ const body = wrap;
307
+
308
+ body.insertAdjacentHTML('beforeend', `
309
+ <p class="text-muted" style="font-size: 0.85rem;">
310
+ A verification code has been sent to your registered device or email. Enter it below.
311
+ </p>
312
+ <div class="mb-3">
313
+ <label for="tfa-code" class="form-label">Verification Code</label>
314
+ <input type="text" class="form-control text-center" id="tfa-code" placeholder="000000"
315
+ maxlength="6" autocomplete="one-time-code" inputmode="numeric" style="font-size: 1.5rem; letter-spacing: 0.3em;">
316
+ </div>
317
+ <div id="login-error" class="alert alert-danger d-none" role="alert"></div>
318
+ <button class="btn btn-primary w-100" id="btn-verify">
319
+ <span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span>
320
+ Verify
321
+ </button>
322
+ `);
323
+
324
+ body.querySelector('#tfa-code').focus();
325
+
326
+ body.querySelector('#btn-verify').addEventListener('click', () => this._doTFA(body));
327
+ body.querySelector('#tfa-code').addEventListener('keydown', (e) => {
328
+ if (e.key === 'Enter') this._doTFA(body);
329
+ });
330
+ },
331
+
332
+ async _doTFA(body) {
333
+ const code = body.querySelector('#tfa-code').value.trim();
334
+ if (!code) {
335
+ this._showError(body, 'Please enter the verification code.');
336
+ return;
337
+ }
338
+
339
+ // Go straight to discovery — auth outcome arrives via push events
340
+ this._loginOptions = { verifyCode: code };
341
+ this._currentStep = this.STEP.DISCOVERY;
342
+ this._renderStep();
343
+ },
344
+
345
+ // ===== Step 3: Captcha =====
346
+ _renderCaptcha(wrap) {
347
+ this._sectionTitle(wrap, 'Captcha Verification');
348
+ const body = wrap;
349
+
350
+ body.insertAdjacentHTML('beforeend', `
351
+ <p class="text-muted" style="font-size: 0.85rem;">
352
+ Please solve the captcha below to continue.
353
+ </p>
354
+ <div class="text-center mb-3">
355
+ <img id="captcha-image" class="img-fluid border rounded" alt="Captcha" style="max-height: 100px;">
356
+ </div>
357
+ <div class="mb-3">
358
+ <label for="captcha-code" class="form-label">Captcha Code</label>
359
+ <input type="text" class="form-control text-center" id="captcha-code" placeholder="Enter captcha"
360
+ style="font-size: 1.2rem; letter-spacing: 0.2em;">
361
+ </div>
362
+ <div id="login-error" class="alert alert-danger d-none" role="alert"></div>
363
+ <button class="btn btn-primary w-100" id="btn-captcha">
364
+ <span class="spinner-border spinner-border-sm d-none me-1" id="login-spinner"></span>
365
+ Submit
366
+ </button>
367
+ `);
368
+
369
+ if (this._captchaData && this._captchaData.captcha) {
370
+ body.querySelector('#captcha-image').src = this._captchaData.captcha;
371
+ }
372
+
373
+ body.querySelector('#captcha-code').focus();
374
+
375
+ body.querySelector('#btn-captcha').addEventListener('click', () => this._doCaptcha(body));
376
+ body.querySelector('#captcha-code').addEventListener('keydown', (e) => {
377
+ if (e.key === 'Enter') this._doCaptcha(body);
378
+ });
379
+ },
380
+
381
+ async _doCaptcha(body) {
382
+ const code = body.querySelector('#captcha-code').value.trim();
383
+ if (!code) {
384
+ this._showError(body, 'Please enter the captcha code.');
385
+ return;
386
+ }
387
+
388
+ // Go straight to discovery — auth outcome arrives via push events
389
+ this._loginOptions = {
390
+ captcha: {
391
+ captchaCode: code,
392
+ captchaId: this._captchaData.id,
393
+ },
394
+ };
395
+ this._currentStep = this.STEP.DISCOVERY;
396
+ this._renderStep();
397
+ },
398
+
399
+ // ===== Step 4: Discovery =====
400
+ _renderDiscovery(wrap) {
401
+ // loginOptions may be set by the reconnect button or _doLogin before navigating here
402
+ const loginOptions = this._loginOptions || null;
403
+ this._loginOptions = null;
404
+
405
+ const isAuthNeeded = !!loginOptions;
406
+
407
+ wrap.innerHTML = `
408
+ <div class="discovery-screen">
409
+ <div class="discovery-screen__icon">${Helpers.iconHtml('satellite_alt.svg', 32)}</div>
410
+ <div class="discovery-screen__title">${isAuthNeeded ? 'Refreshing your devices...' : 'Discovering your devices...'}</div>
411
+ <div class="discovery-screen__subtitle">
412
+ ${isAuthNeeded ? 'Authenticating and re-discovering all your stations and devices.' : 'Connecting to Eufy servers and detecting all your stations and devices. Hang tight!'}
413
+ </div>
414
+ <div class="progress mt-4" style="margin: 0 auto; height: 6px;">
415
+ <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar"
416
+ style="width: 5%" id="discovery-progress"></div>
417
+ </div>
418
+ <div class="text-muted mt-2" style="font-size: 0.8rem;" id="discovery-status">${isAuthNeeded ? 'Authenticating...' : 'Connecting to Eufy Cloud...'}</div>
419
+ <div id="discovery-warning" class="d-none mt-3" style="margin: 0 auto;"></div>
420
+ <div id="discovery-error" class="d-none mt-3" style="margin: 0 auto;"></div>
421
+ </div>
422
+ `;
423
+
424
+ const progressBar = wrap.querySelector('#discovery-progress');
425
+ const statusEl = wrap.querySelector('#discovery-status');
426
+ const warningEl = wrap.querySelector('#discovery-warning');
427
+ const errorEl = wrap.querySelector('#discovery-error');
428
+ let warningActive = false;
429
+
430
+ // --- Show auth error with a back-to-login button ---
431
+ const showAuthError = (message) => {
432
+ progressBar.classList.remove('progress-bar-animated');
433
+ progressBar.classList.add('bg-danger');
434
+ statusEl.textContent = '';
435
+ errorEl.className = 'mt-3';
436
+ errorEl.style.margin = '0 auto';
437
+ errorEl.innerHTML = `
438
+ <div class="alert alert-danger mb-2" role="alert" style="font-size: 0.82rem;">
439
+ ${Helpers.escHtml(message)}
440
+ </div>
441
+ <button class="btn btn-sm btn-primary" id="btn-back-login">Back to Login</button>
442
+ `;
443
+ errorEl.querySelector('#btn-back-login').addEventListener('click', () => {
444
+ this._credentials = null;
445
+ this._currentStep = this.STEP.WELCOME;
446
+ this._renderStep();
447
+ });
448
+ };
449
+
450
+ // --- Handler functions (shared by catch-up replay and live events) ---
451
+ const handleProgress = (data) => {
452
+ if (!data) return;
453
+
454
+ if (typeof data.progress === 'number' && data.progress > 0) {
455
+ progressBar.style.width = data.progress + '%';
456
+ }
457
+
458
+ // Update status text — allow unsupportedWait messages through even when warning banner is shown
459
+ if (data.message && (!warningActive || data.phase === 'unsupportedWait')) {
460
+ statusEl.textContent = data.message;
461
+ }
462
+ };
463
+
464
+ const handleWarning = (data) => {
465
+ if (!data) return;
466
+ warningActive = true;
467
+
468
+ // Update status to indicate we're waiting
469
+ statusEl.textContent = 'Waiting for extra device details...';
470
+
471
+ warningEl.className = 'mt-3';
472
+ warningEl.style.margin = '0 auto';
473
+ warningEl.innerHTML = `
474
+ <div class="alert alert-warning mb-2" role="alert" style="font-size: 0.82rem; text-align: left;">
475
+ <strong>${data.unsupportedCount || ''} unsupported device(s)</strong> detected.<br>
476
+ <span class="text-muted" style="font-size: 0.78rem;">${Helpers.escHtml(data.unsupportedNames || '')}</span>
477
+ <hr class="my-2">
478
+ Collecting raw device data for diagnostics.
479
+ </div>
480
+ <div class="d-flex justify-content-center">
481
+ <button class="btn btn-sm btn-outline-secondary" id="btn-skip-intel">Skip &amp; Continue</button>
482
+ </div>
483
+ `;
484
+
485
+ // Skip button — tell server to abort the wait
486
+ warningEl.querySelector('#btn-skip-intel').addEventListener('click', () => {
487
+ warningActive = false;
488
+ warningEl.innerHTML = '<div class="text-muted" style="font-size: 0.78rem;">Skipped — finalizing devices...</div>';
489
+ statusEl.textContent = 'Finalizing...';
490
+ Api.skipIntelWait().catch(() => { /* ignore */ });
491
+ });
492
+
493
+ // When the server finishes (addAccessory event), it will proceed automatically
494
+ };
495
+
496
+ // Register live event listeners
497
+ Api.onDiscoveryProgress(handleProgress);
498
+ Api.onDiscoveryWarning(handleWarning);
499
+
500
+ // Auth outcome event listeners — all driven by server push events
501
+ Api.onAuthSuccess(() => {
502
+ // Server already sends discoveryProgress with "Authenticated" message
503
+ catchUpOnState();
504
+ });
505
+ Api.onAuthError((data) => {
506
+ showAuthError(data && data.message ? data.message : 'Authentication failed.');
507
+ });
508
+ Api.onTfaRequest(() => {
509
+ this._currentStep = this.STEP.TFA;
510
+ this._renderStep();
511
+ });
512
+ Api.onCaptchaRequest((data) => {
513
+ this._captchaData = data;
514
+ this._currentStep = this.STEP.CAPTCHA;
515
+ this._renderStep();
516
+ });
517
+
518
+ // Catch up on discovery events that fired during the login request.
519
+ const catchUpOnState = () => {
520
+ Api.getDiscoveryState().then((state) => {
521
+ if (state && state.progress > 0) {
522
+ handleProgress(state);
523
+ }
524
+ }).catch(() => { /* ignore — live events will still work */ });
525
+ };
526
+
527
+ // Fire login request — resolves immediately, outcomes arrive as push events
528
+ if (isAuthNeeded) {
529
+ Api.login(loginOptions).catch((e) => {
530
+ showAuthError('Connection error: ' + (e.message || e));
531
+ });
532
+ } else {
533
+ // Auth already done (came from _doTFA / _doCaptcha) — just catch up
534
+ catchUpOnState();
535
+ }
536
+
537
+ // Listen for the batch-processed accessories
538
+ Api.onAccessoriesReady((payload) => {
539
+ warningActive = false;
540
+
541
+ const stations = Array.isArray(payload) ? payload : (payload && payload.stations) || [];
542
+ const extended = payload && payload.extendedDiscovery;
543
+ const noDevices = payload && payload.noDevices;
544
+
545
+ // Save credentials even when no devices found — the account is valid
546
+ if (this._credentials) {
547
+ Config.updateGlobal(this._credentials).then(() => Config.save());
548
+ this._credentials = null;
549
+ }
550
+
551
+ // If no stations or no devices were discovered, go to dashboard with empty state
552
+ const totalDevices = (stations || []).reduce((sum, s) => sum + (s.devices ? s.devices.length : 0), 0);
553
+ if (noDevices || !stations || stations.length === 0 || totalDevices === 0) {
554
+ progressBar.style.width = '100%';
555
+ progressBar.classList.remove('progress-bar-animated');
556
+ progressBar.classList.add('bg-warning');
557
+ statusEl.textContent = 'No devices found — redirecting to dashboard...';
558
+
559
+ setTimeout(() => {
560
+ App.state.stations = stations || [];
561
+ App.state.cacheDate = new Date().toISOString();
562
+ App.navigate('dashboard');
563
+ }, 1500);
564
+ return;
565
+ }
566
+
567
+ progressBar.style.width = '100%';
568
+ progressBar.classList.remove('progress-bar-animated');
569
+ statusEl.textContent = extended
570
+ ? `Done — ${totalDevices} device(s) discovered (collected extra details for unsupported).`
571
+ : `Done — ${totalDevices} device(s) discovered!`;
572
+
573
+ // Hide the warning area
574
+ warningEl.className = 'd-none';
575
+
576
+ // Go to dashboard
577
+ setTimeout(() => {
578
+ App.state.stations = stations;
579
+ App.state.cacheDate = new Date().toISOString();
580
+ App.navigate('dashboard');
581
+ }, 500);
582
+ });
583
+ },
584
+
585
+ /**
586
+ * Replaces the discovery screen content with an error message and a retry button.
587
+ */
588
+ _renderDiscoveryError(wrap, message) {
589
+ wrap.innerHTML = `
590
+ <div class="discovery-screen">
591
+ <div class="discovery-screen__icon">${Helpers.iconHtml('warning.svg', 32)}</div>
592
+ <div class="discovery-screen__title">Discovery Failed</div>
593
+ <div class="discovery-screen__subtitle" style="color: var(--bs-danger, #dc3545);">
594
+ ${message}
595
+ </div>
596
+ <button class="btn btn-primary mt-4" id="btn-retry-login">Retry Login</button>
597
+ </div>
598
+ `;
599
+ wrap.querySelector('#btn-retry-login').addEventListener('click', () => {
600
+ this._credentials = null;
601
+ this._currentStep = this.STEP.CREDENTIALS;
602
+ this._renderStep();
603
+ });
604
+ },
605
+
606
+ // ===== Helpers =====
607
+
608
+ _sectionTitle(container, title) {
609
+ const header = document.createElement('div');
610
+ header.className = 'login-section-title';
611
+ header.textContent = title;
612
+ container.appendChild(header);
613
+ },
614
+
615
+ _showError(body, msg) {
616
+ const el = body.querySelector('#login-error');
617
+ if (el) {
618
+ el.textContent = msg;
619
+ el.classList.remove('d-none');
620
+ }
621
+ },
622
+
623
+ _hideError(body) {
624
+ const el = body.querySelector('#login-error');
625
+ if (el) el.classList.add('d-none');
626
+ },
627
+
628
+ _setLoading(body, loading) {
629
+ const btn = body.querySelector('.btn-primary');
630
+ const spinner = body.querySelector('#login-spinner');
631
+ if (btn) btn.disabled = loading;
632
+ if (spinner) {
633
+ spinner.classList.toggle('d-none', !loading);
634
+ }
635
+ },
636
+ };