@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,1327 @@
1
+ import { EufySecurity, libVersion, Device, PropertyName, CommandName, DeviceType, UserType } from 'eufy-security-client';
2
+ import * as fs from 'fs';
3
+ import { Logger as TsLogger } from 'tslog';
4
+ import { createStream } from 'rotating-file-stream';
5
+ import { Zip } from 'zip-lib';
6
+ import { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { createRequire } from 'module';
10
+
11
+ const require = createRequire(import.meta.url);
12
+ const { version: LIB_VERSION } = require('../package.json');
13
+
14
+ /** Max time (ms) to wait for the client to populate raw data on unsupported items. */
15
+ const UNSUPPORTED_INTEL_WAIT_MS = 2 * 60 * 1000; // 2 minutes
16
+
17
+ class UiServer extends HomebridgePluginUiServer {
18
+
19
+ stations = [];
20
+ eufyClient = null;
21
+ log;
22
+ tsLog;
23
+ storagePath;
24
+ storedAccessories_file;
25
+ diagnosticsZipFilePath;
26
+
27
+ adminAccountUsed = false;
28
+
29
+ // Batch processing for stations and devices
30
+ pendingStations = [];
31
+ pendingDevices = [];
32
+ processingTimeout;
33
+
34
+ /** Set to true when the user clicks "Skip" in the UI to abort the unsupported intel wait. */
35
+ _skipIntelWait = false;
36
+
37
+ /** Current discovery phase — exposed via /discoveryState for UI catch-up. */
38
+ _discoveryPhase = 'idle';
39
+
40
+ /** Seconds to wait after the last station/device event before processing. */
41
+ static DISCOVERY_DEBOUNCE_SEC = 15;
42
+
43
+ /** Seconds to wait after auth before giving up on device discovery. */
44
+ static DISCOVERY_INACTIVITY_SEC = 30;
45
+
46
+ config = {
47
+ username: '',
48
+ password: '',
49
+ language: 'en',
50
+ country: 'US',
51
+ trustedDeviceName: 'My Phone',
52
+ persistentDir: '',
53
+ p2pConnectionSetup: 0,
54
+ pollingIntervalMinutes: 1,
55
+ eventDurationSeconds: 10,
56
+ acceptInvitations: true,
57
+ logging: {
58
+ level: 1, // LogLevel.Debug — enables eufy-security-client internal logging to configui-lib.log
59
+ },
60
+ };
61
+
62
+ constructor() {
63
+ super();
64
+
65
+ this.storagePath = this.homebridgeStoragePath + '/eufysecurity';
66
+ this.storedAccessories_file = this.storagePath + '/accessories.json';
67
+ this.unsupported_file = this.storagePath + '/unsupported.json';
68
+ this.diagnosticsZipFilePath = null; // generated dynamically with timestamp
69
+ this.config.persistentDir = this.storagePath;
70
+
71
+ this.initLogger();
72
+ this.initTransportStreams();
73
+ this.initEventListeners();
74
+ this.ready();
75
+ }
76
+
77
+ /**
78
+ * Compute a unified power descriptor from a properties object.
79
+ * Works for both devices and stations.
80
+ * @param {object} props - the properties object (from device.getProperties() or station.getProperties())
81
+ * @returns {{ source: string, icon: string, label: string, battery?: number, batteryLow?: boolean }}
82
+ * source: 'battery' | 'solar' | 'plugged' | null
83
+ * icon: icon filename for the UI
84
+ * label: display text for the UI
85
+ * battery: percentage (0-100) if available
86
+ * batteryLow: true/false for simple sensors without percentage
87
+ */
88
+ _computePower(props) {
89
+ const power = { source: null, icon: null, label: null };
90
+
91
+ // Battery level
92
+ if (props.battery !== undefined) {
93
+ power.battery = props.battery;
94
+ } else if (props.batteryLow !== undefined) {
95
+ // Simple sensors only expose batteryLow boolean
96
+ power.batteryLow = props.batteryLow;
97
+ }
98
+
99
+ // Charging status (bitmask)
100
+ if (props.chargingStatus !== undefined) {
101
+ const cs = props.chargingStatus;
102
+ const isSolar = ((cs >> 2) & 1) === 1;
103
+ const isPlugSolar = ((cs >> 3) & 1) === 1;
104
+ const isUsb = (cs & 1) === 1;
105
+
106
+ if (isSolar || isPlugSolar) {
107
+ power.source = 'solar';
108
+ power.icon = 'solar_power.svg';
109
+ power.label = 'Solar Charging';
110
+ return power;
111
+ }
112
+ if (isUsb) {
113
+ power.source = 'plugged';
114
+ power.icon = 'bolt.svg';
115
+ power.label = 'Charging';
116
+ return power;
117
+ }
118
+ }
119
+
120
+ // PowerSource property (cameras with battery/solar panel)
121
+ // 0 = BATTERY, 1 = SOLAR_PANEL
122
+ if (props.powerSource === 1) {
123
+ power.source = 'solar';
124
+ power.icon = 'solar_power.svg';
125
+ power.label = 'Solar';
126
+ } else if (props.powerSource === 0) {
127
+ power.source = 'battery';
128
+ } else if (power.battery === undefined && power.batteryLow === undefined) {
129
+ // No battery info at all — AC powered (indoor cameras, stations)
130
+ power.source = 'plugged';
131
+ power.icon = 'bolt.svg';
132
+ power.label = 'Plugged In';
133
+ } else {
134
+ // Has battery/batteryLow but no powerSource — simple battery device (sensors)
135
+ power.source = 'battery';
136
+ }
137
+
138
+ return power;
139
+ }
140
+
141
+ initLogger() {
142
+ const logOptions = {
143
+ name: `[UI-${LIB_VERSION}]`, // Name prefix for log messages
144
+ prettyLogTemplate: '[{{mm}}/{{dd}}/{{yyyy}}, {{hh}}:{{MM}}:{{ss}}]\t{{name}}\t{{logLevelName}}\t', // Template for pretty log output
145
+ prettyErrorTemplate: '\n{{errorName}} {{errorMessage}}\nerror stack:\n{{errorStack}}', // Template for pretty error output
146
+ prettyErrorStackTemplate: ' • {{fileName}}\t{{method}}\n\t{{fileNameWithLine}}', // Template for error stack trace
147
+ prettyErrorParentNamesSeparator: '', // Separator for parent names in error messages
148
+ prettyErrorLoggerNameDelimiter: '\t', // Delimiter for logger name in error messages
149
+ stylePrettyLogs: true, // Enable styling for logs
150
+ minLevel: 2, // Minimum log level to display (3 corresponds to INFO)
151
+ prettyLogTimeZone: 'local', // Time zone for log timestamps
152
+ prettyLogStyles: { // Styles for different log elements
153
+ logLevelName: { // Styles for log level names
154
+ '*': ['bold', 'black', 'bgWhiteBright', 'dim'], // Default style
155
+ SILLY: ['bold', 'white'], // Style for SILLY level
156
+ TRACE: ['bold', 'whiteBright'], // Style for TRACE level
157
+ DEBUG: ['bold', 'green'], // Style for DEBUG level
158
+ INFO: ['bold', 'blue'], // Style for INFO level
159
+ WARN: ['bold', 'yellow'], // Style for WARN level
160
+ ERROR: ['bold', 'red'], // Style for ERROR level
161
+ FATAL: ['bold', 'redBright'], // Style for FATAL level
162
+ },
163
+ dateIsoStr: 'gray', // Style for ISO date strings
164
+ filePathWithLine: 'white', // Style for file paths with line numbers
165
+ name: 'green', // Style for logger names
166
+ nameWithDelimiterPrefix: ['white', 'bold'], // Style for logger names with delimiter prefix
167
+ nameWithDelimiterSuffix: ['white', 'bold'], // Style for logger names with delimiter suffix
168
+ errorName: ['bold', 'bgRedBright', 'whiteBright'], // Style for error names
169
+ fileName: ['yellow'], // Style for file names
170
+ },
171
+ maskValuesOfKeys: [ // Keys whose values should be masked in logs
172
+ 'username',
173
+ 'password',
174
+ 'token',
175
+ 'clientPrivateKey',
176
+ 'private_key',
177
+ 'login_hash',
178
+ 'serverPublicKey',
179
+ 'cloud_token',
180
+ 'refreshToken',
181
+ 'p2p_conn',
182
+ 'app_conn',
183
+ 'address',
184
+ 'latitude',
185
+ 'longitude',
186
+ 'serialnumber',
187
+ 'serialNumber',
188
+ 'stationSerialNumber',
189
+ 'data',
190
+ 'ignoreStations',
191
+ 'ignoreDevices',
192
+ 'pincode',
193
+ ],
194
+ };
195
+ this.log = new TsLogger(logOptions);
196
+ this.tsLog = new TsLogger({ ...logOptions, type: 'hidden', minLevel: 2 });
197
+ }
198
+
199
+ initTransportStreams() {
200
+ if (!fs.existsSync(this.storagePath)) {
201
+ fs.mkdirSync(this.storagePath, { recursive: true });
202
+ }
203
+
204
+ const logStreams = [
205
+ { name: 'configui-server.log', logger: this.log },
206
+ { name: 'configui-lib.log', logger: this.tsLog },
207
+ ];
208
+
209
+ for (const { name, logger } of logStreams) {
210
+ const logStream = createStream(name, { path: this.storagePath, interval: '1d', rotate: 3, maxSize: '200M', compress: 'gzip' });
211
+
212
+ logger.attachTransport((logObj) => {
213
+ const meta = logObj['_meta'];
214
+ const logName = meta.name;
215
+ const level = meta.logLevelName;
216
+ const date = meta.date.toISOString();
217
+
218
+ let message = '';
219
+ for (let i = 0; i <= 5; i++) {
220
+ if (logObj[i]) {
221
+ message += ' ' + (typeof logObj[i] === 'string' ? logObj[i] : JSON.stringify(logObj[i]));
222
+ }
223
+ }
224
+
225
+ logStream.write(date + '\t' + logName + '\t' + level + '\t' + message + '\n');
226
+ });
227
+ }
228
+
229
+ this.log.debug('Using bropats eufy-security-client library in version ' + libVersion);
230
+ }
231
+
232
+ initEventListeners() {
233
+ this.onRequest('/login', this.login.bind(this));
234
+ this.onRequest('/checkCache', this.checkCache.bind(this));
235
+ this.onRequest('/storedAccessories', this.loadStoredAccessories.bind(this));
236
+ this.onRequest('/reset', this.resetPlugin.bind(this));
237
+ this.onRequest('/downloadDiagnostics', this.downloadDiagnostics.bind(this));
238
+ this.onRequest('/cleanStorage', this.cleanStorage.bind(this));
239
+ this.onRequest('/systemInfo', this.getSystemInfo.bind(this));
240
+ this.onRequest('/skipIntelWait', this.skipIntelWait.bind(this));
241
+ this.onRequest('/discoveryState', this.getDiscoveryState.bind(this));
242
+ this.onRequest('/unsupportedDevices', this.loadUnsupportedDevices.bind(this));
243
+ }
244
+
245
+ skipIntelWait() {
246
+ this._skipIntelWait = true;
247
+ this.log.info('User requested to skip unsupported intel wait');
248
+ return { ok: true };
249
+ }
250
+
251
+ /**
252
+ * Load valid country codes from the shared countries.js file.
253
+ * Parsed lazily and cached for subsequent calls.
254
+ * @returns {Set<string>}
255
+ */
256
+ _getValidCountryCodes() {
257
+ if (!this._validCountryCodes) {
258
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
259
+ const source = fs.readFileSync(path.join(__dirname, 'public/utils/countries.js'), 'utf-8');
260
+ this._validCountryCodes = new Set(source.match(/\b[A-Z]{2}(?=\s*:)/g));
261
+ }
262
+ return this._validCountryCodes;
263
+ }
264
+
265
+ getDiscoveryState() {
266
+ return {
267
+ phase: this._discoveryPhase,
268
+ progress: this._discoveryPhase === 'queuing' ? 30 : this._discoveryPhase === 'processing' ? 50 : 0,
269
+ stations: this.pendingStations.length,
270
+ devices: this.pendingDevices.length,
271
+ message: this.pendingStations.length > 0 || this.pendingDevices.length > 0
272
+ ? `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s)...`
273
+ : '',
274
+ };
275
+ }
276
+
277
+ async deleteFileIfExists(filePath) {
278
+ try {
279
+ await fs.promises.unlink(filePath);
280
+ } catch (error) {
281
+ if (error.code !== 'ENOENT') {
282
+ throw error;
283
+ }
284
+ }
285
+ }
286
+
287
+ async resetPersistentData() {
288
+ return this.deleteFileIfExists(this.storagePath + '/persistent.json');
289
+ }
290
+
291
+ async resetAccessoryData() {
292
+ return this.deleteFileIfExists(this.storedAccessories_file);
293
+ }
294
+
295
+ async checkCache() {
296
+ const persistentFile = this.storagePath + '/persistent.json';
297
+ try {
298
+ if (fs.existsSync(persistentFile)) {
299
+ const data = JSON.parse(await fs.promises.readFile(persistentFile, 'utf-8'));
300
+ // Basic validity check: ensure it has some expected content
301
+ if (data && Object.keys(data).length > 0) {
302
+ this.log.debug('Persistent cache file found and valid.');
303
+ return { valid: true };
304
+ }
305
+ }
306
+ } catch (error) {
307
+ this.log.warn('Error checking persistent cache: ' + error);
308
+ }
309
+ return { valid: false };
310
+ }
311
+
312
+ async login(options) {
313
+ // --- Plugin heartbeat safeguard ---
314
+ // If the plugin is running (accessories.json updated within the last 90s),
315
+ // block login to prevent a competing eufy-security-client instance.
316
+ if (!this.eufyClient) {
317
+ try {
318
+ if (fs.existsSync(this.storedAccessories_file)) {
319
+ const data = JSON.parse(fs.readFileSync(this.storedAccessories_file, 'utf-8'));
320
+ if (data?.storedAt) {
321
+ const ageMs = Date.now() - new Date(data.storedAt).getTime();
322
+ if (ageMs < 90_000) {
323
+ this.log.warn('Plugin heartbeat is fresh — blocking UI login to avoid duplicate eufy client. Please stop and wait 90sec before trying again!');
324
+ this.pushEvent('authError', {
325
+ message: 'The plugin is currently running. Please stop it before logging in from the UI. Please stop and wait 90sec before trying again!',
326
+ });
327
+ return { success: false, pluginRunning: true };
328
+ }
329
+ }
330
+ }
331
+ } catch (error) {
332
+ this.log.debug('Heartbeat check failed (non-blocking): ' + error);
333
+ }
334
+ }
335
+
336
+ try {
337
+ if (options && options.username && options.password && !options.reconnect) {
338
+ this.log.info('deleting persistent.json and accessories due to new login');
339
+ await this.resetAccessoryData();
340
+ await this.resetPersistentData();
341
+ } else if (options && options.reconnect) {
342
+ this.log.info('Reconnecting using persistent cache (skipping data reset)');
343
+ }
344
+ } catch (error) {
345
+ this.log.error('Could not delete persistent.json due to error: ' + error);
346
+ }
347
+
348
+ if (!this.eufyClient && options && options.username && options.password && options.country) {
349
+ // Clear any pending timeouts from a previous login attempt
350
+ if (this.processingTimeout) {
351
+ clearTimeout(this.processingTimeout);
352
+ this.processingTimeout = null;
353
+ }
354
+ if (this._closeTimeout) {
355
+ clearTimeout(this._closeTimeout);
356
+ this._closeTimeout = null;
357
+ }
358
+ this.stations = [];
359
+ this.pendingStations = [];
360
+ this.pendingDevices = [];
361
+ this._discoveryPhase = 'authenticating';
362
+ this.log.debug('init eufyClient');
363
+
364
+ // Validate country code against known list
365
+ const country = typeof options.country === 'string' ? options.country.trim().toUpperCase() : '';
366
+ if (!this._getValidCountryCodes().has(country)) {
367
+ const raw = typeof options.country === 'object' ? JSON.stringify(options.country) : String(options.country);
368
+ this.log.warn(`Invalid country code received: ${raw} — falling back to login.`);
369
+ this.pushEvent('authError', { message: `Invalid country code "${raw}". Please select a valid country and try again.` });
370
+ this._discoveryPhase = 'idle';
371
+ return { success: false };
372
+ }
373
+
374
+ this.config.username = options.username;
375
+ this.config.password = options.password;
376
+ this.config.country = country;
377
+ this.config.trustedDeviceName = options.deviceName;
378
+ try {
379
+ this.eufyClient = await EufySecurity.initialize(this.config, this.tsLog);
380
+ this.eufyClient?.on('station added', this.addStation.bind(this));
381
+ this.eufyClient?.on('device added', this.addDevice.bind(this));
382
+ this.eufyClient?.on('push connect', () => this.log.debug('Push Connected!'));
383
+ this.eufyClient?.on('push close', () => this.log.debug('Push Closed!'));
384
+ this.eufyClient?.on('connect', () => this.log.debug('Connected!'));
385
+ this.eufyClient?.on('close', () => this.log.debug('Closed!'));
386
+ } catch (error) {
387
+ this.log.error(error);
388
+ this.pushEvent('authError', { message: `Initialization failed: ${error.message || error}` });
389
+ this._discoveryPhase = 'idle';
390
+ return { success: false };
391
+ }
392
+ }
393
+
394
+ // Timeout — fire authError event after 25s if nothing else resolved
395
+ this._loginTimeout = setTimeout(() => {
396
+ this.pushEvent('authError', { message: 'Authentication timed out. Please try again.' });
397
+ }, 25 * 1000);
398
+
399
+ if (options && options.username && options.password && options.country) {
400
+ this.log.debug('login with credentials');
401
+ try {
402
+ this._registerAuthHandlers();
403
+ this.eufyClient?.connect()
404
+ .then(() => this.log.debug('connected?: ' + this.eufyClient?.isConnected()))
405
+ .catch((error) => this.log.error(error));
406
+ } catch (error) {
407
+ this.log.error(error);
408
+ clearTimeout(this._loginTimeout);
409
+ this.pushEvent('authError', { message: 'Login error: ' + (error.message || error) });
410
+ }
411
+ } else if (options && options.verifyCode) {
412
+ this.log.debug('login with TFA code');
413
+ this.pushEvent('discoveryProgress', {
414
+ phase: 'authenticating',
415
+ progress: 10,
416
+ message: 'Verifying TFA code...',
417
+ });
418
+ try {
419
+ this._registerAuthHandlers();
420
+ this.eufyClient?.connect({ verifyCode: options.verifyCode, force: false })
421
+ .then(() => this.log.debug('TFA connect resolved, connected?: ' + this.eufyClient?.isConnected()))
422
+ .catch((error) => {
423
+ this.log.error('TFA connect error: ' + error);
424
+ clearTimeout(this._loginTimeout);
425
+ this.pushEvent('authError', { message: 'TFA verification failed: ' + (error.message || error) });
426
+ });
427
+ } catch (error) {
428
+ clearTimeout(this._loginTimeout);
429
+ this.pushEvent('authError', { message: 'TFA verification error: ' + (error.message || error) });
430
+ }
431
+ } else if (options && options.captcha) {
432
+ this.log.debug('login with captcha');
433
+ this.pushEvent('discoveryProgress', {
434
+ phase: 'authenticating',
435
+ progress: 10,
436
+ message: 'Verifying captcha...',
437
+ });
438
+ try {
439
+ this._registerAuthHandlers();
440
+ this.eufyClient?.connect({ captcha: { captchaCode: options.captcha.captchaCode, captchaId: options.captcha.captchaId }, force: false })
441
+ .then(() => this.log.debug('Captcha connect resolved, connected?: ' + this.eufyClient?.isConnected()))
442
+ .catch((error) => {
443
+ this.log.error('Captcha connect error: ' + error);
444
+ clearTimeout(this._loginTimeout);
445
+ this.pushEvent('authError', { message: 'Captcha verification failed: ' + (error.message || error) });
446
+ });
447
+ } catch (error) {
448
+ clearTimeout(this._loginTimeout);
449
+ this.pushEvent('authError', { message: 'Captcha verification error: ' + (error.message || error) });
450
+ }
451
+ } else {
452
+ clearTimeout(this._loginTimeout);
453
+ this.pushEvent('authError', { message: 'Unsupported login method.' });
454
+ }
455
+
456
+ // Resolve immediately — all outcomes are delivered via push events
457
+ return { pending: true };
458
+ }
459
+
460
+ /**
461
+ * Register one-time auth outcome handlers on the eufy client.
462
+ * All outcomes are delivered to the UI via push events.
463
+ */
464
+ _registerAuthHandlers() {
465
+ this.eufyClient?.once('tfa request', () => {
466
+ clearTimeout(this._loginTimeout);
467
+ this.pushEvent('tfaRequest', {});
468
+ });
469
+ this.eufyClient?.once('captcha request', (id, captcha) => {
470
+ clearTimeout(this._loginTimeout);
471
+ this.pushEvent('captchaRequest', { id, captcha });
472
+ });
473
+ this.eufyClient?.once('connect', () => {
474
+ clearTimeout(this._loginTimeout);
475
+ this.pushEvent('authSuccess', {});
476
+ this.pushEvent('discoveryProgress', {
477
+ phase: 'authenticating',
478
+ progress: 15,
479
+ message: 'Authenticated — waiting for devices...',
480
+ });
481
+ this._startDiscoveryInactivityTimeout();
482
+ });
483
+ }
484
+
485
+ /**
486
+ * Start the discovery inactivity timeout.
487
+ * If no station or device is discovered within DISCOVERY_INACTIVITY_SEC seconds
488
+ * after authentication, save the account and send an empty result to the UI.
489
+ */
490
+ _startDiscoveryInactivityTimeout() {
491
+ // If stations or devices were already discovered before connect fired, skip
492
+ if (this.pendingStations.length > 0 || this.pendingDevices.length > 0) {
493
+ this.log.debug('Devices already discovered before connect event — skipping inactivity timeout');
494
+ return;
495
+ }
496
+ this._cancelDiscoveryInactivityTimeout();
497
+ const totalSec = UiServer.DISCOVERY_INACTIVITY_SEC;
498
+ const start = Date.now();
499
+
500
+ // Tick every second: progress 15 → 95 during the wait, with countdown
501
+ this._discoveryInactivityTickInterval = setInterval(() => {
502
+ const elapsed = Math.floor((Date.now() - start) / 1000);
503
+ const remaining = Math.max(0, totalSec - elapsed);
504
+ const pct = Math.min(95, 15 + Math.floor((elapsed / totalSec) * 80));
505
+ this.pushEvent('discoveryProgress', {
506
+ phase: 'waitingForDevices',
507
+ progress: pct,
508
+ message: `Authenticated — waiting for devices... ${remaining}s`,
509
+ });
510
+ }, 1000);
511
+
512
+ this._discoveryInactivityTimeout = setTimeout(() => {
513
+ clearInterval(this._discoveryInactivityTickInterval);
514
+ this._discoveryInactivityTickInterval = null;
515
+ this.log.warn(
516
+ `No stations or devices discovered within ${totalSec}s after authentication. ` +
517
+ 'The account may have no devices or the guest invitation has not been accepted yet.',
518
+ );
519
+ this._discoveryPhase = 'done';
520
+ this.stations = [];
521
+ try {
522
+ this.storeAccessories();
523
+ } catch (error) {
524
+ this.log.error('Error storing empty accessories:', error);
525
+ }
526
+ this.pushEvent('discoveryProgress', {
527
+ phase: 'done',
528
+ progress: 100,
529
+ message: 'No devices found.',
530
+ });
531
+ this.pushEvent('addAccessory', { stations: [], noDevices: true });
532
+ this.eufyClient?.removeAllListeners();
533
+ this.eufyClient?.close();
534
+ }, totalSec * 1000);
535
+ }
536
+
537
+ /**
538
+ * Cancel the discovery inactivity timeout (called when a station or device is discovered).
539
+ */
540
+ _cancelDiscoveryInactivityTimeout() {
541
+ if (this._discoveryInactivityTickInterval) {
542
+ clearInterval(this._discoveryInactivityTickInterval);
543
+ this._discoveryInactivityTickInterval = null;
544
+ }
545
+ if (this._discoveryInactivityTimeout) {
546
+ clearTimeout(this._discoveryInactivityTimeout);
547
+ this._discoveryInactivityTimeout = null;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Parse a semver string into [major, minor, patch].
553
+ * @param {string} ver - e.g. '4.4.2-beta.18'
554
+ * @returns {number[]}
555
+ */
556
+ _parseSemver(ver) {
557
+ return (ver || '0.0.0').replace(/-.*$/, '').split('.').map(Number);
558
+ }
559
+
560
+ async loadStoredAccessories() {
561
+ try {
562
+ if (!fs.existsSync(this.storedAccessories_file)) {
563
+ this.log.debug('Stored accessories file does not exist.');
564
+ return [];
565
+ }
566
+
567
+ const storedData = await fs.promises.readFile(this.storedAccessories_file, { encoding: 'utf-8' });
568
+ const { version: storedVersion, storedAt, stations: storedAccessories } = JSON.parse(storedData);
569
+
570
+ // --- Cache age check (30 days) ---
571
+ if (storedAt) {
572
+ const ageMs = Date.now() - new Date(storedAt).getTime();
573
+ const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
574
+ if (ageDays >= 30) {
575
+ this.pushEvent('cacheWarning', { reason: 'stale', ageDays });
576
+ this.log.warn(`Stored accessories are ${ageDays} days old. User should re-login to refresh.`);
577
+ }
578
+ }
579
+
580
+ // --- Version branch check ---
581
+ if (storedVersion && storedVersion !== LIB_VERSION) {
582
+ const [curMajor, curMinor] = this._parseSemver(LIB_VERSION);
583
+ const [stoMajor, stoMinor] = this._parseSemver(storedVersion);
584
+
585
+ if (curMajor !== stoMajor || curMinor !== stoMinor) {
586
+ // Different minor (or major) branch → force re-login
587
+ this.pushEvent('cacheWarning', {
588
+ reason: 'versionForce',
589
+ currentVersion: LIB_VERSION,
590
+ storedVersion,
591
+ });
592
+ this.log.warn(`Stored version (${storedVersion}) is on a different branch than current (${LIB_VERSION}). Forcing re-login.`);
593
+ return { stations: [], storedAt: null }; // Return empty to force login flow
594
+ } else {
595
+ // Same minor branch, different patch → soft warning
596
+ this.pushEvent('cacheWarning', {
597
+ reason: 'versionWarn',
598
+ currentVersion: LIB_VERSION,
599
+ storedVersion,
600
+ });
601
+ this.log.warn(`Stored version (${storedVersion}) differs from current (${LIB_VERSION}) but same branch. Consider re-login.`);
602
+ }
603
+ }
604
+
605
+ return { stations: storedAccessories, storedAt: storedAt || null };
606
+ } catch (error) {
607
+ this.log.error('Could not get stored accessories. Most likely no stored accessories yet: ' + error);
608
+ return { stations: [], storedAt: null };
609
+ }
610
+ }
611
+
612
+ async delay(ms) {
613
+ return new Promise(resolve => setTimeout(resolve, ms));
614
+ }
615
+
616
+ async addStation(station) {
617
+ // Check if creds are guest admin
618
+ const rawStation = station.getRawStation();
619
+ if (rawStation.member.member_type !== UserType.ADMIN) {
620
+ this.adminAccountUsed = true;
621
+ this.eufyClient?.close();
622
+ this.pushEvent('AdminAccountUsed', true);
623
+ this.resetPlugin();
624
+ this.log.error(`
625
+ #########################
626
+ ######### ERROR #########
627
+ #########################
628
+ You're not using a guest admin account with this plugin! You must use a guest admin account!
629
+ Please look here for more details:
630
+ https://github.com/homebridge-plugins/homebridge-eufy-security/wiki/Create-a-dedicated-admin-account-for-Homebridge-Eufy-Security-Plugin
631
+ #########################
632
+ `);
633
+ return;
634
+ }
635
+
636
+ this._cancelDiscoveryInactivityTimeout();
637
+ this.pendingStations.push(station);
638
+ this.log.debug(`${station.getName()}: Station queued for processing`);
639
+ this._discoveryPhase = 'queuing';
640
+ this.pushEvent('discoveryProgress', {
641
+ phase: 'queuing',
642
+ progress: 30,
643
+ stations: this.pendingStations.length,
644
+ devices: this.pendingDevices.length,
645
+ message: `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s)...`,
646
+ });
647
+ this.resetDiscoveryDebounce();
648
+ }
649
+
650
+ async addDevice(device) {
651
+ if (this.adminAccountUsed) {
652
+ this.pushEvent('AdminAccountUsed', true);
653
+ return;
654
+ }
655
+
656
+ const deviceType = device.getDeviceType();
657
+ if (Device.isKeyPad(deviceType)) {
658
+ this.log.warn(`${device.getName()}: The keypad is ignored as it serves no purpose in this plugin. You can ignore this message.`);
659
+ return;
660
+ }
661
+
662
+ this._cancelDiscoveryInactivityTimeout();
663
+ this.pendingDevices.push(device);
664
+ this.log.debug(`${device.getName()}: Device queued for processing`);
665
+ this._discoveryPhase = 'queuing';
666
+ this.pushEvent('discoveryProgress', {
667
+ phase: 'queuing',
668
+ progress: 30,
669
+ stations: this.pendingStations.length,
670
+ devices: this.pendingDevices.length,
671
+ message: `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s)...`,
672
+ });
673
+ this.resetDiscoveryDebounce();
674
+ }
675
+
676
+ /**
677
+ * Resets the discovery debounce timer.
678
+ * Each time a station or device is emitted, the timer restarts.
679
+ * Processing begins once no new events arrive for DISCOVERY_DEBOUNCE_SEC seconds.
680
+ */
681
+ resetDiscoveryDebounce() {
682
+ if (this.processingTimeout) {
683
+ clearTimeout(this.processingTimeout);
684
+ }
685
+ if (this._closeTimeout) {
686
+ clearTimeout(this._closeTimeout);
687
+ }
688
+ if (this._debounceTickInterval) {
689
+ clearInterval(this._debounceTickInterval);
690
+ }
691
+ const delaySec = UiServer.DISCOVERY_DEBOUNCE_SEC;
692
+ this.log.debug(
693
+ `Discovery debounce reset — will process in ${delaySec}s if no more devices arrive ` +
694
+ `(${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s) queued)`,
695
+ );
696
+
697
+ // Tick progress from 30 → 95 during the debounce wait
698
+ const debounceStart = Date.now();
699
+ this._debounceTickInterval = setInterval(() => {
700
+ const elapsed = (Date.now() - debounceStart) / 1000;
701
+ const pct = Math.min(95, 30 + Math.floor((elapsed / delaySec) * 65));
702
+ const remaining = Math.max(0, Math.ceil(delaySec - elapsed));
703
+ this.pushEvent('discoveryProgress', {
704
+ phase: 'queuing',
705
+ progress: pct,
706
+ stations: this.pendingStations.length,
707
+ devices: this.pendingDevices.length,
708
+ message: `Discovered ${this.pendingStations.length} station(s), ${this.pendingDevices.length} device(s) — waiting for more... ${remaining}s`,
709
+ });
710
+ }, 1000);
711
+
712
+ this.processingTimeout = setTimeout(() => {
713
+ clearInterval(this._debounceTickInterval);
714
+ this._debounceTickInterval = null;
715
+ this.processPendingAccessories().catch(error => this.log.error('Error processing pending accessories:', error));
716
+ }, delaySec * 1000);
717
+ // Close connection after processing + potential 2-min unsupported intel wait
718
+ const closeAfterSec = delaySec + (UNSUPPORTED_INTEL_WAIT_MS / 1000) + 15;
719
+ this._closeTimeout = setTimeout(() => {
720
+ this.eufyClient?.removeAllListeners();
721
+ this.eufyClient?.close();
722
+ }, closeAfterSec * 1000);
723
+ }
724
+
725
+ async processPendingAccessories() {
726
+ this.log.debug(`Processing ${this.pendingStations.length} stations and ${this.pendingDevices.length} devices`);
727
+
728
+ this._discoveryPhase = 'processing';
729
+ this.pushEvent('discoveryProgress', {
730
+ phase: 'processing',
731
+ progress: 95,
732
+ stations: this.pendingStations.length,
733
+ devices: this.pendingDevices.length,
734
+ message: `Processing ${this.pendingStations.length} station(s) and ${this.pendingDevices.length} device(s)...`,
735
+ });
736
+
737
+ if (this.pendingStations.length === 0 || this.pendingDevices.length === 0) {
738
+ this.log.warn(
739
+ `Discovery finished with ${this.pendingStations.length} station(s) and ${this.pendingDevices.length} device(s). ` +
740
+ 'If this is unexpected, please verify your Eufy account has devices and the credentials used are for a guest admin account.',
741
+ );
742
+ }
743
+
744
+ // --- Collect unsupported items (stations + devices) upfront ---
745
+ // Hub/base stations (type 0, HB3, etc.) are not in DeviceProperties so
746
+ // Device.isSupported() returns false for them — exclude known station types.
747
+ const unsupportedItems = [];
748
+
749
+ for (const station of this.pendingStations) {
750
+ try {
751
+ const st = station.getDeviceType();
752
+ if (!Device.isStation(st) && !Device.isSupported(st)) unsupportedItems.push(station);
753
+ } catch (e) { /* ignore */ }
754
+ }
755
+ for (const device of this.pendingDevices) {
756
+ try {
757
+ if (!Device.isSupported(device.getDeviceType())) unsupportedItems.push(device);
758
+ } catch (e) { /* ignore */ }
759
+ }
760
+
761
+ // If unsupported items exist, notify UI and wait (user can skip via /skipIntelWait)
762
+ if (unsupportedItems.length > 0) {
763
+ const names = unsupportedItems.map(i => `${i.getName()} (type ${i.getDeviceType()})`).join(', ');
764
+ this._skipIntelWait = false;
765
+
766
+ this.pushEvent('discoveryWarning', {
767
+ unsupportedCount: unsupportedItems.length,
768
+ unsupportedNames: names,
769
+ waitSeconds: UNSUPPORTED_INTEL_WAIT_MS / 1000,
770
+ message: `${unsupportedItems.length} unsupported device(s) detected: ${names}`,
771
+ });
772
+
773
+ this.log.info(`Unsupported intel: waiting up to ${UNSUPPORTED_INTEL_WAIT_MS / 1000}s for raw data (user can skip)`);
774
+
775
+ // Cancellable wait — check _skipIntelWait every second, ticking progress 50 → 95
776
+ const pollMs = 1000;
777
+ let waited = 0;
778
+ while (waited < UNSUPPORTED_INTEL_WAIT_MS && !this._skipIntelWait) {
779
+ await this.delay(pollMs);
780
+ waited += pollMs;
781
+ const pct = Math.min(95, 50 + Math.floor((waited / UNSUPPORTED_INTEL_WAIT_MS) * 45));
782
+ const remaining = Math.max(0, Math.ceil((UNSUPPORTED_INTEL_WAIT_MS - waited) / 1000));
783
+ this.pushEvent('discoveryProgress', {
784
+ phase: 'unsupportedWait',
785
+ progress: pct,
786
+ message: `Collecting data for ${unsupportedItems.length} unsupported device(s)... ${remaining}s`,
787
+ });
788
+ }
789
+
790
+ if (this._skipIntelWait) {
791
+ this.log.info(`Unsupported intel wait skipped by user after ${waited / 1000}s`);
792
+ } else {
793
+ this.log.info(`Unsupported intel wait completed (${waited / 1000}s)`);
794
+ }
795
+ }
796
+
797
+ this.pushEvent('discoveryProgress', {
798
+ phase: 'buildingStations',
799
+ progress: 96,
800
+ message: 'Building station list...',
801
+ });
802
+
803
+ // Process queued stations
804
+ for (const station of this.pendingStations) {
805
+ const stationType = station.getDeviceType();
806
+ const stationSerial = station.getSerial();
807
+
808
+ const s = {
809
+ uniqueId: stationSerial,
810
+ displayName: station.getName(),
811
+ type: stationType,
812
+ typename: DeviceType[stationType],
813
+ disabled: false,
814
+ devices: [],
815
+ properties: station.getProperties(),
816
+ unsupported: false,
817
+ };
818
+
819
+ try {
820
+ delete s.properties.picture;
821
+ } catch (error) {
822
+ // ignore
823
+ }
824
+
825
+ s.ignored = (this.config['ignoreStations'] ?? []).includes(s.uniqueId);
826
+
827
+ // Pre-compute power info for the UI
828
+ s.power = this._computePower(s.properties);
829
+
830
+ if (!Device.isStation(stationType)) {
831
+ // Not a hub/base station — the station IS a standalone device (station.type == device.type)
832
+
833
+ if (!Device.isSupported(stationType)) {
834
+ // Device type not recognized by eufy-security-client — truly unsupported
835
+ s.unsupported = true;
836
+
837
+ this.log.warn(`Station "${station.getName()}" (type ${stationType}) is not supported by eufy-security-client`);
838
+
839
+ // Immediately add the unsupported station and skip further processing
840
+ this.stations.push(s);
841
+ continue;
842
+ } else {
843
+ // Check if the matching device was emitted by the client
844
+ const hasMatchingDevice = this.pendingDevices.some(d => d.getSerial() === stationSerial);
845
+
846
+ if (hasMatchingDevice) {
847
+ s.standalone = true;
848
+ s.disabled = true; // No separate station card; settings accessible via device card
849
+
850
+ // Standalone Locks, Doorbells and SmartDrops don't have Security Control
851
+ if (Device.isLock(s.type) || Device.isDoorbell(s.type) || Device.isSmartDrop(s.type)) {
852
+ s.noSecurityControl = true;
853
+ }
854
+ } else {
855
+ // Station exists but no device counterpart was emitted — unsupported
856
+ s.unsupported = true;
857
+ this.log.warn(`Station "${station.getName()}" (${DeviceType[stationType]}) has no matching device and will be marked as unsupported`);
858
+
859
+ // Short-circuit processing for unsupported station
860
+ this.stations.push(s);
861
+ continue;
862
+ }
863
+ }
864
+ }
865
+
866
+ this.stations.push(s);
867
+ }
868
+
869
+ this.pushEvent('discoveryProgress', {
870
+ phase: 'buildingDevices',
871
+ progress: 98,
872
+ message: 'Building device list...',
873
+ });
874
+
875
+ // Process queued devices and attach them to stations
876
+ for (const device of this.pendingDevices) {
877
+ const devType = device.getDeviceType();
878
+
879
+ const d = {
880
+ uniqueId: device.getSerial(),
881
+ displayName: device.getName(),
882
+ type: devType,
883
+ typename: DeviceType[devType],
884
+ standalone: device.getSerial() === device.getStationSerial(),
885
+ hasBattery: device.hasBattery(),
886
+ isCamera: device.isCamera() || Device.isLockWifiVideo(devType),
887
+ isDoorbell: device.isDoorbell(),
888
+ isKeypad: device.isKeyPad(),
889
+ isMotionSensor: Device.isMotionSensor(devType),
890
+ isEntrySensor: Device.isEntrySensor(devType),
891
+ isLock: Device.isLock(devType),
892
+ isSmartDrop: Device.isSmartDrop(devType),
893
+ supportsRTSP: device.hasPropertyValue(PropertyName.DeviceRTSPStream),
894
+ supportsTalkback: device.hasCommand(CommandName.DeviceStartTalkback),
895
+ DeviceEnabled: device.hasProperty(PropertyName.DeviceEnabled),
896
+ DeviceMotionDetection: device.hasProperty(PropertyName.DeviceMotionDetection),
897
+ DeviceLight: device.hasProperty(PropertyName.DeviceLight),
898
+ DeviceChimeIndoor: device.hasProperty(PropertyName.DeviceChimeIndoor),
899
+ disabled: false,
900
+ properties: device.getProperties(),
901
+ unsupported: false,
902
+ };
903
+
904
+ // Mark device as unsupported if eufy-security-client doesn't recognize this device type
905
+ if (!Device.isSupported(devType)) {
906
+ d.unsupported = true;
907
+ }
908
+
909
+ // Pre-compute power info for the UI
910
+ d.power = this._computePower(d.properties);
911
+
912
+ try {
913
+ delete d.properties.picture;
914
+ } catch (error) {
915
+ this.log.error(error);
916
+ }
917
+
918
+ d.ignored = (this.config['ignoreDevices'] ?? []).includes(d.uniqueId);
919
+
920
+ const stationUniqueId = device.getStationSerial();
921
+ const stationIndex = this.stations.findIndex(station => station.uniqueId === stationUniqueId);
922
+
923
+ if (stationIndex !== -1) {
924
+ // If parent station is unsupported, propagate flag to device
925
+ if (this.stations[stationIndex].unsupported) {
926
+ d.unsupported = true;
927
+ }
928
+
929
+ if (!this.stations[stationIndex].devices) {
930
+ this.stations[stationIndex].devices = [];
931
+ }
932
+ this.stations[stationIndex].devices.push(d);
933
+ } else {
934
+ this.log.error('Station not found for device:', d.displayName);
935
+ }
936
+ }
937
+
938
+ // Write unsupported.json with raw device intel for triage (before clearing pending queues)
939
+ try {
940
+ this.storeUnsupportedDevices(this.pendingStations, this.pendingDevices);
941
+ } catch (error) {
942
+ this.log.error('Error storing unsupported devices:', error);
943
+ }
944
+
945
+ // Clear pending queues
946
+ this.pendingStations = [];
947
+ this.pendingDevices = [];
948
+
949
+ // Always send the final list to the UI, even if empty
950
+ try {
951
+ this.storeAccessories();
952
+ } catch (error) {
953
+ this.log.error('Error storing accessories:', error);
954
+ }
955
+
956
+ this.pushEvent('discoveryProgress', {
957
+ phase: 'done',
958
+ progress: 100,
959
+ message: 'Discovery complete!',
960
+ });
961
+
962
+ this.pushEvent('addAccessory', { stations: this.stations, extendedDiscovery: unsupportedItems.length > 0 });
963
+ }
964
+
965
+ storeAccessories() {
966
+ if (!fs.existsSync(this.storagePath)) {
967
+ fs.mkdirSync(this.storagePath, { recursive: true });
968
+ }
969
+ const dataToStore = { version: LIB_VERSION, storedAt: new Date().toISOString(), stations: this.stations };
970
+ fs.writeFileSync(this.storedAccessories_file, JSON.stringify(dataToStore));
971
+ }
972
+
973
+ // ── Sensitive-field redaction ──────────────────────────────────────────────
974
+ // Keys whose string values must be partially masked before persisting to
975
+ // unsupported.json. Values are [keepStart, keepEnd] — the number of
976
+ // characters to leave visible at the beginning and end of the string.
977
+ // An empty / falsy string is left as-is so we can tell the field is blank.
978
+
979
+ static SENSITIVE_KEYS = new Map([
980
+ // Serial numbers — keep model prefix (e.g. T8170)
981
+ ['station_sn', [5, 0]],
982
+ ['device_sn', [5, 0]],
983
+
984
+ // Network / connectivity
985
+ ['wifi_ssid', [3, 0]],
986
+ ['wifi_mac', [4, 0]],
987
+ ['ip_addr', [3, 0]],
988
+ ['local_ip', [3, 0]],
989
+
990
+ // Hardware identifiers
991
+ ['cpuid', [4, 0]],
992
+
993
+ // P2P identifiers & key-exchange material
994
+ ['p2p_did', [7, 0]],
995
+ ['push_did', [7, 0]],
996
+ ['ndt_did', [7, 0]],
997
+ ['query_server_did', [7, 0]],
998
+ ['p2p_conn', [4, 0]],
999
+ ['app_conn', [4, 0]],
1000
+ ['p2p_license', [2, 0]],
1001
+ ['push_license', [2, 0]],
1002
+ ['ndt_license', [2, 0]],
1003
+ ['wakeup_key', [4, 0]],
1004
+ ['dsk_key', [4, 0]],
1005
+ ['setup_code', [2, 0]],
1006
+ ['setup_id', [2, 0]],
1007
+
1008
+ // User / account identifiers
1009
+ ['account_id', [4, 0]],
1010
+ ['admin_user_id', [4, 0]],
1011
+ ['member_user_id', [4, 0]],
1012
+ ['action_user_id', [4, 0]],
1013
+ ['short_user_id', [4, 0]],
1014
+ ['email', [3, 0]],
1015
+ ['action_user_email', [3, 0]],
1016
+ ['member_nick', [3, 0]],
1017
+ ['nick_name', [3, 0]],
1018
+ ['action_user_name', [3, 0]],
1019
+ ['avatar', [0, 0]],
1020
+ ['member_avatar', [0, 0]],
1021
+
1022
+ // House / location
1023
+ ['house_id', [4, 0]],
1024
+
1025
+ // Misc identifiers
1026
+ ['volume', [6, 0]],
1027
+ ]);
1028
+
1029
+ /**
1030
+ * Partially mask a string value, keeping the first `keepStart` and last
1031
+ * `keepEnd` characters visible. Returns the original if it is empty or
1032
+ * too short to meaningfully mask.
1033
+ */
1034
+ static _partialMask(value, keepStart, keepEnd) {
1035
+ if (typeof value !== 'string' || !value) return value;
1036
+ const minLen = keepStart + keepEnd + 1;
1037
+ if (value.length <= minLen) return value.length <= 2 ? '***' : value[0] + '***';
1038
+ return value.slice(0, keepStart) + '***' + (keepEnd > 0 ? value.slice(-keepEnd) : '');
1039
+ }
1040
+
1041
+ /**
1042
+ * Deep-walk an object and partially redact every value whose key appears
1043
+ * in SENSITIVE_KEYS. Returns a new object — the original is not mutated.
1044
+ */
1045
+ static _redactSensitiveFields(obj) {
1046
+ if (Array.isArray(obj)) {
1047
+ return obj.map(item => EufySecurityServer._redactSensitiveFields(item));
1048
+ }
1049
+ if (obj !== null && typeof obj === 'object') {
1050
+ const out = {};
1051
+ for (const [key, val] of Object.entries(obj)) {
1052
+ const rule = EufySecurityServer.SENSITIVE_KEYS.get(key);
1053
+ if (rule && typeof val === 'string') {
1054
+ out[key] = EufySecurityServer._partialMask(val, rule[0], rule[1]);
1055
+ } else if (typeof val === 'object' && val !== null) {
1056
+ out[key] = EufySecurityServer._redactSensitiveFields(val);
1057
+ } else {
1058
+ out[key] = val;
1059
+ }
1060
+ }
1061
+ return out;
1062
+ }
1063
+ return obj;
1064
+ }
1065
+
1066
+ // ── Unsupported device storage ──────────────────────────────────────────
1067
+
1068
+ /**
1069
+ * Collect raw intel for all unsupported devices/stations and write to unsupported.json.
1070
+ * This data is only used by the Plugin UI for triage and diagnostics.
1071
+ */
1072
+ storeUnsupportedDevices(pendingStations, pendingDevices) {
1073
+ const unsupportedEntries = [];
1074
+
1075
+ // Collect unsupported standalone stations
1076
+ for (const station of pendingStations) {
1077
+ const stationType = station.getDeviceType();
1078
+ if (!Device.isStation(stationType) && !Device.isSupported(stationType)) {
1079
+ unsupportedEntries.push(this._buildUnsupportedStationEntry(station));
1080
+ }
1081
+ }
1082
+
1083
+ // Collect unsupported devices
1084
+ for (const device of pendingDevices) {
1085
+ if (!Device.isSupported(device.getDeviceType())) {
1086
+ unsupportedEntries.push(this._buildUnsupportedDeviceEntry(device));
1087
+ }
1088
+ }
1089
+
1090
+ if (!fs.existsSync(this.storagePath)) {
1091
+ fs.mkdirSync(this.storagePath, { recursive: true });
1092
+ }
1093
+
1094
+ const dataToStore = { version: LIB_VERSION, storedAt: new Date().toISOString(), devices: unsupportedEntries };
1095
+ fs.writeFileSync(this.unsupported_file, JSON.stringify(dataToStore));
1096
+ this.log.debug(`Persisted ${unsupportedEntries.length} unsupported device(s) to unsupported.json`);
1097
+ }
1098
+
1099
+ /**
1100
+ * Build a triage-ready intel object for an unsupported device.
1101
+ */
1102
+ _buildUnsupportedDeviceEntry(device) {
1103
+ const rawDevice = device.getRawDevice ? device.getRawDevice() : {};
1104
+ const rawProps = device.getRawProperties ? device.getRawProperties() : {};
1105
+
1106
+ const wifiSsid = rawDevice.wifi_ssid || undefined;
1107
+ const localIp = rawDevice.ip_addr || rawDevice.local_ip || undefined;
1108
+
1109
+ return {
1110
+ uniqueId: device.getSerial(),
1111
+ displayName: device.getName(),
1112
+ type: device.getDeviceType(),
1113
+ typename: DeviceType[device.getDeviceType()] || undefined,
1114
+ stationSerialNumber: device.getStationSerial(),
1115
+ model: rawDevice.device_model,
1116
+ hardwareVersion: rawDevice.main_hw_version,
1117
+ softwareVersion: rawDevice.main_sw_version,
1118
+ wifiSsid: wifiSsid ? EufySecurityServer._partialMask(wifiSsid, 3, 0) : undefined,
1119
+ localIp: localIp ? EufySecurityServer._partialMask(localIp, 3, 0) : undefined,
1120
+ rawDevice: EufySecurityServer._redactSensitiveFields(rawDevice),
1121
+ rawProperties: rawProps,
1122
+ };
1123
+ }
1124
+
1125
+ /**
1126
+ * Build a triage-ready intel object for an unsupported standalone station.
1127
+ */
1128
+ _buildUnsupportedStationEntry(station) {
1129
+ const rawStation = station.getRawStation ? station.getRawStation() : {};
1130
+ const rawProps = station.getRawProperties ? station.getRawProperties() : {};
1131
+
1132
+ const wifiSsid = rawStation.wifi_ssid || undefined;
1133
+ const localIp = rawStation.ip_addr || undefined;
1134
+
1135
+ return {
1136
+ uniqueId: station.getSerial(),
1137
+ displayName: station.getName(),
1138
+ type: station.getDeviceType(),
1139
+ typename: DeviceType[station.getDeviceType()] || undefined,
1140
+ stationSerialNumber: station.getSerial(),
1141
+ model: rawStation.station_model,
1142
+ hardwareVersion: rawStation.main_hw_version,
1143
+ softwareVersion: rawStation.main_sw_version,
1144
+ wifiSsid: wifiSsid ? EufySecurityServer._partialMask(wifiSsid, 3, 0) : undefined,
1145
+ localIp: localIp ? EufySecurityServer._partialMask(localIp, 3, 0) : undefined,
1146
+ rawDevice: EufySecurityServer._redactSensitiveFields(rawStation),
1147
+ rawProperties: rawProps,
1148
+ };
1149
+ }
1150
+
1151
+ /**
1152
+ * Load unsupported device intel from disk.
1153
+ */
1154
+ async loadUnsupportedDevices() {
1155
+ try {
1156
+ if (!fs.existsSync(this.unsupported_file)) {
1157
+ return { devices: [] };
1158
+ }
1159
+ const data = JSON.parse(await fs.promises.readFile(this.unsupported_file, 'utf-8'));
1160
+ return { devices: data.devices || [] };
1161
+ } catch (error) {
1162
+ this.log.error('Could not load unsupported devices: ' + error);
1163
+ return { devices: [] };
1164
+ }
1165
+ }
1166
+
1167
+ async resetPlugin() {
1168
+ try {
1169
+ fs.rmSync(this.storagePath, { recursive: true, force: true });
1170
+ return { result: 1 };
1171
+ } catch (error) {
1172
+ this.log.error('Could not reset plugin: ' + error);
1173
+ return { result: 0 };
1174
+ }
1175
+ }
1176
+
1177
+ async getLogFiles() {
1178
+ const files = await fs.promises.readdir(this.storagePath);
1179
+
1180
+ const logFiles = files.filter(file => {
1181
+ return file.endsWith('.log') || file.endsWith('.log.gz');
1182
+ });
1183
+
1184
+ const nonEmptyLogFiles = await Promise.all(logFiles.map(async file => {
1185
+ const filePath = path.join(this.storagePath, file);
1186
+ const stats = await fs.promises.stat(filePath);
1187
+ if (stats.size > 0) {
1188
+ return file;
1189
+ }
1190
+ return null;
1191
+ }));
1192
+
1193
+ return nonEmptyLogFiles.filter(file => file !== null);
1194
+ }
1195
+
1196
+ async downloadDiagnostics() {
1197
+ this.pushEvent('diagnosticsProgress', { progress: 10, status: 'Collecting log files' });
1198
+ const finalLogFiles = await this.getLogFiles();
1199
+
1200
+ this.pushEvent('diagnosticsProgress', { progress: 30, status: 'Adding files to archive' });
1201
+ const zip = new Zip();
1202
+ let numberOfFiles = 0;
1203
+ finalLogFiles.forEach(logFile => {
1204
+ const filePath = path.join(this.storagePath, logFile);
1205
+ zip.addFile(filePath);
1206
+ numberOfFiles++;
1207
+ });
1208
+
1209
+ // Include accessories.json for diagnostics
1210
+ if (fs.existsSync(this.storedAccessories_file)) {
1211
+ zip.addFile(this.storedAccessories_file);
1212
+ numberOfFiles++;
1213
+ }
1214
+
1215
+ // Include unsupported.json for diagnostics
1216
+ if (fs.existsSync(this.unsupported_file)) {
1217
+ zip.addFile(this.unsupported_file);
1218
+ numberOfFiles++;
1219
+ }
1220
+
1221
+ this.pushEvent('diagnosticsProgress', { progress: 40, status: 'Checking archive content' });
1222
+ if (numberOfFiles === 0) {
1223
+ throw new Error('No diagnostic files were found');
1224
+ }
1225
+
1226
+ try {
1227
+ const now = new Date();
1228
+ const timestamp = now.toISOString().replace(/[:T]/g, '-').replace(/\..+/, '');
1229
+ this.diagnosticsZipFilePath = path.join(this.storagePath, `diagnostics-${timestamp}.zip`);
1230
+
1231
+ this.pushEvent('diagnosticsProgress', { progress: 45, status: `Compressing ${numberOfFiles} files` });
1232
+ await zip.archive(this.diagnosticsZipFilePath);
1233
+
1234
+ this.pushEvent('diagnosticsProgress', { progress: 80, status: 'Reading content' });
1235
+ const fileBuffer = fs.readFileSync(this.diagnosticsZipFilePath);
1236
+
1237
+ this.pushEvent('diagnosticsProgress', { progress: 90, status: 'Returning zip file' });
1238
+ return { buffer: fileBuffer, filename: path.basename(this.diagnosticsZipFilePath) };
1239
+ } catch (error) {
1240
+ this.log.error('Error while generating diagnostics archive: ' + error);
1241
+ throw error;
1242
+ } finally {
1243
+ this.removeDiagnosticsArchive();
1244
+ }
1245
+ }
1246
+
1247
+ removeDiagnosticsArchive() {
1248
+ try {
1249
+ if (fs.existsSync(this.diagnosticsZipFilePath)) {
1250
+ fs.unlinkSync(this.diagnosticsZipFilePath);
1251
+ }
1252
+ return true;
1253
+ } catch {
1254
+ return false;
1255
+ }
1256
+ }
1257
+
1258
+ async cleanStorage() {
1259
+ const preserved = new Set([
1260
+ 'accessories.json',
1261
+ 'persistent.json',
1262
+ 'eufy-security.log',
1263
+ 'eufy-lib.log',
1264
+ 'ffmpeg.log',
1265
+ 'configui-server.log',
1266
+ 'configui-lib.log',
1267
+ ]);
1268
+ const files = await fs.promises.readdir(this.storagePath);
1269
+ let deleted = 0;
1270
+ for (const file of files) {
1271
+ if (preserved.has(file)) continue;
1272
+ const filePath = path.join(this.storagePath, file);
1273
+ try {
1274
+ await fs.promises.unlink(filePath);
1275
+ deleted++;
1276
+ this.log.debug(`Deleted: ${file}`);
1277
+ } catch (error) {
1278
+ this.log.warn(`Failed to delete ${file}: ${error}`);
1279
+ }
1280
+ }
1281
+ this.log.info(`Cleaned ${deleted} file(s)`);
1282
+ return { deleted };
1283
+ }
1284
+
1285
+ async getSystemInfo() {
1286
+ const os = await import('os');
1287
+ let homebridgeVersion = 'unknown';
1288
+ try {
1289
+ const hbPkg = require('homebridge/package.json');
1290
+ homebridgeVersion = hbPkg.version;
1291
+ } catch {
1292
+ // Homebridge package not resolvable from here
1293
+ }
1294
+
1295
+ let deviceSummary = [];
1296
+ try {
1297
+ if (fs.existsSync(this.storedAccessories_file)) {
1298
+ const storedData = JSON.parse(fs.readFileSync(this.storedAccessories_file, 'utf-8'));
1299
+ if (storedData.stations) {
1300
+ deviceSummary = storedData.stations.map(s => ({
1301
+ name: s.displayName,
1302
+ type: s.typename,
1303
+ devices: (s.devices || []).map(d => ({
1304
+ name: d.displayName,
1305
+ type: d.typename,
1306
+ })),
1307
+ }));
1308
+ }
1309
+ }
1310
+ } catch {
1311
+ // ignore
1312
+ }
1313
+
1314
+ this.log.debug('System info requested by UI');
1315
+
1316
+ return {
1317
+ pluginVersion: LIB_VERSION,
1318
+ eufyClientVersion: libVersion,
1319
+ homebridgeVersion,
1320
+ nodeVersion: process.version,
1321
+ os: `${os.type()} ${os.release()} (${os.arch()})`,
1322
+ devices: deviceSummary,
1323
+ };
1324
+ }
1325
+ }
1326
+
1327
+ (() => new UiServer())();