homebridge-multiple-switch 1.4.1 → 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 CHANGED
@@ -1,5 +1,22 @@
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
+
11
+ ## [1.5.0] - 2026-03-21
12
+
13
+ ### Added
14
+ - Multi-device support: create multiple separate HomeKit accessories, each with
15
+ its own name, switch behavior mode, and set of switches
16
+ - `devices` array in config — each device becomes a separate accessory in HomeKit
17
+ - Full backward compatibility: existing configs with `switches` at root level
18
+ continue to work and are auto-migrated to the new `devices` format in the UI
19
+
3
20
  ## [1.4.1] - 2026-03-21
4
21
 
5
22
  ### Fixed
@@ -12,65 +12,73 @@
12
12
  "type": "string",
13
13
  "default": "Multiple Switch Platform"
14
14
  },
15
- "switchBehavior": {
16
- "title": "Switch Behavior Mode",
17
- "description": "Controls how switches interact with each other.",
18
- "type": "string",
19
- "default": "independent",
20
- "oneOf": [
21
- { "title": "Independent", "enum": ["independent"] },
22
- { "title": "Master", "enum": ["master"] },
23
- { "title": "Single", "enum": ["single"] }
24
- ]
25
- },
26
- "switches": {
27
- "title": "Switches",
28
- "description": "List of virtual switches to create.",
15
+ "devices": {
16
+ "title": "Devices",
17
+ "description": "List of switch devices. Each device appears as a separate accessory in HomeKit.",
29
18
  "type": "array",
30
19
  "items": {
31
20
  "type": "object",
32
21
  "properties": {
33
22
  "name": {
34
- "title": "Switch Name",
35
- "description": "Display name of the switch in HomeKit.",
23
+ "title": "Device Name",
24
+ "description": "Display name of this device in HomeKit.",
36
25
  "type": "string"
37
26
  },
38
- "type": {
39
- "title": "Switch Type",
40
- "description": "The HomeKit accessory type for this switch.",
27
+ "switchBehavior": {
28
+ "title": "Switch Behavior Mode",
29
+ "description": "Controls how switches interact with each other within this device.",
41
30
  "type": "string",
42
- "default": "outlet",
31
+ "default": "independent",
43
32
  "oneOf": [
44
- { "title": "Switch", "enum": ["switch"] },
45
- { "title": "Outlet", "enum": ["outlet"] }
33
+ { "title": "Independent", "enum": ["independent"] },
34
+ { "title": "Master", "enum": ["master"] },
35
+ { "title": "Single", "enum": ["single"] }
46
36
  ]
47
37
  },
48
- "defaultState": {
49
- "title": "Default State",
50
- "description": "Initial power state when Homebridge starts.",
51
- "type": "boolean",
52
- "default": false
53
- },
54
- "delayOff": {
55
- "title": "Auto Turn Off (ms)",
56
- "description": "Automatically turn off after this many milliseconds. Set to 0 to disable.",
57
- "type": "number",
58
- "default": 0,
59
- "minimum": 0
38
+ "switches": {
39
+ "title": "Switches",
40
+ "description": "List of virtual switches in this device.",
41
+ "type": "array",
42
+ "items": {
43
+ "type": "object",
44
+ "properties": {
45
+ "name": {
46
+ "title": "Switch Name",
47
+ "description": "Display name of the switch in HomeKit.",
48
+ "type": "string"
49
+ },
50
+ "type": {
51
+ "title": "Switch Type",
52
+ "description": "The HomeKit accessory type for this switch.",
53
+ "type": "string",
54
+ "default": "outlet",
55
+ "oneOf": [
56
+ { "title": "Switch", "enum": ["switch"] },
57
+ { "title": "Outlet", "enum": ["outlet"] }
58
+ ]
59
+ },
60
+ "defaultState": {
61
+ "title": "Default State",
62
+ "description": "Initial power state when Homebridge starts.",
63
+ "type": "boolean",
64
+ "default": false
65
+ },
66
+ "delayOff": {
67
+ "title": "Auto Turn Off (ms)",
68
+ "description": "Automatically turn off after this many milliseconds. Set to 0 to disable.",
69
+ "type": "number",
70
+ "default": 0,
71
+ "minimum": 0
72
+ }
73
+ },
74
+ "required": ["name", "type"]
75
+ }
60
76
  }
61
77
  },
62
- "required": ["name", "type"]
63
- },
64
- "default": [
65
- {
66
- "name": "Switch 1",
67
- "type": "outlet",
68
- "defaultState": false,
69
- "delayOff": 0
70
- }
71
- ]
78
+ "required": ["name", "switches"]
79
+ }
72
80
  }
73
81
  },
74
- "required": ["name", "switches"]
82
+ "required": ["name"]
75
83
  }
76
84
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "إزالة",
24
24
  "save": "حفظ",
25
25
  "on": "تشغيل",
26
- "off": "إيقاف"
26
+ "off": "إيقاف",
27
+ "devices": "الأجهزة",
28
+ "devicesDesc": "يظهر كل جهاز كملحق منفصل في HomeKit مع مفاتيحه الخاصة.",
29
+ "device": "جهاز",
30
+ "deviceName": "اسم الجهاز",
31
+ "addDevice": "إضافة جهاز",
32
+ "removeDevice": "إزالة الجهاز"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "Entfernen",
24
24
  "save": "Speichern",
25
25
  "on": "Ein",
26
- "off": "Aus"
26
+ "off": "Aus",
27
+ "devices": "Geräte",
28
+ "devicesDesc": "Jedes Gerät erscheint als separates Zubehör in HomeKit mit eigenen Schaltern.",
29
+ "device": "Gerät",
30
+ "deviceName": "Gerätename",
31
+ "addDevice": "Gerät hinzufügen",
32
+ "removeDevice": "Gerät entfernen"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "Remove",
24
24
  "save": "Save",
25
25
  "on": "On",
26
- "off": "Off"
26
+ "off": "Off",
27
+ "devices": "Devices",
28
+ "devicesDesc": "Each device appears as a separate accessory in HomeKit with its own switches.",
29
+ "device": "Device",
30
+ "deviceName": "Device Name",
31
+ "addDevice": "Add Device",
32
+ "removeDevice": "Remove Device"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "Eliminar",
24
24
  "save": "Guardar",
25
25
  "on": "Encendido",
26
- "off": "Apagado"
26
+ "off": "Apagado",
27
+ "devices": "Dispositivos",
28
+ "devicesDesc": "Cada dispositivo aparece como un accesorio separado en HomeKit con sus propios interruptores.",
29
+ "device": "Dispositivo",
30
+ "deviceName": "Nombre del dispositivo",
31
+ "addDevice": "Agregar dispositivo",
32
+ "removeDevice": "Eliminar dispositivo"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "Supprimer",
24
24
  "save": "Enregistrer",
25
25
  "on": "Activé",
26
- "off": "Désactivé"
26
+ "off": "Désactivé",
27
+ "devices": "Appareils",
28
+ "devicesDesc": "Chaque appareil apparaît comme un accessoire séparé dans HomeKit avec ses propres interrupteurs.",
29
+ "device": "Appareil",
30
+ "deviceName": "Nom de l'appareil",
31
+ "addDevice": "Ajouter un appareil",
32
+ "removeDevice": "Supprimer l'appareil"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "Rimuovi",
24
24
  "save": "Salva",
25
25
  "on": "Acceso",
26
- "off": "Spento"
26
+ "off": "Spento",
27
+ "devices": "Dispositivi",
28
+ "devicesDesc": "Ogni dispositivo appare come un accessorio separato in HomeKit con i propri interruttori.",
29
+ "device": "Dispositivo",
30
+ "deviceName": "Nome del dispositivo",
31
+ "addDevice": "Aggiungi dispositivo",
32
+ "removeDevice": "Rimuovi dispositivo"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "削除",
24
24
  "save": "保存",
25
25
  "on": "オン",
26
- "off": "オフ"
26
+ "off": "オフ",
27
+ "devices": "デバイス",
28
+ "devicesDesc": "各デバイスは HomeKit で独自のスイッチを持つ個別のアクセサリとして表示されます。",
29
+ "device": "デバイス",
30
+ "deviceName": "デバイス名",
31
+ "addDevice": "デバイスを追加",
32
+ "removeDevice": "デバイスを削除"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "삭제",
24
24
  "save": "저장",
25
25
  "on": "켜짐",
26
- "off": "꺼짐"
26
+ "off": "꺼짐",
27
+ "devices": "장치",
28
+ "devicesDesc": "각 장치는 HomeKit에서 자체 스위치를 가진 별도의 액세서리로 표시됩니다.",
29
+ "device": "장치",
30
+ "deviceName": "장치 이름",
31
+ "addDevice": "장치 추가",
32
+ "removeDevice": "장치 삭제"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "Verwijderen",
24
24
  "save": "Opslaan",
25
25
  "on": "Aan",
26
- "off": "Uit"
26
+ "off": "Uit",
27
+ "devices": "Apparaten",
28
+ "devicesDesc": "Elk apparaat verschijnt als een apart accessoire in HomeKit met zijn eigen schakelaars.",
29
+ "device": "Apparaat",
30
+ "deviceName": "Apparaatnaam",
31
+ "addDevice": "Apparaat toevoegen",
32
+ "removeDevice": "Apparaat verwijderen"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "Usuń",
24
24
  "save": "Zapisz",
25
25
  "on": "Wł",
26
- "off": "Wył"
26
+ "off": "Wył",
27
+ "devices": "Urządzenia",
28
+ "devicesDesc": "Każde urządzenie pojawia się jako osobne akcesorium w HomeKit z własnymi przełącznikami.",
29
+ "device": "Urządzenie",
30
+ "deviceName": "Nazwa urządzenia",
31
+ "addDevice": "Dodaj urządzenie",
32
+ "removeDevice": "Usuń urządzenie"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "Remover",
24
24
  "save": "Salvar",
25
25
  "on": "Ligado",
26
- "off": "Desligado"
26
+ "off": "Desligado",
27
+ "devices": "Dispositivos",
28
+ "devicesDesc": "Cada dispositivo aparece como um acessório separado no HomeKit com seus próprios interruptores.",
29
+ "device": "Dispositivo",
30
+ "deviceName": "Nome do dispositivo",
31
+ "addDevice": "Adicionar dispositivo",
32
+ "removeDevice": "Remover dispositivo"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "Удалить",
24
24
  "save": "Сохранить",
25
25
  "on": "Вкл",
26
- "off": "Выкл"
26
+ "off": "Выкл",
27
+ "devices": "Устройства",
28
+ "devicesDesc": "Каждое устройство отображается как отдельный аксессуар в HomeKit со своими переключателями.",
29
+ "device": "Устройство",
30
+ "deviceName": "Название устройства",
31
+ "addDevice": "Добавить устройство",
32
+ "removeDevice": "Удалить устройство"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "Kaldır",
24
24
  "save": "Kaydet",
25
25
  "on": "Açık",
26
- "off": "Kapalı"
26
+ "off": "Kapalı",
27
+ "devices": "Cihazlar",
28
+ "devicesDesc": "Her cihaz HomeKit'te kendi anahtarlarıyla ayrı bir aksesuar olarak görünür.",
29
+ "device": "Cihaz",
30
+ "deviceName": "Cihaz Adı",
31
+ "addDevice": "Cihaz Ekle",
32
+ "removeDevice": "Cihazı Kaldır"
27
33
  }
@@ -23,5 +23,11 @@
23
23
  "removeSwitch": "删除",
24
24
  "save": "保存",
25
25
  "on": "开",
26
- "off": "关"
26
+ "off": "关",
27
+ "devices": "设备",
28
+ "devicesDesc": "每个设备在 HomeKit 中显示为一个独立的配件,拥有自己的开关。",
29
+ "device": "设备",
30
+ "deviceName": "设备名称",
31
+ "addDevice": "添加设备",
32
+ "removeDevice": "删除设备"
27
33
  }
@@ -7,6 +7,8 @@
7
7
  --ui-card-bg: #f8f9fa;
8
8
  --ui-input-bg: #fff;
9
9
  --ui-accent: #007bff;
10
+ --ui-device-bg: rgba(0,123,255,0.04);
11
+ --ui-device-border: rgba(0,123,255,0.2);
10
12
  }
11
13
  @media (prefers-color-scheme: dark) {
12
14
  :root {
@@ -17,6 +19,8 @@
17
19
  --ui-card-bg: rgba(255,255,255,0.06);
18
20
  --ui-input-bg: rgba(255,255,255,0.08);
19
21
  --ui-accent: #7b61ff;
22
+ --ui-device-bg: rgba(123,97,255,0.06);
23
+ --ui-device-border: rgba(123,97,255,0.25);
20
24
  }
21
25
  }
22
26
  * { box-sizing: border-box; margin: 0; padding: 0; }
@@ -46,18 +50,60 @@
46
50
  border-color: var(--ui-accent);
47
51
  outline: none;
48
52
  }
53
+ .device-card {
54
+ border: 2px solid var(--ui-device-border);
55
+ border-radius: 10px; margin-bottom: 20px;
56
+ background: var(--ui-device-bg);
57
+ overflow: hidden;
58
+ }
59
+ .device-header {
60
+ display: flex; justify-content: space-between; align-items: center;
61
+ padding: 14px 20px; cursor: pointer; user-select: none;
62
+ }
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 {
68
+ font-size: 16px; color: var(--ui-accent);
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; }
49
79
  .switch-card {
50
80
  border: 1px solid var(--ui-border);
51
- border-radius: 8px; padding: 16px; margin-bottom: 12px;
81
+ border-radius: 8px; margin-bottom: 12px;
52
82
  background: var(--ui-card-bg);
53
- position: relative;
83
+ position: relative; overflow: hidden;
54
84
  }
55
- .switch-card .switch-header {
56
- display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;
85
+ .switch-header {
86
+ display: flex; justify-content: space-between; align-items: center;
87
+ padding: 12px 16px; cursor: pointer; user-select: none;
57
88
  }
58
- .switch-card .switch-header strong {
59
- font-size: 14px;
60
- color: var(--ui-text);
89
+ .switch-header:hover { opacity: 0.85; }
90
+ .switch-header-left {
91
+ display: flex; align-items: center; gap: 8px;
92
+ }
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;
61
107
  }
62
108
  .btn {
63
109
  padding: 8px 16px; border: none; border-radius: 6px;
@@ -65,8 +111,14 @@
65
111
  }
66
112
  .btn-danger { background: #dc3545; color: #fff; }
67
113
  .btn-danger:hover { background: #c82333; }
114
+ .btn-sm { padding: 4px 10px; font-size: 12px; }
68
115
  .btn-primary { background: var(--ui-accent); color: #fff; }
69
116
  .btn-primary:hover { opacity: 0.85; }
117
+ .btn-outline {
118
+ background: transparent; color: var(--ui-accent);
119
+ border: 1px solid var(--ui-accent);
120
+ }
121
+ .btn-outline:hover { background: var(--ui-accent); color: #fff; }
70
122
  .actions { margin-top: 16px; display: flex; gap: 10px; }
71
123
  .toggle-wrap { display: flex; align-items: center; gap: 8px; }
72
124
  .toggle-wrap input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--ui-accent); }
@@ -113,10 +165,33 @@
113
165
  config = configs[0] || {};
114
166
  } catch {}
115
167
 
168
+ // Migrate old format (switches at root) to devices array
169
+ if (Array.isArray(config.switches) && config.switches.length > 0 && !config.devices) {
170
+ config.devices = [{
171
+ name: config.name || 'Multiple Switch Panel',
172
+ switchBehavior: config.switchBehavior || 'independent',
173
+ switches: config.switches,
174
+ }];
175
+ delete config.switches;
176
+ delete config.switchBehavior;
177
+ }
178
+
179
+ if (!config.devices) {
180
+ config.devices = [];
181
+ }
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
+
116
192
  const app = document.getElementById('app');
117
193
 
118
194
  function render() {
119
- const switches = config.switches || [];
120
195
  app.innerHTML = `
121
196
  <div class="form-group">
122
197
  <label>${t.platformName || 'Platform Name'}</label>
@@ -124,25 +199,15 @@
124
199
  <input type="text" id="cfg-name" value="${esc(config.name || 'Multiple Switch Platform')}">
125
200
  </div>
126
201
 
127
- <div class="form-group">
128
- <label>${t.switchBehavior || 'Switch Behavior Mode'}</label>
129
- <div class="desc">${t.switchBehaviorDesc || ''}</div>
130
- <select id="cfg-behavior">
131
- <option value="independent" ${config.switchBehavior === 'independent' || !config.switchBehavior ? 'selected' : ''}>${t.behaviorIndependent || 'Independent'}</option>
132
- <option value="master" ${config.switchBehavior === 'master' ? 'selected' : ''}>${t.behaviorMaster || 'Master'}</option>
133
- <option value="single" ${config.switchBehavior === 'single' ? 'selected' : ''}>${t.behaviorSingle || 'Single'}</option>
134
- </select>
135
- </div>
202
+ <div class="section-title">${t.devices || 'Devices'}</div>
203
+ <div class="desc" style="margin-bottom:16px">${t.devicesDesc || ''}</div>
136
204
 
137
- <div class="section-title">${t.switches || 'Switches'}</div>
138
- <div class="desc" style="margin-bottom:12px">${t.switchesDesc || ''}</div>
139
-
140
- <div id="switches-list">
141
- ${switches.map((sw, i) => renderSwitch(sw, i)).join('')}
205
+ <div id="devices-list">
206
+ ${config.devices.map((dev, di) => renderDevice(dev, di)).join('')}
142
207
  </div>
143
208
 
144
209
  <div class="actions">
145
- <button class="btn btn-primary" id="btn-add">+ ${t.addSwitch || 'Add Switch'}</button>
210
+ <button class="btn btn-outline" id="btn-add-device">+ ${t.addDevice || 'Add Device'}</button>
146
211
  </div>
147
212
  `;
148
213
 
@@ -150,21 +215,90 @@
150
215
  config.name = e.target.value;
151
216
  save();
152
217
  });
153
- document.getElementById('cfg-behavior').addEventListener('change', (e) => {
154
- config.switchBehavior = e.target.value;
155
- save();
156
- });
157
- document.getElementById('btn-add').addEventListener('click', () => {
158
- if (!config.switches) config.switches = [];
159
- config.switches.push({ name: '', type: 'outlet', defaultState: false, delayOff: 0 });
218
+
219
+ document.getElementById('btn-add-device').addEventListener('click', () => {
220
+ const di = config.devices.length;
221
+ config.devices.push({
222
+ name: '',
223
+ switchBehavior: 'independent',
224
+ switches: [{ name: '', type: 'outlet', defaultState: false, delayOff: 0 }],
225
+ });
226
+ expandedDevices.add(di);
227
+ expandedSwitches.add(`${di}_0`);
160
228
  render();
161
229
  save();
162
230
  });
163
231
 
164
- document.querySelectorAll('.btn-remove').forEach(btn => {
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
+
261
+ // Device-level events
262
+ document.querySelectorAll('.dev-field').forEach(input => {
263
+ input.addEventListener('change', () => {
264
+ const di = parseInt(input.dataset.dev);
265
+ const field = input.dataset.field;
266
+ config.devices[di][field] = input.value;
267
+ save();
268
+ });
269
+ });
270
+
271
+ document.querySelectorAll('.btn-remove-device').forEach(btn => {
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();
278
+ render();
279
+ save();
280
+ });
281
+ });
282
+
283
+ // Switch-level events
284
+ document.querySelectorAll('.btn-add-switch').forEach(btn => {
165
285
  btn.addEventListener('click', () => {
166
- const idx = parseInt(btn.dataset.idx);
167
- config.switches.splice(idx, 1);
286
+ const di = parseInt(btn.dataset.dev);
287
+ const si = config.devices[di].switches.length;
288
+ config.devices[di].switches.push({ name: '', type: 'outlet', defaultState: false, delayOff: 0 });
289
+ expandedSwitches.add(`${di}_${si}`);
290
+ render();
291
+ save();
292
+ });
293
+ });
294
+
295
+ document.querySelectorAll('.btn-remove-switch').forEach(btn => {
296
+ btn.addEventListener('click', (e) => {
297
+ e.stopPropagation();
298
+ const di = parseInt(btn.dataset.dev);
299
+ const si = parseInt(btn.dataset.sw);
300
+ config.devices[di].switches.splice(si, 1);
301
+ rebuildExpandState();
168
302
  render();
169
303
  save();
170
304
  });
@@ -172,50 +306,126 @@
172
306
 
173
307
  document.querySelectorAll('.sw-field').forEach(input => {
174
308
  input.addEventListener('change', () => {
175
- const idx = parseInt(input.dataset.idx);
309
+ const di = parseInt(input.dataset.dev);
310
+ const si = parseInt(input.dataset.sw);
176
311
  const field = input.dataset.field;
177
312
  if (field === 'defaultState') {
178
- config.switches[idx][field] = input.checked;
313
+ config.devices[di].switches[si][field] = input.checked;
179
314
  } else if (field === 'delayOff') {
180
- config.switches[idx][field] = parseInt(input.value) || 0;
315
+ config.devices[di].switches[si][field] = parseInt(input.value) || 0;
181
316
  } else {
182
- config.switches[idx][field] = input.value;
317
+ config.devices[di].switches[si][field] = input.value;
183
318
  }
184
319
  save();
185
320
  });
186
321
  });
187
322
  }
188
323
 
189
- function renderSwitch(sw, i) {
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
+
343
+ function renderDevice(dev, di) {
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
+
190
349
  return `
191
- <div class="switch-card">
192
- <div class="switch-header">
193
- <strong>#${i + 1}</strong>
194
- <button class="btn btn-danger btn-remove" data-idx="${i}">${t.removeSwitch || 'Remove'}</button>
350
+ <div class="device-card">
351
+ <div class="device-header device-toggle" data-dev="${di}">
352
+ <div class="device-header-left">
353
+ <span class="chevron ${isOpen ? 'open' : ''}">&#9654;</span>
354
+ <strong>${esc(devLabel)}</strong>
355
+ ${!isOpen ? `<span class="switch-summary">(${switchCount} ${switchCount === 1 ? (t.switchSingular || 'switch') : (t.switches || 'switches').toLowerCase()})</span>` : ''}
356
+ </div>
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>
359
+ </div>
195
360
  </div>
196
- <div class="inline-row">
197
- <div class="form-group">
198
- <label>${t.switchName || 'Switch Name'}</label>
199
- <input type="text" class="sw-field" data-idx="${i}" data-field="name" value="${esc(sw.name || '')}">
361
+
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>
200
376
  </div>
201
- <div class="form-group">
202
- <label>${t.switchType || 'Switch Type'}</label>
203
- <select class="sw-field" data-idx="${i}" data-field="type">
204
- <option value="switch" ${sw.type === 'switch' ? 'selected' : ''}>${t.typeSwitch || 'Switch'}</option>
205
- <option value="outlet" ${sw.type === 'outlet' || !sw.type ? 'selected' : ''}>${t.typeOutlet || 'Outlet'}</option>
206
- </select>
377
+
378
+ <div class="section-title">${t.switches || 'Switches'}</div>
379
+
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>
384
+ </div>
385
+ `;
386
+ }
387
+
388
+ function renderSwitch(sw, di, si) {
389
+ const isOpen = expandedSwitches.has(`${di}_${si}`);
390
+ const swLabel = sw.name || `#${si + 1}`;
391
+
392
+ return `
393
+ <div class="switch-card">
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' : ''}">&#9654;</span>
397
+ <strong>${esc(swLabel)}</strong>
398
+ ${!isOpen ? `<span class="switch-summary">${sw.type || 'outlet'}${sw.delayOff ? ` / ${sw.delayOff}ms` : ''}</span>` : ''}
399
+ </div>
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>
207
402
  </div>
208
403
  </div>
209
- <div class="inline-row">
210
- <div class="form-group">
211
- <label>${t.delayOff || 'Auto Turn Off (ms)'}</label>
212
- <input type="number" class="sw-field" data-idx="${i}" data-field="delayOff" min="0" value="${sw.delayOff || 0}">
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>
213
417
  </div>
214
- <div class="form-group">
215
- <label>${t.defaultState || 'Default State'}</label>
216
- <div class="toggle-wrap" style="margin-top:6px">
217
- <input type="checkbox" class="sw-field" data-idx="${i}" data-field="defaultState" ${sw.defaultState ? 'checked' : ''}>
218
- <span>${sw.defaultState ? (t.on || 'On') : (t.off || 'Off')}</span>
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>
219
429
  </div>
220
430
  </div>
221
431
  </div>
package/index.js CHANGED
@@ -20,7 +20,7 @@ class MultipleSwitchPlatform {
20
20
  this.Service = api.hap.Service;
21
21
  this.Characteristic = api.hap.Characteristic;
22
22
  this.cachedAccessories = new Map();
23
- this.switchServices = new Map();
23
+ this.deviceServices = new Map();
24
24
 
25
25
  this.api.on('didFinishLaunching', () => {
26
26
  this.log.info('MultipleSwitchPlatform started.');
@@ -32,16 +32,47 @@ class MultipleSwitchPlatform {
32
32
  this.cachedAccessories.set(accessory.UUID, accessory);
33
33
  }
34
34
 
35
+ getDevices() {
36
+ if (Array.isArray(this.config.devices) && this.config.devices.length > 0) {
37
+ return this.config.devices;
38
+ }
39
+
40
+ if (Array.isArray(this.config.switches) && this.config.switches.length > 0) {
41
+ return [{
42
+ name: this.config.name || 'Multiple Switch Panel',
43
+ switchBehavior: this.config.switchBehavior || 'independent',
44
+ switches: this.config.switches,
45
+ }];
46
+ }
47
+
48
+ return [];
49
+ }
50
+
35
51
  setupAccessories() {
36
- const switches = this.config.switches;
37
- if (!Array.isArray(switches) || switches.length === 0) {
38
- this.log.warn('No switches configured. Removing stale accessories.');
52
+ const devices = this.getDevices();
53
+
54
+ if (devices.length === 0) {
55
+ this.log.warn('No devices configured. Removing stale accessories.');
39
56
  this.removeStaleCachedAccessories();
40
57
  return;
41
58
  }
42
59
 
43
- const name = this.config.name || 'Multiple Switch Panel';
44
- const behavior = this.config.switchBehavior || 'independent';
60
+ for (const device of devices) {
61
+ this.setupDevice(device);
62
+ }
63
+
64
+ this.removeStaleCachedAccessories();
65
+ }
66
+
67
+ setupDevice(device) {
68
+ const switches = device.switches;
69
+ if (!Array.isArray(switches) || switches.length === 0) {
70
+ this.log.warn(`Device "${device.name}" has no switches, skipping.`);
71
+ return;
72
+ }
73
+
74
+ const name = device.name || 'Multiple Switch Panel';
75
+ const behavior = device.switchBehavior || 'independent';
45
76
  const uuid = this.api.hap.uuid.generate(name);
46
77
 
47
78
  let accessory = this.cachedAccessories.get(uuid);
@@ -54,17 +85,19 @@ class MultipleSwitchPlatform {
54
85
  accessory.context.switchBehavior = behavior;
55
86
  accessory.context.switchStates = accessory.context.switchStates || {};
56
87
 
57
- this.reconcileServices(accessory, switches);
88
+ const services = new Map();
89
+ this.deviceServices.set(uuid, services);
90
+
91
+ this.reconcileServices(accessory, switches, services);
58
92
 
59
93
  if (isNew) {
60
94
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
61
95
  }
62
96
 
63
97
  this.cachedAccessories.delete(uuid);
64
- this.removeStaleCachedAccessories();
65
98
  }
66
99
 
67
- reconcileServices(accessory, switches) {
100
+ reconcileServices(accessory, switches, services) {
68
101
  const activeSubtypes = new Set();
69
102
 
70
103
  switches.forEach((sw, index) => {
@@ -79,9 +112,9 @@ class MultipleSwitchPlatform {
79
112
  }
80
113
 
81
114
  service.setCharacteristic(this.Characteristic.Name, sw.name);
82
- this.configureSwitchHandlers(accessory, service, sw, subtype);
115
+ this.configureSwitchHandlers(accessory, service, sw, subtype, services);
83
116
 
84
- this.switchServices.set(subtype, service);
117
+ services.set(subtype, service);
85
118
 
86
119
  if (accessory.context.switchStates[subtype] === undefined) {
87
120
  accessory.context.switchStates[subtype] = sw.defaultState || false;
@@ -94,7 +127,7 @@ class MultipleSwitchPlatform {
94
127
  servicesToRemove.forEach((s) => accessory.removeService(s));
95
128
  }
96
129
 
97
- configureSwitchHandlers(accessory, service, sw, subtype) {
130
+ configureSwitchHandlers(accessory, service, sw, subtype, services) {
98
131
  service.getCharacteristic(this.Characteristic.On)
99
132
  .onGet(() => accessory.context.switchStates[subtype] ?? false)
100
133
  .onSet((value) => {
@@ -104,11 +137,11 @@ class MultipleSwitchPlatform {
104
137
  const behavior = accessory.context.switchBehavior;
105
138
 
106
139
  if (behavior === 'single' && value) {
107
- this.turnOffOthers(accessory, subtype);
140
+ this.turnOffOthers(accessory, subtype, services);
108
141
  }
109
142
 
110
143
  if (behavior === 'master') {
111
- this.setAll(accessory, value);
144
+ this.setAll(accessory, value, services);
112
145
  }
113
146
 
114
147
  if (value && sw.delayOff > 0) {
@@ -117,8 +150,8 @@ class MultipleSwitchPlatform {
117
150
  });
118
151
  }
119
152
 
120
- turnOffOthers(accessory, excludeSubtype) {
121
- for (const [key, svc] of this.switchServices) {
153
+ turnOffOthers(accessory, excludeSubtype, services) {
154
+ for (const [key, svc] of services) {
122
155
  if (key !== excludeSubtype) {
123
156
  accessory.context.switchStates[key] = false;
124
157
  svc.updateCharacteristic(this.Characteristic.On, false);
@@ -126,8 +159,8 @@ class MultipleSwitchPlatform {
126
159
  }
127
160
  }
128
161
 
129
- setAll(accessory, value) {
130
- for (const [key, svc] of this.switchServices) {
162
+ setAll(accessory, value, services) {
163
+ for (const [key, svc] of services) {
131
164
  accessory.context.switchStates[key] = value;
132
165
  svc.updateCharacteristic(this.Characteristic.On, value);
133
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-multiple-switch",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "Multiple switch platform for Homebridge",
5
5
  "homepage": "https://github.com/azadaydinli/homebridge-multiple-switch",
6
6
  "main": "index.js",