@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,214 @@
1
+ /**
2
+ * API service — wraps homebridge.request() and push event listeners.
3
+ * Communicates with server.ts endpoints.
4
+ */
5
+ // eslint-disable-next-line no-unused-vars
6
+ const Api = {
7
+ /** @private Track registered listeners to prevent stacking on re-render */
8
+ _listeners: {},
9
+
10
+ /**
11
+ * Register an event listener, replacing any previous listener for the same event.
12
+ * Prevents listener stacking when views re-render.
13
+ * @param {string} event
14
+ * @param {function} handler
15
+ */
16
+ _on(event, handler) {
17
+ if (this._listeners[event]) {
18
+ homebridge.removeEventListener(event, this._listeners[event]);
19
+ }
20
+ this._listeners[event] = handler;
21
+ homebridge.addEventListener(event, handler);
22
+ },
23
+
24
+ /**
25
+ * Remove a previously registered listener for an event.
26
+ * Views can call this on teardown to explicitly detach their callbacks.
27
+ * @param {string} event
28
+ */
29
+ _off(event) {
30
+ if (this._listeners[event]) {
31
+ homebridge.removeEventListener(event, this._listeners[event]);
32
+ delete this._listeners[event];
33
+ }
34
+ },
35
+
36
+ /**
37
+ * Login with credentials, TFA code, or captcha
38
+ * @param {object} options - { username, password, country, deviceName } | { verifyCode } | { captcha: { captchaCode, captchaId } }
39
+ * @returns {Promise<{success: boolean, failReason?: number, data?: any}>}
40
+ */
41
+ async login(options) {
42
+ return homebridge.request('/login', options);
43
+ },
44
+
45
+ /**
46
+ * Check if a valid persistent cache file exists on the server.
47
+ * @returns {Promise<{valid: boolean}>}
48
+ */
49
+ async checkCache() {
50
+ return homebridge.request('/checkCache');
51
+ },
52
+
53
+ /**
54
+ * Load stored accessories from server (cached from last login)
55
+ * @returns {Promise<Array>} Array of L_Station objects
56
+ */
57
+ async loadStoredAccessories() {
58
+ return homebridge.request('/storedAccessories');
59
+ },
60
+
61
+ /**
62
+ * Load unsupported device intel from server (raw properties for triage)
63
+ * @returns {Promise<{devices: Array}>} Array of unsupported device entries
64
+ */
65
+ async loadUnsupportedDevices() {
66
+ return homebridge.request('/unsupportedDevices');
67
+ },
68
+
69
+ /**
70
+ * Reset plugin data (removes persistent storage)
71
+ * @returns {Promise<{result: number}>}
72
+ */
73
+ async resetPlugin() {
74
+ return homebridge.request('/reset');
75
+ },
76
+
77
+ /**
78
+ * Download compressed diagnostics archive (logs + accessories)
79
+ * @returns {Promise<{buffer: Buffer, filename: string}>}
80
+ */
81
+ async downloadDiagnostics() {
82
+ return homebridge.request('/downloadDiagnostics');
83
+ },
84
+
85
+ /**
86
+ * Delete all files except persistent data and accessories
87
+ * @returns {Promise<{deleted: number}>}
88
+ */
89
+ async cleanStorage() {
90
+ return homebridge.request('/cleanStorage');
91
+ },
92
+
93
+ /**
94
+ * Register a listener for the 'addAccessory' push event.
95
+ * Fired by server after batch processing completes (~45s after login).
96
+ * Replaces any previously registered listener.
97
+ * @param {function} callback - receives array of L_Station objects
98
+ */
99
+ onAccessoriesReady(callback) {
100
+ this._on('addAccessory', (event) => {
101
+ callback(event.data);
102
+ });
103
+ },
104
+
105
+ /**
106
+ * Register a listener for discovery warnings (e.g. extended discovery due to unsupported devices)
107
+ * @param {function} callback - receives { unsupportedCount, unsupportedNames, waitSeconds, message }
108
+ */
109
+ onDiscoveryWarning(callback) {
110
+ this._on('discoveryWarning', (event) => {
111
+ callback(event.data);
112
+ });
113
+ },
114
+
115
+ /**
116
+ * Register a listener for real-time discovery progress events.
117
+ * @param {function} callback - receives { phase, stations?, devices?, message }
118
+ */
119
+ onDiscoveryProgress(callback) {
120
+ this._on('discoveryProgress', (event) => {
121
+ callback(event.data);
122
+ });
123
+ },
124
+ /**
125
+ * Register a listener for TFA (two-factor auth) request from the server.
126
+ * Fired when the Eufy server requires a verification code.
127
+ * @param {function} callback - receives no arguments
128
+ */
129
+ onTfaRequest(callback) {
130
+ this._on('tfaRequest', () => callback());
131
+ },
132
+
133
+ /**
134
+ * Register a listener for Captcha request from the server.
135
+ * Fired when the Eufy server requires captcha verification.
136
+ * @param {function} callback - receives { id, captcha }
137
+ */
138
+ onCaptchaRequest(callback) {
139
+ this._on('captchaRequest', (event) => callback(event.data));
140
+ },
141
+
142
+ /**
143
+ * Register a listener for successful authentication.
144
+ * @param {function} callback - receives no arguments
145
+ */
146
+ onAuthSuccess(callback) {
147
+ this._on('authSuccess', () => callback());
148
+ },
149
+
150
+ /**
151
+ * Register a listener for authentication errors (timeout, bad credentials, etc.).
152
+ * @param {function} callback - receives { message }
153
+ */
154
+ onAuthError(callback) {
155
+ this._on('authError', (event) => callback(event.data));
156
+ },
157
+ /**
158
+ * Tell the server to skip the unsupported-device intel wait and proceed immediately.
159
+ * @returns {Promise<{ok: boolean}>}
160
+ */
161
+ async skipIntelWait() {
162
+ return homebridge.request('/skipIntelWait');
163
+ },
164
+
165
+ /**
166
+ * Get the current discovery state (phase + pending counts).
167
+ * Used by the discovery UI to catch up on events that fired during login.
168
+ * @returns {Promise<{phase: string, stations: number, devices: number}>}
169
+ */
170
+ async getDiscoveryState() {
171
+ return homebridge.request('/discoveryState');
172
+ },
173
+
174
+ /**
175
+ * Register a listener for admin account error.
176
+ * Replaces any previously registered listener.
177
+ * @param {function} callback
178
+ */
179
+ onAdminAccountUsed(callback) {
180
+ this._on('AdminAccountUsed', () => {
181
+ callback();
182
+ });
183
+ },
184
+
185
+ /**
186
+ * Register a listener for cache warnings (stale, version mismatch).
187
+ * Replaces any previously registered listener.
188
+ * @param {function} callback - receives { reason, ageDays?, currentVersion?, storedVersion? }
189
+ */
190
+ onCacheWarning(callback) {
191
+ this._on('cacheWarning', (event) => {
192
+ callback(event.data);
193
+ });
194
+ },
195
+
196
+ /**
197
+ * Register a listener for diagnostics download progress.
198
+ * Replaces any previously registered listener.
199
+ * @param {function} callback - receives { progress, status }
200
+ */
201
+ onDiagnosticsProgress(callback) {
202
+ this._on('diagnosticsProgress', (event) => {
203
+ callback(event.data);
204
+ });
205
+ },
206
+
207
+ /**
208
+ * Get system and environment information for issue reporting
209
+ * @returns {Promise<{pluginVersion: string, eufyClientVersion: string, homebridgeVersion: string, nodeVersion: string, os: string, devices: Array}>}
210
+ */
211
+ async getSystemInfo() {
212
+ return homebridge.request('/systemInfo');
213
+ },
214
+ };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Config service — wraps homebridge plugin config CRUD operations.
3
+ * Manages reading/writing plugin configuration via the Homebridge API.
4
+ */
5
+ // eslint-disable-next-line no-unused-vars
6
+ const Config = {
7
+ _cache: null,
8
+
9
+ /**
10
+ * Get the current plugin config (first block).
11
+ * Uses cache if available; call load() to force a fresh read.
12
+ * @returns {Promise<object>}
13
+ */
14
+ async get() {
15
+ if (this._cache) return this._cache;
16
+ return this.load();
17
+ },
18
+
19
+ /**
20
+ * Load config from Homebridge (always fetches fresh, no side-effects).
21
+ * @returns {Promise<object>}
22
+ */
23
+ async load() {
24
+ const configs = await homebridge.getPluginConfig();
25
+ this._cache = configs.length > 0 ? configs[0] : {};
26
+ return this._cache;
27
+ },
28
+
29
+ /**
30
+ * Update config in memory (does NOT save to disk).
31
+ * @param {object} config - full config object
32
+ */
33
+ async update(config) {
34
+ this._cache = config;
35
+ await homebridge.updatePluginConfig([config]);
36
+ },
37
+
38
+ /**
39
+ * Save current config to disk (config.json).
40
+ * Call sparingly — only after login or explicit user save.
41
+ */
42
+ async save() {
43
+ await homebridge.savePluginConfig();
44
+ },
45
+
46
+ /**
47
+ * Update config and immediately save to disk.
48
+ * @param {object} config
49
+ */
50
+ async updateAndSave(config) {
51
+ await this.update(config);
52
+ await this.save();
53
+ },
54
+
55
+ /**
56
+ * Get device config for a specific device by serial number.
57
+ * @param {string} serialNumber
58
+ * @returns {object|undefined}
59
+ */
60
+ getDeviceConfig(serialNumber) {
61
+ if (!this._cache) return undefined;
62
+ const cameras = this._cache.cameras || [];
63
+ return cameras.find((c) => c.serialNumber === serialNumber);
64
+ },
65
+
66
+ /**
67
+ * Get station config for a specific station by serial number.
68
+ * @param {string} serialNumber
69
+ * @returns {object|undefined}
70
+ */
71
+ getStationConfig(serialNumber) {
72
+ if (!this._cache) return undefined;
73
+ const stations = this._cache.stations || [];
74
+ return stations.find((s) => s.serialNumber === serialNumber);
75
+ },
76
+
77
+ /**
78
+ * Update or create camera config for a device.
79
+ * @param {string} serialNumber
80
+ * @param {object} options - config properties to merge
81
+ */
82
+ async updateDeviceConfig(serialNumber, options) {
83
+ const config = this._cache || await this.get();
84
+ if (!config.cameras) config.cameras = [];
85
+
86
+ const idx = config.cameras.findIndex((c) => c.serialNumber === serialNumber);
87
+ if (idx !== -1) {
88
+ Object.assign(config.cameras[idx], options);
89
+ } else {
90
+ config.cameras.push({ serialNumber, ...options });
91
+ }
92
+
93
+ await this.update(config);
94
+ },
95
+
96
+ /**
97
+ * Update or create station config.
98
+ * @param {string} serialNumber
99
+ * @param {object} options - config properties to merge
100
+ */
101
+ async updateStationConfig(serialNumber, options) {
102
+ const config = this._cache || await this.get();
103
+ if (!config.stations) config.stations = [];
104
+
105
+ const idx = config.stations.findIndex((s) => s.serialNumber === serialNumber);
106
+ if (idx !== -1) {
107
+ Object.assign(config.stations[idx], options);
108
+ } else {
109
+ config.stations.push({ serialNumber, ...options });
110
+ }
111
+
112
+ await this.update(config);
113
+ },
114
+
115
+ /**
116
+ * Update global config options (top-level properties).
117
+ * @param {object} options - properties to merge into top-level config
118
+ */
119
+ async updateGlobal(options) {
120
+ const config = this._cache || await this.get();
121
+ Object.assign(config, options);
122
+ await this.update(config);
123
+ },
124
+
125
+ /**
126
+ * Toggle a device in the ignore list.
127
+ * @param {string} serialNumber
128
+ * @param {boolean} ignored - true to ignore, false to un-ignore
129
+ * @param {'device'|'station'} type
130
+ */
131
+ async toggleIgnore(serialNumber, ignored, type) {
132
+ const config = this._cache || await this.get();
133
+ const key = type === 'device' ? 'ignoreDevices' : 'ignoreStations';
134
+ if (!config[key]) config[key] = [];
135
+
136
+ if (ignored && !config[key].includes(serialNumber)) {
137
+ config[key].push(serialNumber);
138
+ } else if (!ignored) {
139
+ config[key] = config[key].filter((id) => id !== serialNumber);
140
+ }
141
+
142
+ await this.update(config);
143
+ },
144
+ };