homebridge-eufy-security 4.4.2-beta.2 → 4.4.2-beta.20

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 (147) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/FUNDING.yml +1 -0
  3. package/README.md +14 -17
  4. package/dist/accessories/AutoSyncStationAccessory.js +26 -30
  5. package/dist/accessories/AutoSyncStationAccessory.js.map +1 -1
  6. package/dist/accessories/BaseAccessory.js +33 -46
  7. package/dist/accessories/BaseAccessory.js.map +1 -1
  8. package/dist/accessories/CameraAccessory.js +92 -167
  9. package/dist/accessories/CameraAccessory.js.map +1 -1
  10. package/dist/accessories/Device.js +11 -15
  11. package/dist/accessories/Device.js.map +1 -1
  12. package/dist/accessories/EntrySensorAccessory.js +7 -12
  13. package/dist/accessories/EntrySensorAccessory.js.map +1 -1
  14. package/dist/accessories/LockAccessory.js +20 -24
  15. package/dist/accessories/LockAccessory.js.map +1 -1
  16. package/dist/accessories/MotionSensorAccessory.js +7 -12
  17. package/dist/accessories/MotionSensorAccessory.js.map +1 -1
  18. package/dist/accessories/StationAccessory.js +40 -45
  19. package/dist/accessories/StationAccessory.js.map +1 -1
  20. package/dist/config.js +1 -4
  21. package/dist/config.js.map +1 -1
  22. package/dist/controller/LocalLivestreamManager.js +85 -89
  23. package/dist/controller/LocalLivestreamManager.js.map +1 -1
  24. package/dist/controller/recordingDelegate.js +116 -108
  25. package/dist/controller/recordingDelegate.js.map +1 -1
  26. package/dist/controller/snapshotDelegate.js +379 -0
  27. package/dist/controller/snapshotDelegate.js.map +1 -0
  28. package/dist/controller/streamingDelegate.js +202 -207
  29. package/dist/controller/streamingDelegate.js.map +1 -1
  30. package/dist/index.js +9 -7
  31. package/dist/index.js.map +1 -1
  32. package/dist/interfaces.js +1 -2
  33. package/dist/interfaces.js.map +1 -1
  34. package/dist/platform.js +145 -165
  35. package/dist/platform.js.map +1 -1
  36. package/dist/settings.js +26 -19
  37. package/dist/settings.js.map +1 -1
  38. package/dist/utils/Talkback.js +9 -13
  39. package/dist/utils/Talkback.js.map +1 -1
  40. package/dist/utils/configTypes.js +12 -7
  41. package/dist/utils/configTypes.js.map +1 -1
  42. package/dist/utils/ffmpeg.js +44 -52
  43. package/dist/utils/ffmpeg.js.map +1 -1
  44. package/dist/utils/interfaces.js +1 -5
  45. package/dist/utils/interfaces.js.map +1 -1
  46. package/dist/utils/utils.js +32 -134
  47. package/dist/utils/utils.js.map +1 -1
  48. package/dist/version.js +1 -4
  49. package/dist/version.js.map +1 -1
  50. package/eslint.config.mjs +25 -47
  51. package/homebridge-eufy-security.png +0 -0
  52. package/homebridge-ui/public/app.js +221 -0
  53. package/homebridge-ui/public/assets/devices/eufycam3pro_large.png +0 -0
  54. package/homebridge-ui/public/assets/devices/homebase3_large.png +0 -0
  55. package/homebridge-ui/public/assets/devices/homebasemini_large.jpg +0 -0
  56. package/homebridge-ui/public/assets/devices/indoorcamC210_large.png +0 -0
  57. package/homebridge-ui/public/assets/devices/indoorcamC220_large.png +0 -0
  58. package/homebridge-ui/public/assets/devices/indoorcamE30_large.png +0 -0
  59. package/homebridge-ui/public/assets/devices/solocamc35_large.png +0 -0
  60. package/homebridge-ui/public/assets/devices/solocame30_large.png +0 -0
  61. package/homebridge-ui/public/components/device-card.js +141 -0
  62. package/homebridge-ui/public/components/guard-modes.js +88 -0
  63. package/homebridge-ui/public/components/number-input.js +121 -0
  64. package/homebridge-ui/public/components/select.js +73 -0
  65. package/homebridge-ui/public/components/toggle.js +68 -0
  66. package/homebridge-ui/public/index.html +24 -14
  67. package/homebridge-ui/public/services/api.js +129 -0
  68. package/homebridge-ui/public/services/config.js +144 -0
  69. package/homebridge-ui/public/style.css +705 -0
  70. package/homebridge-ui/public/utils/countries.js +73 -0
  71. package/homebridge-ui/public/utils/device-images.js +69 -0
  72. package/homebridge-ui/public/utils/helpers.js +47 -0
  73. package/homebridge-ui/public/views/dashboard.js +211 -0
  74. package/homebridge-ui/public/views/device-detail.js +631 -0
  75. package/homebridge-ui/public/views/login.js +625 -0
  76. package/homebridge-ui/public/views/settings.js +389 -0
  77. package/homebridge-ui/public/views/unsupported-detail.js +216 -0
  78. package/homebridge-ui/server.js +567 -450
  79. package/package.json +19 -59
  80. package/dist/accessories/AutoSyncStationAccessory.d.ts +0 -42
  81. package/dist/accessories/AutoSyncStationAccessory.d.ts.map +0 -1
  82. package/dist/accessories/BaseAccessory.d.ts +0 -56
  83. package/dist/accessories/BaseAccessory.d.ts.map +0 -1
  84. package/dist/accessories/CameraAccessory.d.ts +0 -84
  85. package/dist/accessories/CameraAccessory.d.ts.map +0 -1
  86. package/dist/accessories/Device.d.ts +0 -19
  87. package/dist/accessories/Device.d.ts.map +0 -1
  88. package/dist/accessories/EntrySensorAccessory.d.ts +0 -25
  89. package/dist/accessories/EntrySensorAccessory.d.ts.map +0 -1
  90. package/dist/accessories/LockAccessory.d.ts +0 -45
  91. package/dist/accessories/LockAccessory.d.ts.map +0 -1
  92. package/dist/accessories/MotionSensorAccessory.d.ts +0 -25
  93. package/dist/accessories/MotionSensorAccessory.d.ts.map +0 -1
  94. package/dist/accessories/StationAccessory.d.ts +0 -86
  95. package/dist/accessories/StationAccessory.d.ts.map +0 -1
  96. package/dist/config.d.ts +0 -31
  97. package/dist/config.d.ts.map +0 -1
  98. package/dist/controller/LocalLivestreamManager.d.ts +0 -31
  99. package/dist/controller/LocalLivestreamManager.d.ts.map +0 -1
  100. package/dist/controller/SnapshotManager.d.ts +0 -64
  101. package/dist/controller/SnapshotManager.d.ts.map +0 -1
  102. package/dist/controller/SnapshotManager.js +0 -434
  103. package/dist/controller/SnapshotManager.js.map +0 -1
  104. package/dist/controller/recordingDelegate.d.ts +0 -27
  105. package/dist/controller/recordingDelegate.d.ts.map +0 -1
  106. package/dist/controller/streamingDelegate.d.ts +0 -52
  107. package/dist/controller/streamingDelegate.d.ts.map +0 -1
  108. package/dist/index.d.ts +0 -7
  109. package/dist/index.d.ts.map +0 -1
  110. package/dist/interfaces.d.ts +0 -15
  111. package/dist/interfaces.d.ts.map +0 -1
  112. package/dist/platform.d.ts +0 -94
  113. package/dist/platform.d.ts.map +0 -1
  114. package/dist/settings.d.ts +0 -23
  115. package/dist/settings.d.ts.map +0 -1
  116. package/dist/utils/Talkback.d.ts +0 -21
  117. package/dist/utils/Talkback.d.ts.map +0 -1
  118. package/dist/utils/configTypes.d.ts +0 -68
  119. package/dist/utils/configTypes.d.ts.map +0 -1
  120. package/dist/utils/ffmpeg.d.ts +0 -111
  121. package/dist/utils/ffmpeg.d.ts.map +0 -1
  122. package/dist/utils/interfaces.d.ts +0 -8
  123. package/dist/utils/interfaces.d.ts.map +0 -1
  124. package/dist/utils/utils.d.ts +0 -35
  125. package/dist/utils/utils.d.ts.map +0 -1
  126. package/dist/version.d.ts +0 -2
  127. package/dist/version.d.ts.map +0 -1
  128. package/homebridge-ui/configui/app/util/types.d.ts +0 -65
  129. package/homebridge-ui/configui/app/util/types.d.ts.map +0 -1
  130. package/homebridge-ui/configui/app/util/types.js +0 -18
  131. package/homebridge-ui/configui/app/util/types.js.map +0 -1
  132. package/homebridge-ui/public/3rdpartylicenses.txt +0 -561
  133. package/homebridge-ui/public/assets/devices/homebase2_large.jpg +0 -0
  134. package/homebridge-ui/public/assets/devices/homebase3_large.jpg +0 -0
  135. package/homebridge-ui/public/assets/images/homebridge-eufy-security.png +0 -0
  136. package/homebridge-ui/public/favicon.ico +0 -0
  137. package/homebridge-ui/public/main.f6423287e0411c87.js +0 -1
  138. package/homebridge-ui/public/polyfills.efa2f09ba3e49167.js +0 -1
  139. package/homebridge-ui/public/runtime.ccde331d62c423b3.js +0 -1
  140. package/homebridge-ui/public/styles.13e635bf7a488639.css +0 -5
  141. package/homebridge-ui/server.d.ts +0 -2
  142. package/homebridge-ui/server.d.ts.map +0 -1
  143. package/homebridge-ui/server.js.map +0 -1
  144. package/homebridge-ui/version.d.ts +0 -2
  145. package/homebridge-ui/version.d.ts.map +0 -1
  146. package/homebridge-ui/version.js +0 -5
  147. package/homebridge-ui/version.js.map +0 -1
@@ -0,0 +1,221 @@
1
+ /**
2
+ * App — main controller, hash-based SPA router, and global state.
3
+ * Entry point initialized on homebridge 'ready' event.
4
+ */
5
+ // eslint-disable-next-line no-unused-vars
6
+ const App = {
7
+ /** Global state shared across views */
8
+ state: {
9
+ stations: [],
10
+ cacheDate: null, // ISO string of when accessories were last fetched
11
+ initialized: false,
12
+ nodeVersionWarning: null, // { nodeVersion, affected, acknowledged }
13
+ },
14
+
15
+ /** Root container element */
16
+ _root: null,
17
+
18
+ /**
19
+ * Initialize the app. Called once on homebridge 'ready' event.
20
+ */
21
+ async init() {
22
+ this._root = document.getElementById('app');
23
+
24
+ // Let Homebridge's native Save button handle saving to disk.
25
+ // We call updatePluginConfig() on every change to keep config in sync.
26
+
27
+ // Listen for admin account error globally
28
+ Api.onAdminAccountUsed(() => {
29
+ this._showAdminError();
30
+ });
31
+
32
+ // Listen for cache warnings (stale data, version mismatch)
33
+ Api.onCacheWarning((data) => {
34
+ switch (data.reason) {
35
+ case 'stale':
36
+ homebridge.toast.warning(
37
+ `Your cached devices are ${data.ageDays} days old. Please re-login to refresh.`,
38
+ 'Stale Cache',
39
+ );
40
+ break;
41
+ case 'versionWarn':
42
+ homebridge.toast.warning(
43
+ `Plugin updated (${data.storedVersion} → ${data.currentVersion}). Consider re-logging in to refresh devices.`,
44
+ 'Version Updated',
45
+ );
46
+ break;
47
+ case 'versionForce':
48
+ homebridge.toast.error(
49
+ `Plugin version changed significantly (${data.storedVersion} → ${data.currentVersion}). Please re-login.`,
50
+ 'Re-login Required',
51
+ );
52
+ this.navigate('login');
53
+ break;
54
+ }
55
+ });
56
+
57
+ // Listen for hash changes
58
+ window.addEventListener('hashchange', () => this._onRoute());
59
+
60
+ // Determine initial route
61
+ try {
62
+ // Load config without pushing it back to Homebridge
63
+ const config = await Config.load();
64
+ if (!config.platform) {
65
+ // Initialize default config only if truly empty
66
+ await Config.update({ platform: 'EufySecurity', name: 'EufySecurity' });
67
+ }
68
+
69
+ const stored = await Api.loadStoredAccessories();
70
+ const stations = stored.stations || stored;
71
+ const cacheDate = stored.storedAt || null;
72
+ if (stations && stations.length > 0) {
73
+ this.state.stations = stations;
74
+ this.state.cacheDate = cacheDate;
75
+ // Check Node.js version for returning users (skipping login)
76
+ await this.checkNodeVersion();
77
+ if (!window.location.hash || window.location.hash === '#/') {
78
+ this.navigate('dashboard');
79
+ } else {
80
+ this._onRoute();
81
+ }
82
+ } else {
83
+ this.navigate('login');
84
+ }
85
+ } catch (e) {
86
+ // No stored accessories — go to login
87
+ this.navigate('login');
88
+ }
89
+
90
+ this.state.initialized = true;
91
+ },
92
+
93
+ /**
94
+ * Navigate to a route.
95
+ * @param {string} route - e.g. 'login', 'dashboard', 'detail/device/SERIAL', 'settings'
96
+ */
97
+ navigate(route) {
98
+ window.location.hash = '#/' + route;
99
+ },
100
+
101
+ /**
102
+ * Handle route changes.
103
+ */
104
+ _onRoute() {
105
+ const hash = window.location.hash.replace('#/', '') || '';
106
+ const parts = hash.split('/');
107
+ const route = parts[0];
108
+
109
+ // Clear the root container
110
+ this._root.innerHTML = '';
111
+
112
+ switch (route) {
113
+ case 'login':
114
+ LoginView.render(this._root);
115
+ break;
116
+
117
+ case 'dashboard':
118
+ DashboardView.render(this._root);
119
+ break;
120
+
121
+ case 'detail': {
122
+ const type = parts[1]; // 'device' or 'station'
123
+ const id = parts[2]; // uniqueId
124
+ if (type && id) {
125
+ DeviceDetailView.render(this._root, type, id);
126
+ } else {
127
+ this.navigate('dashboard');
128
+ }
129
+ break;
130
+ }
131
+
132
+ case 'unsupported': {
133
+ const uid = parts[1];
134
+ if (uid) {
135
+ UnsupportedDetailView.render(this._root, uid);
136
+ } else {
137
+ this.navigate('dashboard');
138
+ }
139
+ break;
140
+ }
141
+
142
+ case 'settings':
143
+ SettingsView.render(this._root);
144
+ break;
145
+
146
+ default:
147
+ // Unknown route — try dashboard if we have stations, otherwise login
148
+ if (this.state.stations.length > 0) {
149
+ this.navigate('dashboard');
150
+ } else {
151
+ this.navigate('login');
152
+ }
153
+ break;
154
+ }
155
+ },
156
+
157
+ /**
158
+ * Show a blocking error when admin account is detected.
159
+ */
160
+ _showAdminError() {
161
+ this._root.innerHTML = `
162
+ <div class="alert alert-danger alert-admin mt-4" role="alert">
163
+ <h5 class="alert-heading">⚠️ Admin Account Detected</h5>
164
+ <p>
165
+ You are not using a <strong>dedicated guest admin account</strong>.
166
+ Using your primary Eufy account can cause conflicts with the Eufy app.
167
+ </p>
168
+ <hr>
169
+ <p class="mb-0">
170
+ Please create a dedicated admin account for this plugin.
171
+ <a href="https://github.com/homebridge-plugins/homebridge-eufy-security/wiki/Create-a-dedicated-admin-account-for-Homebridge-Eufy-Security-Plugin"
172
+ target="_blank" class="alert-link">
173
+ Follow this guide
174
+ </a>
175
+ </p>
176
+ </div>
177
+ `;
178
+ },
179
+
180
+ /**
181
+ * Check if a Node.js version string is affected by the RSA_PKCS1_PADDING removal.
182
+ * Affected: 18.19.1+, 20.11.1+, 21.6.2+, 22.x, 23.x, and 24.x < 24.5.0.
183
+ * Safe: <= 18.19.0, <= 20.11.0, <= 21.6.1, and >= 24.5.0.
184
+ * @param {string} versionStr - e.g. 'v20.18.1'
185
+ * @returns {boolean}
186
+ */
187
+ isNodeVersionAffected(versionStr) {
188
+ const parts = versionStr.replace('v', '').split('.').map(Number);
189
+ const [major, minor, patch] = parts;
190
+
191
+ if (major === 18 && (minor > 19 || (minor === 19 && patch >= 1))) return true;
192
+ if (major === 20 && (minor > 11 || (minor === 11 && patch >= 1))) return true;
193
+ if (major === 21 && (minor > 6 || (minor === 6 && patch >= 2))) return true;
194
+ if (major === 22 || major === 23) return true;
195
+ if (major === 24 && (minor < 5)) return true;
196
+
197
+ return false;
198
+ },
199
+
200
+ /**
201
+ * Fetch the Node.js version from the server and update state.
202
+ */
203
+ async checkNodeVersion() {
204
+ try {
205
+ const info = await Api.getSystemInfo();
206
+ const affected = this.isNodeVersionAffected(info.nodeVersion);
207
+ this.state.nodeVersionWarning = {
208
+ nodeVersion: info.nodeVersion,
209
+ affected,
210
+ acknowledged: false,
211
+ };
212
+ } catch (e) {
213
+ // Silently ignore — non-critical check
214
+ }
215
+ },
216
+ };
217
+
218
+ // ===== Bootstrap the app when Homebridge UI is ready =====
219
+ homebridge.addEventListener('ready', () => {
220
+ App.init();
221
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * DeviceCard component — renders a single device/station card for the dashboard grid.
3
+ *
4
+ * Usage:
5
+ * DeviceCard.render(container, {
6
+ * device: { uniqueId, displayName, type, typename, ... },
7
+ * isStation: false,
8
+ * enabled: true,
9
+ * onClick: (device) => { ... },
10
+ * onToggle: (device, enabled) => { ... }
11
+ * });
12
+ */
13
+ // eslint-disable-next-line no-unused-vars
14
+ const DeviceCard = {
15
+
16
+ CHARGING_LABELS: {
17
+ 1: 'Charging',
18
+ 2: 'Unplugged',
19
+ 3: 'Plugged In',
20
+ 4: 'Solar',
21
+ },
22
+
23
+ /**
24
+ * @param {HTMLElement} container
25
+ * @param {object} opts
26
+ * @param {object} opts.device - L_Device or L_Station data
27
+ * @param {boolean} [opts.isStation]
28
+ * @param {boolean} opts.enabled - whether device is enabled (not ignored)
29
+ * @param {function} opts.onClick - callback(device)
30
+ * @param {function} opts.onToggle - callback(device, enabled)
31
+ * @returns {HTMLElement}
32
+ */
33
+ render(container, opts) {
34
+ const d = opts.device;
35
+ const isUnsupported = d.unsupported === true;
36
+ const isIgnored = d.ignored === true;
37
+
38
+ const col = document.createElement('div');
39
+ col.className = 'col-6 col-md-4 col-lg-3 mb-3';
40
+
41
+ const card = document.createElement('div');
42
+ card.className = 'device-card';
43
+ if (isUnsupported) card.classList.add('device-card--unsupported');
44
+ if (isIgnored) card.classList.add('device-card--ignored');
45
+
46
+ // Image (skip for unsupported)
47
+ if (!isUnsupported) {
48
+ const imgWrap = document.createElement('div');
49
+ imgWrap.className = 'device-card__image-wrap';
50
+ const img = document.createElement('img');
51
+ img.src = DeviceImages.getPath(d.type);
52
+ img.alt = d.displayName;
53
+ img.loading = 'lazy';
54
+ imgWrap.appendChild(img);
55
+ card.appendChild(imgWrap);
56
+ }
57
+
58
+ // Body
59
+ const body = document.createElement('div');
60
+ body.className = 'device-card__body';
61
+
62
+ const name = document.createElement('div');
63
+ name.className = 'device-card__name';
64
+ name.textContent = d.displayName;
65
+ name.title = d.displayName;
66
+
67
+ const meta = document.createElement('div');
68
+ meta.className = 'device-card__meta';
69
+
70
+ const metaParts = [];
71
+ if (isUnsupported) {
72
+ metaParts.push('Type ' + d.type);
73
+ } else if (d.typename) {
74
+ metaParts.push(d.typename);
75
+ }
76
+ if (d.hasBattery && d.properties && d.properties.battery !== undefined) {
77
+ metaParts.push('🔋 ' + d.properties.battery + '%');
78
+ }
79
+ if (d.chargingStatus && this.CHARGING_LABELS[d.chargingStatus]) {
80
+ metaParts.push('⚡ ' + this.CHARGING_LABELS[d.chargingStatus]);
81
+ }
82
+ meta.textContent = metaParts.join(' · ');
83
+
84
+ body.appendChild(name);
85
+ body.appendChild(meta);
86
+
87
+ // Footer with badges + toggle
88
+ const footer = document.createElement('div');
89
+ footer.className = 'device-card__footer';
90
+
91
+ const badgeArea = document.createElement('div');
92
+
93
+ if (isUnsupported) {
94
+ const badge = document.createElement('span');
95
+ badge.className = 'badge badge-unsupported';
96
+ badge.textContent = 'Not Supported';
97
+ badgeArea.appendChild(badge);
98
+ } else if (isIgnored) {
99
+ const badge = document.createElement('span');
100
+ badge.className = 'badge bg-secondary';
101
+ badge.textContent = 'Disabled';
102
+ badgeArea.appendChild(badge);
103
+ }
104
+
105
+ footer.appendChild(badgeArea);
106
+
107
+ // Toggle — only for non-unsupported devices
108
+ if (!isUnsupported) {
109
+ const switchWrap = document.createElement('div');
110
+ switchWrap.className = 'form-check form-switch mb-0';
111
+ switchWrap.addEventListener('click', (e) => e.stopPropagation());
112
+
113
+ const toggle = document.createElement('input');
114
+ toggle.type = 'checkbox';
115
+ toggle.className = 'form-check-input';
116
+ toggle.checked = opts.enabled;
117
+ toggle.role = 'switch';
118
+ toggle.title = opts.enabled ? 'Enabled in HomeKit' : 'Disabled in HomeKit';
119
+
120
+ toggle.addEventListener('change', (e) => {
121
+ e.stopPropagation();
122
+ if (opts.onToggle) opts.onToggle(d, toggle.checked);
123
+ });
124
+
125
+ switchWrap.appendChild(toggle);
126
+ footer.appendChild(switchWrap);
127
+ }
128
+
129
+ card.appendChild(body);
130
+ card.appendChild(footer);
131
+
132
+ // Click handler — navigate to detail
133
+ card.addEventListener('click', () => {
134
+ if (opts.onClick) opts.onClick(d);
135
+ });
136
+
137
+ col.appendChild(card);
138
+ if (container) container.appendChild(col);
139
+ return col;
140
+ },
141
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Guard Modes mapping component.
3
+ * Maps HomeKit modes (Home, Away, Night, Off) to Eufy guard mode numbers.
4
+ *
5
+ * Usage:
6
+ * GuardModes.render(container, {
7
+ * hkHome: 1, hkAway: 0, hkNight: 1, hkOff: 63,
8
+ * onChange: (modes) => { ... } // { hkHome, hkAway, hkNight, hkOff }
9
+ * });
10
+ */
11
+ // eslint-disable-next-line no-unused-vars
12
+ const GuardModes = {
13
+ EUFY_MODES: [
14
+ { value: '0', label: 'Away' },
15
+ { value: '1', label: 'Home' },
16
+ { value: '2', label: 'Schedule' },
17
+ { value: '3', label: 'Custom 1' },
18
+ { value: '4', label: 'Custom 2' },
19
+ { value: '5', label: 'Custom 3' },
20
+ { value: '6', label: 'Off' },
21
+ { value: '47', label: 'Geofencing' },
22
+ { value: '63', label: 'Disarmed' },
23
+ ],
24
+
25
+ /**
26
+ * @param {HTMLElement} container
27
+ * @param {object} opts
28
+ * @param {number} [opts.hkHome]
29
+ * @param {number} [opts.hkAway]
30
+ * @param {number} [opts.hkNight]
31
+ * @param {number} [opts.hkOff]
32
+ * @param {boolean} [opts.disabled]
33
+ * @param {function} opts.onChange - callback({ hkHome, hkAway, hkNight, hkOff })
34
+ * @returns {HTMLElement}
35
+ */
36
+ render(container, opts) {
37
+ const wrap = document.createElement('div');
38
+ wrap.className = 'guard-mode-grid';
39
+
40
+ const modes = {
41
+ hkHome: { label: 'HomeKit Home', value: opts.hkHome ?? 1 },
42
+ hkAway: { label: 'HomeKit Away', value: opts.hkAway ?? 0 },
43
+ hkNight: { label: 'HomeKit Night', value: opts.hkNight ?? 1 },
44
+ hkOff: { label: 'HomeKit Off', value: opts.hkOff ?? 63 },
45
+ };
46
+
47
+ const currentValues = {
48
+ hkHome: modes.hkHome.value,
49
+ hkAway: modes.hkAway.value,
50
+ hkNight: modes.hkNight.value,
51
+ hkOff: modes.hkOff.value,
52
+ };
53
+
54
+ Object.entries(modes).forEach(([key, mode]) => {
55
+ const group = document.createElement('div');
56
+ group.className = 'form-group';
57
+
58
+ const label = document.createElement('label');
59
+ label.textContent = mode.label;
60
+ label.setAttribute('for', 'guard-' + key);
61
+
62
+ const select = document.createElement('select');
63
+ select.className = 'form-select form-select-sm';
64
+ select.id = 'guard-' + key;
65
+ select.disabled = !!opts.disabled;
66
+
67
+ this.EUFY_MODES.forEach((em) => {
68
+ const option = document.createElement('option');
69
+ option.value = em.value;
70
+ option.textContent = em.label;
71
+ if (String(em.value) === String(mode.value)) option.selected = true;
72
+ select.appendChild(option);
73
+ });
74
+
75
+ select.addEventListener('change', () => {
76
+ currentValues[key] = parseInt(select.value);
77
+ if (opts.onChange) opts.onChange({ ...currentValues });
78
+ });
79
+
80
+ group.appendChild(label);
81
+ group.appendChild(select);
82
+ wrap.appendChild(group);
83
+ });
84
+
85
+ if (container) container.appendChild(wrap);
86
+ return wrap;
87
+ },
88
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * NumberInput component — renders a number input with +/- buttons, label, and optional help tooltip.
3
+ *
4
+ * Usage:
5
+ * NumberInput.render(container, {
6
+ * id: 'polling-interval',
7
+ * label: 'Polling Interval',
8
+ * help: 'Minutes between cloud polls',
9
+ * value: 10,
10
+ * min: 1,
11
+ * max: 120,
12
+ * step: 1,
13
+ * suffix: 'min',
14
+ * onChange: (value) => { ... }
15
+ * });
16
+ */
17
+ // eslint-disable-next-line no-unused-vars
18
+ const NumberInput = {
19
+ /**
20
+ * @param {HTMLElement} container
21
+ * @param {object} opts
22
+ * @param {string} opts.id
23
+ * @param {string} opts.label
24
+ * @param {string} [opts.help]
25
+ * @param {number} opts.value
26
+ * @param {number} [opts.min]
27
+ * @param {number} [opts.max]
28
+ * @param {number} [opts.step]
29
+ * @param {string} [opts.suffix] - text after the input (e.g., 'min', 'sec')
30
+ * @param {boolean} [opts.disabled]
31
+ * @param {function} opts.onChange - callback(value: number)
32
+ * @returns {HTMLElement}
33
+ */
34
+ render(container, opts) {
35
+ const row = document.createElement('div');
36
+ row.className = 'eufy-toggle';
37
+
38
+ const labelWrap = document.createElement('div');
39
+ labelWrap.className = 'eufy-toggle__label';
40
+
41
+ const labelEl = document.createElement('label');
42
+ labelEl.setAttribute('for', opts.id);
43
+ labelEl.textContent = opts.label;
44
+ labelWrap.appendChild(labelEl);
45
+
46
+ if (opts.help) {
47
+ const helpEl = document.createElement('span');
48
+ helpEl.className = 'eufy-toggle__help';
49
+ helpEl.textContent = '?';
50
+ helpEl.title = opts.help;
51
+ labelWrap.appendChild(helpEl);
52
+ }
53
+
54
+ const inputGroup = document.createElement('div');
55
+ inputGroup.className = 'number-input-group';
56
+
57
+ const step = opts.step || 1;
58
+ const min = opts.min !== undefined ? opts.min : 0;
59
+ const max = opts.max !== undefined ? opts.max : 99999;
60
+
61
+ const btnMinus = document.createElement('button');
62
+ btnMinus.type = 'button';
63
+ btnMinus.className = 'btn btn-outline-secondary btn-sm';
64
+ btnMinus.textContent = '−';
65
+
66
+ const input = document.createElement('input');
67
+ input.type = 'number';
68
+ input.className = 'form-control form-control-sm';
69
+ input.id = opts.id;
70
+ input.value = opts.value;
71
+ input.min = min;
72
+ input.max = max;
73
+ input.step = step;
74
+ input.disabled = !!opts.disabled;
75
+
76
+ const btnPlus = document.createElement('button');
77
+ btnPlus.type = 'button';
78
+ btnPlus.className = 'btn btn-outline-secondary btn-sm';
79
+ btnPlus.textContent = '+';
80
+
81
+ const fireChange = () => {
82
+ let val = parseFloat(input.value);
83
+ if (isNaN(val)) val = min;
84
+ val = Math.max(min, Math.min(max, val));
85
+ input.value = val;
86
+ if (opts.onChange) opts.onChange(val);
87
+ };
88
+
89
+ btnMinus.addEventListener('click', () => {
90
+ let val = parseFloat(input.value) - step;
91
+ input.value = Math.max(min, val);
92
+ fireChange();
93
+ });
94
+
95
+ btnPlus.addEventListener('click', () => {
96
+ let val = parseFloat(input.value) + step;
97
+ input.value = Math.min(max, val);
98
+ fireChange();
99
+ });
100
+
101
+ input.addEventListener('change', fireChange);
102
+
103
+ inputGroup.appendChild(btnMinus);
104
+ inputGroup.appendChild(input);
105
+ inputGroup.appendChild(btnPlus);
106
+
107
+ if (opts.suffix) {
108
+ const suffixEl = document.createElement('span');
109
+ suffixEl.className = 'text-muted ms-1';
110
+ suffixEl.style.fontSize = '0.8rem';
111
+ suffixEl.textContent = opts.suffix;
112
+ inputGroup.appendChild(suffixEl);
113
+ }
114
+
115
+ row.appendChild(labelWrap);
116
+ row.appendChild(inputGroup);
117
+
118
+ if (container) container.appendChild(row);
119
+ return row;
120
+ },
121
+ };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Select component — renders a dropdown with label and optional help tooltip.
3
+ *
4
+ * Usage:
5
+ * Select.render(container, {
6
+ * id: 'snapshot-method',
7
+ * label: 'Snapshot Method',
8
+ * help: 'How snapshots are captured',
9
+ * options: [{ value: '0', label: 'Auto' }, { value: '1', label: 'From Stream' }],
10
+ * value: '0',
11
+ * onChange: (value) => { ... }
12
+ * });
13
+ */
14
+ // eslint-disable-next-line no-unused-vars
15
+ const Select = {
16
+ /**
17
+ * @param {HTMLElement} container
18
+ * @param {object} opts
19
+ * @param {string} opts.id
20
+ * @param {string} opts.label
21
+ * @param {string} [opts.help]
22
+ * @param {Array<{value: string, label: string}>} opts.options
23
+ * @param {string} opts.value - initial selected value
24
+ * @param {boolean} [opts.disabled]
25
+ * @param {function} opts.onChange - callback(value: string)
26
+ * @returns {HTMLElement}
27
+ */
28
+ render(container, opts) {
29
+ const row = document.createElement('div');
30
+ row.className = 'eufy-toggle';
31
+
32
+ const labelWrap = document.createElement('div');
33
+ labelWrap.className = 'eufy-toggle__label';
34
+
35
+ const labelEl = document.createElement('label');
36
+ labelEl.setAttribute('for', opts.id);
37
+ labelEl.textContent = opts.label;
38
+ labelWrap.appendChild(labelEl);
39
+
40
+ if (opts.help) {
41
+ const helpEl = document.createElement('span');
42
+ helpEl.className = 'eufy-toggle__help';
43
+ helpEl.textContent = '?';
44
+ helpEl.title = opts.help;
45
+ labelWrap.appendChild(helpEl);
46
+ }
47
+
48
+ const selectEl = document.createElement('select');
49
+ selectEl.className = 'form-select form-select-sm';
50
+ selectEl.id = opts.id;
51
+ selectEl.style.width = 'auto';
52
+ selectEl.style.maxWidth = '200px';
53
+ selectEl.disabled = !!opts.disabled;
54
+
55
+ (opts.options || []).forEach((opt) => {
56
+ const option = document.createElement('option');
57
+ option.value = opt.value;
58
+ option.textContent = opt.label;
59
+ if (String(opt.value) === String(opts.value)) option.selected = true;
60
+ selectEl.appendChild(option);
61
+ });
62
+
63
+ selectEl.addEventListener('change', () => {
64
+ if (opts.onChange) opts.onChange(selectEl.value);
65
+ });
66
+
67
+ row.appendChild(labelWrap);
68
+ row.appendChild(selectEl);
69
+
70
+ if (container) container.appendChild(row);
71
+ return row;
72
+ },
73
+ };