@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.
- package/CHANGELOG.md +5 -0
- package/FUNDING.yml +1 -0
- package/LICENSE +176 -0
- package/README.md +67 -0
- package/config.schema.json +6 -0
- package/dist/accessories/AutoSyncStationAccessory.js +156 -0
- package/dist/accessories/AutoSyncStationAccessory.js.map +1 -0
- package/dist/accessories/BaseAccessory.js +247 -0
- package/dist/accessories/BaseAccessory.js.map +1 -0
- package/dist/accessories/CameraAccessory.js +431 -0
- package/dist/accessories/CameraAccessory.js.map +1 -0
- package/dist/accessories/Device.js +67 -0
- package/dist/accessories/Device.js.map +1 -0
- package/dist/accessories/EntrySensorAccessory.js +48 -0
- package/dist/accessories/EntrySensorAccessory.js.map +1 -0
- package/dist/accessories/LockAccessory.js +142 -0
- package/dist/accessories/LockAccessory.js.map +1 -0
- package/dist/accessories/MotionSensorAccessory.js +48 -0
- package/dist/accessories/MotionSensorAccessory.js.map +1 -0
- package/dist/accessories/SmartDropAccessory.js +145 -0
- package/dist/accessories/SmartDropAccessory.js.map +1 -0
- package/dist/accessories/StationAccessory.js +371 -0
- package/dist/accessories/StationAccessory.js.map +1 -0
- package/dist/config.js +25 -0
- package/dist/config.js.map +1 -0
- package/dist/controller/LocalLivestreamManager.js +116 -0
- package/dist/controller/LocalLivestreamManager.js.map +1 -0
- package/dist/controller/recordingDelegate.js +208 -0
- package/dist/controller/recordingDelegate.js.map +1 -0
- package/dist/controller/snapshotDelegate.js +345 -0
- package/dist/controller/snapshotDelegate.js.map +1 -0
- package/dist/controller/streamingDelegate.js +345 -0
- package/dist/controller/streamingDelegate.js.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces.js +2 -0
- package/dist/interfaces.js.map +1 -0
- package/dist/media/Snapshot-Unavailable.png +0 -0
- package/dist/media/Snapshot-Unavailable.xcf +0 -0
- package/dist/media/Snapshot-black.png +0 -0
- package/dist/media/camera-disabled.png +0 -0
- package/dist/media/camera-offline.png +0 -0
- package/dist/media/media/Snapshot-Unavailable.png +0 -0
- package/dist/media/media/Snapshot-Unavailable.xcf +0 -0
- package/dist/media/media/Snapshot-black.png +0 -0
- package/dist/media/media/camera-disabled.png +0 -0
- package/dist/media/media/camera-offline.png +0 -0
- package/dist/platform.js +716 -0
- package/dist/platform.js.map +1 -0
- package/dist/settings.js +38 -0
- package/dist/settings.js.map +1 -0
- package/dist/utils/Talkback.js +92 -0
- package/dist/utils/Talkback.js.map +1 -0
- package/dist/utils/accessoriesStore.js +206 -0
- package/dist/utils/accessoriesStore.js.map +1 -0
- package/dist/utils/configTypes.js +35 -0
- package/dist/utils/configTypes.js.map +1 -0
- package/dist/utils/ffmpeg.js +843 -0
- package/dist/utils/ffmpeg.js.map +1 -0
- package/dist/utils/interfaces.js +8 -0
- package/dist/utils/interfaces.js.map +1 -0
- package/dist/utils/utils.js +44 -0
- package/dist/utils/utils.js.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/eslint.config.mjs +18 -0
- package/homebridge-eufy-security.png +0 -0
- package/homebridge-ui/public/app.js +225 -0
- package/homebridge-ui/public/assets/devices/4g_lte_starlight_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/BATTERY_DOORBELL_C30.png +0 -0
- package/homebridge-ui/public/assets/devices/BATTERY_DOORBELL_C31.png +0 -0
- package/homebridge-ui/public/assets/devices/batterydoorbell1080p_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/batterydoorbell2kdual_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/batterydoorbell_e340_large.png +0 -0
- package/homebridge-ui/public/assets/devices/eufy-security-client.png +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2_large.png +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2c_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2cpro_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam2pro_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam3_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam3c_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycam3pro_large.png +0 -0
- package/homebridge-ui/public/assets/devices/eufycam_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/eufycame330_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/floodlight2_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/floodlight2pro_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/floodlight_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/floodlightcame340_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/garage_camera_t8452_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/homebase2_large.png +0 -0
- package/homebridge-ui/public/assets/devices/homebase3_large.png +0 -0
- package/homebridge-ui/public/assets/devices/homebase_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/homebasemini_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamC210_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamC220_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamE30_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamc120_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcammini_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/indoorcamp24_large.png +0 -0
- package/homebridge-ui/public/assets/devices/indoorcams350_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/keypad_large.png +0 -0
- package/homebridge-ui/public/assets/devices/minibase_chime_T8023_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/motionsensor_large.png +0 -0
- package/homebridge-ui/public/assets/devices/sensor_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartdrop_t8790_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8500_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8500_wifibridge_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8503_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8504_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_t8510P_t8520P_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8502_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8506_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_and_wifi_t8520_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_t8510_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_touch_t8510_wifibridge_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartlock_video_t8530_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartlockwifibridge_t8021_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/smartsafe_s10_t7400_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smartsafe_s12_t7401_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smarttrack_card_t87B2_large.png +0 -0
- package/homebridge-ui/public/assets/devices/smarttrack_link_t87B0_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocamc210_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocamc35_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocame20_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocame30_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocame40_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocaml20_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocams220_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solocams340_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solocams40_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/soloindoorcamc24_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/solooutdoorcamc22_large.png +0 -0
- package/homebridge-ui/public/assets/devices/solooutdoorcamc24_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/unknown.png +0 -0
- package/homebridge-ui/public/assets/devices/walllight_s100_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/walllight_s120_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/wireddoorbell1080p_large.jpg +0 -0
- package/homebridge-ui/public/assets/devices/wireddoorbell2k_large.png +0 -0
- package/homebridge-ui/public/assets/devices/wireddoorbelldual_large.jpg +0 -0
- package/homebridge-ui/public/assets/icons/attach.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_0.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_1.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_2.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_3.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_4.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_5.svg +1 -0
- package/homebridge-ui/public/assets/icons/battery_6.svg +1 -0
- package/homebridge-ui/public/assets/icons/bolt.svg +1 -0
- package/homebridge-ui/public/assets/icons/bug-report.svg +1 -0
- package/homebridge-ui/public/assets/icons/copy.svg +1 -0
- package/homebridge-ui/public/assets/icons/delete.svg +1 -0
- package/homebridge-ui/public/assets/icons/download.svg +1 -0
- package/homebridge-ui/public/assets/icons/info.svg +1 -0
- package/homebridge-ui/public/assets/icons/inventory.svg +1 -0
- package/homebridge-ui/public/assets/icons/refresh.svg +1 -0
- package/homebridge-ui/public/assets/icons/satellite_alt.svg +1 -0
- package/homebridge-ui/public/assets/icons/settings.svg +1 -0
- package/homebridge-ui/public/assets/icons/settings_backup_restore.svg +1 -0
- package/homebridge-ui/public/assets/icons/solar_power.svg +1 -0
- package/homebridge-ui/public/assets/icons/warning.svg +1 -0
- package/homebridge-ui/public/components/device-card.js +162 -0
- package/homebridge-ui/public/components/guard-modes.js +88 -0
- package/homebridge-ui/public/components/number-input.js +121 -0
- package/homebridge-ui/public/components/select.js +73 -0
- package/homebridge-ui/public/components/toggle.js +68 -0
- package/homebridge-ui/public/index.html +27 -0
- package/homebridge-ui/public/services/api.js +214 -0
- package/homebridge-ui/public/services/config.js +144 -0
- package/homebridge-ui/public/style.css +775 -0
- package/homebridge-ui/public/utils/countries.js +73 -0
- package/homebridge-ui/public/utils/device-images.js +89 -0
- package/homebridge-ui/public/utils/helpers.js +87 -0
- package/homebridge-ui/public/views/dashboard.js +226 -0
- package/homebridge-ui/public/views/device-detail.js +610 -0
- package/homebridge-ui/public/views/diagnostics.js +296 -0
- package/homebridge-ui/public/views/login.js +636 -0
- package/homebridge-ui/public/views/settings.js +192 -0
- package/homebridge-ui/public/views/unsupported-detail.js +296 -0
- package/homebridge-ui/server.js +1327 -0
- package/media/Snapshot-Unavailable.png +0 -0
- package/media/Snapshot-Unavailable.xcf +0 -0
- package/media/Snapshot-black.png +0 -0
- package/media/camera-disabled.png +0 -0
- package/media/camera-offline.png +0 -0
- 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())();
|