homebridge-multiple-switch 1.5.0 → 1.5.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 +8 -0
- package/homebridge-ui/public/index.html +177 -56
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.5.1] - 2026-03-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Collapsible device cards — click the device header to expand/collapse
|
|
7
|
+
- Collapsible switch cards — click the switch header to expand/collapse
|
|
8
|
+
- Summary info shown when collapsed (switch count for devices, type/delay for switches)
|
|
9
|
+
- Chevron indicator (▶) with rotation animation for open/closed state
|
|
10
|
+
|
|
3
11
|
## [1.5.0] - 2026-03-21
|
|
4
12
|
|
|
5
13
|
### Added
|
|
@@ -52,29 +52,58 @@
|
|
|
52
52
|
}
|
|
53
53
|
.device-card {
|
|
54
54
|
border: 2px solid var(--ui-device-border);
|
|
55
|
-
border-radius: 10px;
|
|
55
|
+
border-radius: 10px; margin-bottom: 20px;
|
|
56
56
|
background: var(--ui-device-bg);
|
|
57
|
+
overflow: hidden;
|
|
57
58
|
}
|
|
58
59
|
.device-header {
|
|
59
60
|
display: flex; justify-content: space-between; align-items: center;
|
|
60
|
-
|
|
61
|
-
border-bottom: 1px solid var(--ui-border);
|
|
61
|
+
padding: 14px 20px; cursor: pointer; user-select: none;
|
|
62
62
|
}
|
|
63
|
-
.device-header
|
|
63
|
+
.device-header:hover { opacity: 0.85; }
|
|
64
|
+
.device-header-left {
|
|
65
|
+
display: flex; align-items: center; gap: 10px;
|
|
66
|
+
}
|
|
67
|
+
.device-header-left strong {
|
|
64
68
|
font-size: 16px; color: var(--ui-accent);
|
|
65
69
|
}
|
|
70
|
+
.device-header-left .chevron {
|
|
71
|
+
font-size: 12px; color: var(--ui-text-muted);
|
|
72
|
+
transition: transform 0.2s;
|
|
73
|
+
display: inline-block;
|
|
74
|
+
}
|
|
75
|
+
.device-header-left .chevron.open { transform: rotate(90deg); }
|
|
76
|
+
.device-header-right { display: flex; align-items: center; gap: 8px; }
|
|
77
|
+
.device-body { padding: 0 20px 20px; }
|
|
78
|
+
.device-body.collapsed { display: none; }
|
|
66
79
|
.switch-card {
|
|
67
80
|
border: 1px solid var(--ui-border);
|
|
68
|
-
border-radius: 8px;
|
|
81
|
+
border-radius: 8px; margin-bottom: 12px;
|
|
69
82
|
background: var(--ui-card-bg);
|
|
70
|
-
position: relative;
|
|
83
|
+
position: relative; overflow: hidden;
|
|
84
|
+
}
|
|
85
|
+
.switch-header {
|
|
86
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
87
|
+
padding: 12px 16px; cursor: pointer; user-select: none;
|
|
71
88
|
}
|
|
72
|
-
.switch-
|
|
73
|
-
|
|
89
|
+
.switch-header:hover { opacity: 0.85; }
|
|
90
|
+
.switch-header-left {
|
|
91
|
+
display: flex; align-items: center; gap: 8px;
|
|
74
92
|
}
|
|
75
|
-
.switch-
|
|
76
|
-
font-size: 14px;
|
|
77
|
-
|
|
93
|
+
.switch-header-left strong {
|
|
94
|
+
font-size: 14px; color: var(--ui-text);
|
|
95
|
+
}
|
|
96
|
+
.switch-header-left .chevron {
|
|
97
|
+
font-size: 11px; color: var(--ui-text-muted);
|
|
98
|
+
transition: transform 0.2s;
|
|
99
|
+
display: inline-block;
|
|
100
|
+
}
|
|
101
|
+
.switch-header-left .chevron.open { transform: rotate(90deg); }
|
|
102
|
+
.switch-header-right { display: flex; align-items: center; gap: 8px; }
|
|
103
|
+
.switch-body { padding: 0 16px 16px; }
|
|
104
|
+
.switch-body.collapsed { display: none; }
|
|
105
|
+
.switch-summary {
|
|
106
|
+
font-size: 12px; color: var(--ui-text-muted); margin-left: 4px;
|
|
78
107
|
}
|
|
79
108
|
.btn {
|
|
80
109
|
padding: 8px 16px; border: none; border-radius: 6px;
|
|
@@ -82,6 +111,7 @@
|
|
|
82
111
|
}
|
|
83
112
|
.btn-danger { background: #dc3545; color: #fff; }
|
|
84
113
|
.btn-danger:hover { background: #c82333; }
|
|
114
|
+
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
|
85
115
|
.btn-primary { background: var(--ui-accent); color: #fff; }
|
|
86
116
|
.btn-primary:hover { opacity: 0.85; }
|
|
87
117
|
.btn-outline {
|
|
@@ -150,6 +180,15 @@
|
|
|
150
180
|
config.devices = [];
|
|
151
181
|
}
|
|
152
182
|
|
|
183
|
+
// Track expand/collapse state
|
|
184
|
+
const expandedDevices = new Set(config.devices.map((_, i) => i));
|
|
185
|
+
const expandedSwitches = new Set();
|
|
186
|
+
config.devices.forEach((dev, di) => {
|
|
187
|
+
(dev.switches || []).forEach((_, si) => {
|
|
188
|
+
expandedSwitches.add(`${di}_${si}`);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
153
192
|
const app = document.getElementById('app');
|
|
154
193
|
|
|
155
194
|
function render() {
|
|
@@ -178,15 +217,47 @@
|
|
|
178
217
|
});
|
|
179
218
|
|
|
180
219
|
document.getElementById('btn-add-device').addEventListener('click', () => {
|
|
220
|
+
const di = config.devices.length;
|
|
181
221
|
config.devices.push({
|
|
182
222
|
name: '',
|
|
183
223
|
switchBehavior: 'independent',
|
|
184
224
|
switches: [{ name: '', type: 'outlet', defaultState: false, delayOff: 0 }],
|
|
185
225
|
});
|
|
226
|
+
expandedDevices.add(di);
|
|
227
|
+
expandedSwitches.add(`${di}_0`);
|
|
186
228
|
render();
|
|
187
229
|
save();
|
|
188
230
|
});
|
|
189
231
|
|
|
232
|
+
// Device toggle
|
|
233
|
+
document.querySelectorAll('.device-toggle').forEach(el => {
|
|
234
|
+
el.addEventListener('click', (e) => {
|
|
235
|
+
// Don't toggle if clicking a button inside header
|
|
236
|
+
if (e.target.closest('.btn')) return;
|
|
237
|
+
const di = parseInt(el.dataset.dev);
|
|
238
|
+
if (expandedDevices.has(di)) {
|
|
239
|
+
expandedDevices.delete(di);
|
|
240
|
+
} else {
|
|
241
|
+
expandedDevices.add(di);
|
|
242
|
+
}
|
|
243
|
+
render();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Switch toggle
|
|
248
|
+
document.querySelectorAll('.switch-toggle').forEach(el => {
|
|
249
|
+
el.addEventListener('click', (e) => {
|
|
250
|
+
if (e.target.closest('.btn')) return;
|
|
251
|
+
const key = `${el.dataset.dev}_${el.dataset.sw}`;
|
|
252
|
+
if (expandedSwitches.has(key)) {
|
|
253
|
+
expandedSwitches.delete(key);
|
|
254
|
+
} else {
|
|
255
|
+
expandedSwitches.add(key);
|
|
256
|
+
}
|
|
257
|
+
render();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
190
261
|
// Device-level events
|
|
191
262
|
document.querySelectorAll('.dev-field').forEach(input => {
|
|
192
263
|
input.addEventListener('change', () => {
|
|
@@ -198,8 +269,12 @@
|
|
|
198
269
|
});
|
|
199
270
|
|
|
200
271
|
document.querySelectorAll('.btn-remove-device').forEach(btn => {
|
|
201
|
-
btn.addEventListener('click', () => {
|
|
202
|
-
|
|
272
|
+
btn.addEventListener('click', (e) => {
|
|
273
|
+
e.stopPropagation();
|
|
274
|
+
const di = parseInt(btn.dataset.dev);
|
|
275
|
+
config.devices.splice(di, 1);
|
|
276
|
+
// Rebuild expand state
|
|
277
|
+
rebuildExpandState();
|
|
203
278
|
render();
|
|
204
279
|
save();
|
|
205
280
|
});
|
|
@@ -209,17 +284,21 @@
|
|
|
209
284
|
document.querySelectorAll('.btn-add-switch').forEach(btn => {
|
|
210
285
|
btn.addEventListener('click', () => {
|
|
211
286
|
const di = parseInt(btn.dataset.dev);
|
|
287
|
+
const si = config.devices[di].switches.length;
|
|
212
288
|
config.devices[di].switches.push({ name: '', type: 'outlet', defaultState: false, delayOff: 0 });
|
|
289
|
+
expandedSwitches.add(`${di}_${si}`);
|
|
213
290
|
render();
|
|
214
291
|
save();
|
|
215
292
|
});
|
|
216
293
|
});
|
|
217
294
|
|
|
218
295
|
document.querySelectorAll('.btn-remove-switch').forEach(btn => {
|
|
219
|
-
btn.addEventListener('click', () => {
|
|
296
|
+
btn.addEventListener('click', (e) => {
|
|
297
|
+
e.stopPropagation();
|
|
220
298
|
const di = parseInt(btn.dataset.dev);
|
|
221
299
|
const si = parseInt(btn.dataset.sw);
|
|
222
300
|
config.devices[di].switches.splice(si, 1);
|
|
301
|
+
rebuildExpandState();
|
|
223
302
|
render();
|
|
224
303
|
save();
|
|
225
304
|
});
|
|
@@ -242,69 +321,111 @@
|
|
|
242
321
|
});
|
|
243
322
|
}
|
|
244
323
|
|
|
324
|
+
function rebuildExpandState() {
|
|
325
|
+
// Keep devices that still exist expanded
|
|
326
|
+
const newDevices = new Set();
|
|
327
|
+
config.devices.forEach((_, i) => {
|
|
328
|
+
if (expandedDevices.has(i)) newDevices.add(i);
|
|
329
|
+
});
|
|
330
|
+
expandedDevices.clear();
|
|
331
|
+
newDevices.forEach(i => expandedDevices.add(i));
|
|
332
|
+
|
|
333
|
+
const newSwitches = new Set();
|
|
334
|
+
config.devices.forEach((dev, di) => {
|
|
335
|
+
(dev.switches || []).forEach((_, si) => {
|
|
336
|
+
if (expandedSwitches.has(`${di}_${si}`)) newSwitches.add(`${di}_${si}`);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
expandedSwitches.clear();
|
|
340
|
+
newSwitches.forEach(k => expandedSwitches.add(k));
|
|
341
|
+
}
|
|
342
|
+
|
|
245
343
|
function renderDevice(dev, di) {
|
|
246
344
|
const switches = dev.switches || [];
|
|
345
|
+
const isOpen = expandedDevices.has(di);
|
|
346
|
+
const devLabel = dev.name || `${t.device || 'Device'} #${di + 1}`;
|
|
347
|
+
const switchCount = switches.length;
|
|
348
|
+
|
|
247
349
|
return `
|
|
248
350
|
<div class="device-card">
|
|
249
|
-
<div class="device-header">
|
|
250
|
-
<
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
<div class="inline-row">
|
|
255
|
-
<div class="form-group">
|
|
256
|
-
<label>${t.deviceName || 'Device Name'}</label>
|
|
257
|
-
<input type="text" class="dev-field" data-dev="${di}" data-field="name" value="${esc(dev.name || '')}">
|
|
351
|
+
<div class="device-header device-toggle" data-dev="${di}">
|
|
352
|
+
<div class="device-header-left">
|
|
353
|
+
<span class="chevron ${isOpen ? 'open' : ''}">▶</span>
|
|
354
|
+
<strong>${esc(devLabel)}</strong>
|
|
355
|
+
${!isOpen ? `<span class="switch-summary">(${switchCount} ${switchCount === 1 ? (t.switchSingular || 'switch') : (t.switches || 'switches').toLowerCase()})</span>` : ''}
|
|
258
356
|
</div>
|
|
259
|
-
<div class="
|
|
260
|
-
<
|
|
261
|
-
<select class="dev-field" data-dev="${di}" data-field="switchBehavior">
|
|
262
|
-
<option value="independent" ${dev.switchBehavior === 'independent' || !dev.switchBehavior ? 'selected' : ''}>${t.behaviorIndependent || 'Independent'}</option>
|
|
263
|
-
<option value="master" ${dev.switchBehavior === 'master' ? 'selected' : ''}>${t.behaviorMaster || 'Master'}</option>
|
|
264
|
-
<option value="single" ${dev.switchBehavior === 'single' ? 'selected' : ''}>${t.behaviorSingle || 'Single'}</option>
|
|
265
|
-
</select>
|
|
357
|
+
<div class="device-header-right">
|
|
358
|
+
<button class="btn btn-danger btn-sm btn-remove-device" data-dev="${di}">${t.removeDevice || 'Remove Device'}</button>
|
|
266
359
|
</div>
|
|
267
360
|
</div>
|
|
268
361
|
|
|
269
|
-
<div class="
|
|
362
|
+
<div class="device-body ${isOpen ? '' : 'collapsed'}">
|
|
363
|
+
<div class="inline-row">
|
|
364
|
+
<div class="form-group">
|
|
365
|
+
<label>${t.deviceName || 'Device Name'}</label>
|
|
366
|
+
<input type="text" class="dev-field" data-dev="${di}" data-field="name" value="${esc(dev.name || '')}">
|
|
367
|
+
</div>
|
|
368
|
+
<div class="form-group">
|
|
369
|
+
<label>${t.switchBehavior || 'Switch Behavior Mode'}</label>
|
|
370
|
+
<select class="dev-field" data-dev="${di}" data-field="switchBehavior">
|
|
371
|
+
<option value="independent" ${dev.switchBehavior === 'independent' || !dev.switchBehavior ? 'selected' : ''}>${t.behaviorIndependent || 'Independent'}</option>
|
|
372
|
+
<option value="master" ${dev.switchBehavior === 'master' ? 'selected' : ''}>${t.behaviorMaster || 'Master'}</option>
|
|
373
|
+
<option value="single" ${dev.switchBehavior === 'single' ? 'selected' : ''}>${t.behaviorSingle || 'Single'}</option>
|
|
374
|
+
</select>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
270
377
|
|
|
271
|
-
|
|
378
|
+
<div class="section-title">${t.switches || 'Switches'}</div>
|
|
272
379
|
|
|
273
|
-
|
|
380
|
+
${switches.map((sw, si) => renderSwitch(sw, di, si)).join('')}
|
|
381
|
+
|
|
382
|
+
<button class="btn btn-primary btn-add-switch" data-dev="${di}">+ ${t.addSwitch || 'Add Switch'}</button>
|
|
383
|
+
</div>
|
|
274
384
|
</div>
|
|
275
385
|
`;
|
|
276
386
|
}
|
|
277
387
|
|
|
278
388
|
function renderSwitch(sw, di, si) {
|
|
389
|
+
const isOpen = expandedSwitches.has(`${di}_${si}`);
|
|
390
|
+
const swLabel = sw.name || `#${si + 1}`;
|
|
391
|
+
|
|
279
392
|
return `
|
|
280
393
|
<div class="switch-card">
|
|
281
|
-
<div class="switch-header">
|
|
282
|
-
<
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
<div class="form-group">
|
|
287
|
-
<label>${t.switchName || 'Switch Name'}</label>
|
|
288
|
-
<input type="text" class="sw-field" data-dev="${di}" data-sw="${si}" data-field="name" value="${esc(sw.name || '')}">
|
|
394
|
+
<div class="switch-header switch-toggle" data-dev="${di}" data-sw="${si}">
|
|
395
|
+
<div class="switch-header-left">
|
|
396
|
+
<span class="chevron ${isOpen ? 'open' : ''}">▶</span>
|
|
397
|
+
<strong>${esc(swLabel)}</strong>
|
|
398
|
+
${!isOpen ? `<span class="switch-summary">${sw.type || 'outlet'}${sw.delayOff ? ` / ${sw.delayOff}ms` : ''}</span>` : ''}
|
|
289
399
|
</div>
|
|
290
|
-
<div class="
|
|
291
|
-
<
|
|
292
|
-
<select class="sw-field" data-dev="${di}" data-sw="${si}" data-field="type">
|
|
293
|
-
<option value="switch" ${sw.type === 'switch' ? 'selected' : ''}>${t.typeSwitch || 'Switch'}</option>
|
|
294
|
-
<option value="outlet" ${sw.type === 'outlet' || !sw.type ? 'selected' : ''}>${t.typeOutlet || 'Outlet'}</option>
|
|
295
|
-
</select>
|
|
400
|
+
<div class="switch-header-right">
|
|
401
|
+
<button class="btn btn-danger btn-sm btn-remove-switch" data-dev="${di}" data-sw="${si}">${t.removeSwitch || 'Remove'}</button>
|
|
296
402
|
</div>
|
|
297
403
|
</div>
|
|
298
|
-
<div class="
|
|
299
|
-
<div class="
|
|
300
|
-
<
|
|
301
|
-
|
|
404
|
+
<div class="switch-body ${isOpen ? '' : 'collapsed'}">
|
|
405
|
+
<div class="inline-row">
|
|
406
|
+
<div class="form-group">
|
|
407
|
+
<label>${t.switchName || 'Switch Name'}</label>
|
|
408
|
+
<input type="text" class="sw-field" data-dev="${di}" data-sw="${si}" data-field="name" value="${esc(sw.name || '')}">
|
|
409
|
+
</div>
|
|
410
|
+
<div class="form-group">
|
|
411
|
+
<label>${t.switchType || 'Switch Type'}</label>
|
|
412
|
+
<select class="sw-field" data-dev="${di}" data-sw="${si}" data-field="type">
|
|
413
|
+
<option value="switch" ${sw.type === 'switch' ? 'selected' : ''}>${t.typeSwitch || 'Switch'}</option>
|
|
414
|
+
<option value="outlet" ${sw.type === 'outlet' || !sw.type ? 'selected' : ''}>${t.typeOutlet || 'Outlet'}</option>
|
|
415
|
+
</select>
|
|
416
|
+
</div>
|
|
302
417
|
</div>
|
|
303
|
-
<div class="
|
|
304
|
-
<
|
|
305
|
-
|
|
306
|
-
<input type="
|
|
307
|
-
|
|
418
|
+
<div class="inline-row">
|
|
419
|
+
<div class="form-group">
|
|
420
|
+
<label>${t.delayOff || 'Auto Turn Off (ms)'}</label>
|
|
421
|
+
<input type="number" class="sw-field" data-dev="${di}" data-sw="${si}" data-field="delayOff" min="0" value="${sw.delayOff || 0}">
|
|
422
|
+
</div>
|
|
423
|
+
<div class="form-group">
|
|
424
|
+
<label>${t.defaultState || 'Default State'}</label>
|
|
425
|
+
<div class="toggle-wrap" style="margin-top:6px">
|
|
426
|
+
<input type="checkbox" class="sw-field" data-dev="${di}" data-sw="${si}" data-field="defaultState" ${sw.defaultState ? 'checked' : ''}>
|
|
427
|
+
<span>${sw.defaultState ? (t.on || 'On') : (t.off || 'Off')}</span>
|
|
428
|
+
</div>
|
|
308
429
|
</div>
|
|
309
430
|
</div>
|
|
310
431
|
</div>
|
package/package.json
CHANGED