@mp-consulting/homebridge-unifi-access 1.0.0
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/.claude/settings.local.json +91 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE.md +22 -0
- package/README.md +159 -0
- package/config.schema.json +202 -0
- package/dist/access-controller.d.ts +41 -0
- package/dist/access-controller.js +342 -0
- package/dist/access-controller.js.map +1 -0
- package/dist/access-device-catalog.d.ts +43 -0
- package/dist/access-device-catalog.js +151 -0
- package/dist/access-device-catalog.js.map +1 -0
- package/dist/access-device.d.ts +68 -0
- package/dist/access-device.js +330 -0
- package/dist/access-device.js.map +1 -0
- package/dist/access-events.d.ts +27 -0
- package/dist/access-events.js +152 -0
- package/dist/access-events.js.map +1 -0
- package/dist/access-options.d.ts +32 -0
- package/dist/access-options.js +65 -0
- package/dist/access-options.js.map +1 -0
- package/dist/access-platform.d.ts +15 -0
- package/dist/access-platform.js +74 -0
- package/dist/access-platform.js.map +1 -0
- package/dist/access-types.d.ts +30 -0
- package/dist/access-types.js +42 -0
- package/dist/access-types.js.map +1 -0
- package/dist/hub/access-hub-api.d.ts +13 -0
- package/dist/hub/access-hub-api.js +140 -0
- package/dist/hub/access-hub-api.js.map +1 -0
- package/dist/hub/access-hub-events.d.ts +2 -0
- package/dist/hub/access-hub-events.js +229 -0
- package/dist/hub/access-hub-events.js.map +1 -0
- package/dist/hub/access-hub-mqtt.d.ts +2 -0
- package/dist/hub/access-hub-mqtt.js +137 -0
- package/dist/hub/access-hub-mqtt.js.map +1 -0
- package/dist/hub/access-hub-services.d.ts +4 -0
- package/dist/hub/access-hub-services.js +451 -0
- package/dist/hub/access-hub-services.js.map +1 -0
- package/dist/hub/access-hub-types.d.ts +145 -0
- package/dist/hub/access-hub-types.js +35 -0
- package/dist/hub/access-hub-types.js.map +1 -0
- package/dist/hub/access-hub-utils.d.ts +20 -0
- package/dist/hub/access-hub-utils.js +128 -0
- package/dist/hub/access-hub-utils.js.map +1 -0
- package/dist/hub/access-hub.d.ts +39 -0
- package/dist/hub/access-hub.js +185 -0
- package/dist/hub/access-hub.js.map +1 -0
- package/dist/hub/index.d.ts +4 -0
- package/dist/hub/index.js +7 -0
- package/dist/hub/index.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/settings.d.ts +16 -0
- package/dist/settings.js +49 -0
- package/dist/settings.js.map +1 -0
- package/docs/FeatureOptions.md +120 -0
- package/docs/MQTT.md +116 -0
- package/docs/api_reference.pdf +0 -0
- package/docs/media/homebridge-unifi-access.png +0 -0
- package/docs/media/homebridge-unifi-access.svg +21 -0
- package/eslint.config.mjs +99 -0
- package/homebridge-ui/public/app.js +104 -0
- package/homebridge-ui/public/index.html +267 -0
- package/homebridge-ui/public/modules/constants.js +22 -0
- package/homebridge-ui/public/modules/controllers.js +202 -0
- package/homebridge-ui/public/modules/discovery.js +89 -0
- package/homebridge-ui/public/modules/dom-helpers.js +41 -0
- package/homebridge-ui/public/modules/feature-options.js +625 -0
- package/homebridge-ui/public/modules/state.js +26 -0
- package/homebridge-ui/public/styles.css +533 -0
- package/homebridge-ui/server.js +374 -0
- package/package.json +83 -0
- package/scripts/event-schema-monitor.ts +350 -0
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { $, escapeHtml, showScreen } from "./dom-helpers.js";
|
|
2
|
+
import { CATEGORY_COLORS, CATEGORY_ICONS } from "./constants.js";
|
|
3
|
+
import { getControllers, saveConfigSilent, state } from "./state.js";
|
|
4
|
+
|
|
5
|
+
export const openFeatureOptions = async (controllerIndex) => {
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
state.currentControllerIndex = controllerIndex;
|
|
9
|
+
const ctrl = getControllers()[controllerIndex];
|
|
10
|
+
|
|
11
|
+
showScreen("featureOptionsScreen");
|
|
12
|
+
$("optionsLoading").style.display = "block";
|
|
13
|
+
$("optionsContainer").innerHTML = "";
|
|
14
|
+
$("deviceInfoPanel").style.display = "none";
|
|
15
|
+
$("unsavedChanges").style.display = "none";
|
|
16
|
+
$("optionsSearch").value = "";
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
const [ optionsData, devices ] = await Promise.all([
|
|
22
|
+
homebridge.request("/getOptions"),
|
|
23
|
+
homebridge.request("/getDevices", { address: ctrl.address, password: ctrl.password, username: ctrl.username })
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
state.categories = optionsData.categories;
|
|
27
|
+
state.options = optionsData.options;
|
|
28
|
+
|
|
29
|
+
// Process devices.
|
|
30
|
+
state.devices = [];
|
|
31
|
+
|
|
32
|
+
if(devices?.length) {
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
/* eslint-disable camelcase */
|
|
36
|
+
devices[0].display_model = "controller";
|
|
37
|
+
devices[0].ip = devices[0].host?.ip;
|
|
38
|
+
devices[0].is_online = true;
|
|
39
|
+
devices[0].mac = devices[0].host?.mac;
|
|
40
|
+
devices[0].model = devices[0].host?.device_type;
|
|
41
|
+
devices[0].unique_id = devices[0].host?.mac;
|
|
42
|
+
/* eslint-enable camelcase */
|
|
43
|
+
|
|
44
|
+
for(const device of devices) {
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
device.name ||= device.alias || device.display_model;
|
|
48
|
+
device.serialNumber = (device.mac || "").replace(/:/g, "").toUpperCase() +
|
|
49
|
+
((device.device_type === "UAH-Ent") ? "-" + (device.source_id || "").toUpperCase() : "");
|
|
50
|
+
|
|
51
|
+
if((device.display_model === "controller") || device.capabilities?.includes("is_hub") || device.capabilities?.includes("is_reader")) {
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
state.devices.push(device);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
buildScopeSelector(ctrl);
|
|
60
|
+
renderOptions();
|
|
61
|
+
} catch(e) {
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
homebridge.toast.error("Failed to load: " + e.message);
|
|
65
|
+
} finally {
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
$("optionsLoading").style.display = "none";
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const buildScopeSelector = (ctrl) => {
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
const select = $("scopeSelect");
|
|
76
|
+
|
|
77
|
+
select.innerHTML = "";
|
|
78
|
+
|
|
79
|
+
const addOpt = (value, text) => {
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
const opt = document.createElement("option");
|
|
83
|
+
|
|
84
|
+
opt.value = value;
|
|
85
|
+
opt.textContent = text;
|
|
86
|
+
select.appendChild(opt);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
addOpt("global", "Global Options");
|
|
90
|
+
addOpt("controller:" + ctrl.address, "Controller: " + (ctrl.name || ctrl.address));
|
|
91
|
+
|
|
92
|
+
const hubs = state.devices.filter(d => (d.display_model !== "controller") && d.capabilities?.includes("is_hub"));
|
|
93
|
+
const readers = state.devices.filter(d => (d.display_model !== "controller") && d.capabilities?.includes("is_reader"));
|
|
94
|
+
|
|
95
|
+
const addGroup = (label, items) => {
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if(!items.length) { return; }
|
|
99
|
+
const group = document.createElement("optgroup");
|
|
100
|
+
|
|
101
|
+
group.label = label;
|
|
102
|
+
items.forEach(d => {
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
const opt = document.createElement("option");
|
|
106
|
+
|
|
107
|
+
opt.value = "device:" + d.serialNumber;
|
|
108
|
+
opt.textContent = d.name;
|
|
109
|
+
group.appendChild(opt);
|
|
110
|
+
});
|
|
111
|
+
select.appendChild(group);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
addGroup("Hubs", hubs);
|
|
115
|
+
addGroup("Readers", readers);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const getCurrentScope = () => {
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
const val = $("scopeSelect").value;
|
|
122
|
+
|
|
123
|
+
if(val === "global") { return { device: null, id: null, type: "global" }; }
|
|
124
|
+
|
|
125
|
+
const colonIdx = val.indexOf(":");
|
|
126
|
+
const type = val.substring(0, colonIdx);
|
|
127
|
+
const id = val.substring(colonIdx + 1);
|
|
128
|
+
|
|
129
|
+
if(type === "controller") {
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
return { device: null, id, type: "controller" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const device = state.devices.find(d => d.serialNumber === id);
|
|
136
|
+
|
|
137
|
+
return { device, id, type: "device" };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const SCOPE_ORDER = [ "global", "controller", "device" ];
|
|
141
|
+
|
|
142
|
+
const updateCascade = (scopeType) => {
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
const activeIdx = SCOPE_ORDER.indexOf(scopeType);
|
|
146
|
+
const cascade = $("scopeCascade");
|
|
147
|
+
|
|
148
|
+
if(!cascade) { return; }
|
|
149
|
+
|
|
150
|
+
// Update scope levels.
|
|
151
|
+
cascade.querySelectorAll(".scope-level").forEach(el => {
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
const level = el.dataset.scope;
|
|
155
|
+
const levelIdx = SCOPE_ORDER.indexOf(level);
|
|
156
|
+
|
|
157
|
+
el.classList.remove("active", "inherited");
|
|
158
|
+
|
|
159
|
+
if(levelIdx === activeIdx) {
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
el.classList.add("active");
|
|
163
|
+
} else if(levelIdx < activeIdx) {
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
el.classList.add("inherited");
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Update connectors: active if they connect inherited/active levels.
|
|
171
|
+
cascade.querySelectorAll(".scope-connector").forEach((conn, i) => {
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
conn.classList.toggle("active", i < activeIdx);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Update hints based on active scope.
|
|
178
|
+
const hints = {
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
controller: [ "Base values", "Editing this scope", "Inherits controller" ],
|
|
182
|
+
device: [ "Base values", "Intermediate", "Editing this scope" ],
|
|
183
|
+
global: [ "Editing this scope", "Inherits global", "Inherits global" ]
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
cascade.querySelectorAll(".scope-level").forEach((el, i) => {
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
const hint = el.querySelector(".scope-hint");
|
|
190
|
+
|
|
191
|
+
if(hint) { hint.textContent = hints[scopeType][i]; }
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const renderOptions = () => {
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
const container = $("optionsContainer");
|
|
199
|
+
|
|
200
|
+
container.innerHTML = "";
|
|
201
|
+
|
|
202
|
+
const scope = getCurrentScope();
|
|
203
|
+
|
|
204
|
+
// Update the visual cascade diagram.
|
|
205
|
+
updateCascade(scope.type);
|
|
206
|
+
const searchTerm = ($("optionsSearch").value || "").toLowerCase();
|
|
207
|
+
const modifiedOnly = $("modifiedOnlyToggle")?.checked || false;
|
|
208
|
+
|
|
209
|
+
// Device info panel.
|
|
210
|
+
if(scope.device && (scope.device.display_model !== "controller")) {
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
$("deviceInfoPanel").style.display = "block";
|
|
214
|
+
$("infoModel").textContent = scope.device.model || scope.device.display_model;
|
|
215
|
+
$("infoMac").textContent = scope.device.serialNumber;
|
|
216
|
+
$("infoIp").textContent = scope.device.ip || "N/A";
|
|
217
|
+
const statusEl = $("infoStatus");
|
|
218
|
+
|
|
219
|
+
statusEl.textContent = scope.device.is_online ? "Connected" : "Disconnected";
|
|
220
|
+
statusEl.className = scope.device.is_online ? "text-success" : "text-danger";
|
|
221
|
+
} else {
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
$("deviceInfoPanel").style.display = "none";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let totalModified = 0;
|
|
228
|
+
|
|
229
|
+
// Render each category as a card.
|
|
230
|
+
state.categories.forEach((category) => {
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
// Filter categories based on device type.
|
|
234
|
+
if(scope.device && (scope.device.display_model !== "controller")) {
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
const catModelKey = category.modelKey || [];
|
|
238
|
+
const catCapability = category.hasCapability;
|
|
239
|
+
|
|
240
|
+
if(!catModelKey.some(m => [ "all", scope.device.display_model ].includes(m))) { return; }
|
|
241
|
+
|
|
242
|
+
if(catCapability && (!scope.device.capabilities || !catCapability.some(c => scope.device.capabilities.includes(c)))) { return; }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const categoryOptions = state.options[category.name] || [];
|
|
246
|
+
|
|
247
|
+
if(!categoryOptions.length) { return; }
|
|
248
|
+
|
|
249
|
+
// Filter options for this device and search.
|
|
250
|
+
let validOptions = categoryOptions.filter(opt => {
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if(scope.device && (scope.device.display_model !== "controller")) {
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
if(opt.hasCapability && (!scope.device.capabilities || !opt.hasCapability.some(c => scope.device.capabilities.includes(c)))) { return false; }
|
|
257
|
+
|
|
258
|
+
if(opt.modelKey && (opt.modelKey !== "all") && !opt.modelKey.includes(scope.device.display_model)) { return false; }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if(searchTerm) {
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
const text = (opt.name + " " + opt.description).toLowerCase();
|
|
265
|
+
|
|
266
|
+
if(!text.includes(searchTerm)) { return false; }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return true;
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
if(!validOptions.length) { return; }
|
|
273
|
+
|
|
274
|
+
// Count stats before filtering for "modified only".
|
|
275
|
+
const modifiedCount = countModified(category.name, validOptions, scope);
|
|
276
|
+
const enabledCount = countEnabled(category.name, validOptions, scope);
|
|
277
|
+
|
|
278
|
+
totalModified += modifiedCount;
|
|
279
|
+
|
|
280
|
+
// Apply "modified only" filter.
|
|
281
|
+
if(modifiedOnly) {
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
validOptions = validOptions.filter(opt => {
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
const optionKey = category.name + (opt.name ? "." + opt.name : "");
|
|
288
|
+
|
|
289
|
+
return isOptionModified(optionKey, scope.id);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if(!validOptions.length) { return; }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const icon = CATEGORY_ICONS[category.name] || "fa-cog";
|
|
296
|
+
const color = CATEGORY_COLORS[category.name] || "#6c757d";
|
|
297
|
+
const isOpen = state.openCategories.has(category.name) || ((modifiedCount > 0) && !state.openCategories.size) || !!searchTerm;
|
|
298
|
+
|
|
299
|
+
// Build card.
|
|
300
|
+
const card = document.createElement("div");
|
|
301
|
+
|
|
302
|
+
card.className = "card mb-3 category-card";
|
|
303
|
+
card.style.setProperty("--category-color", color);
|
|
304
|
+
card.dataset.category = category.name;
|
|
305
|
+
|
|
306
|
+
const header = document.createElement("div");
|
|
307
|
+
|
|
308
|
+
header.className = "card-header bg-transparent d-flex justify-content-between align-items-center";
|
|
309
|
+
header.style.cursor = "pointer";
|
|
310
|
+
|
|
311
|
+
const progressPct = validOptions.length ? Math.round((enabledCount / categoryOptions.length) * 100) : 0;
|
|
312
|
+
|
|
313
|
+
/* eslint-disable no-restricted-syntax */
|
|
314
|
+
header.innerHTML = `
|
|
315
|
+
<div class="d-flex align-items-center">
|
|
316
|
+
<span class="category-icon" style="background-color: ${color}"><i class="fas ${icon}"></i></span>
|
|
317
|
+
<span class="ms-2">${escapeHtml(category.description.replace(/ feature options\.?/i, ""))}</span>
|
|
318
|
+
</div>
|
|
319
|
+
<div class="d-flex align-items-center gap-2">
|
|
320
|
+
<span class="category-summary d-none d-sm-flex align-items-center gap-2">
|
|
321
|
+
<span>${enabledCount}/${categoryOptions.length} on</span>
|
|
322
|
+
<span class="category-progress"><span class="category-progress-fill" style="width: ${progressPct}%"></span></span>
|
|
323
|
+
</span>
|
|
324
|
+
${modifiedCount ? `<span class="badge bg-warning text-dark">${modifiedCount}</span>` : ""}
|
|
325
|
+
<i class="fas fa-chevron-${isOpen ? "up" : "down"} toggle-icon"></i>
|
|
326
|
+
</div>
|
|
327
|
+
`;
|
|
328
|
+
/* eslint-enable no-restricted-syntax */
|
|
329
|
+
|
|
330
|
+
const body = document.createElement("div");
|
|
331
|
+
|
|
332
|
+
body.className = "category-body" + (isOpen ? " open" : "");
|
|
333
|
+
|
|
334
|
+
const optList = document.createElement("div");
|
|
335
|
+
|
|
336
|
+
optList.className = "list-group list-group-flush";
|
|
337
|
+
|
|
338
|
+
validOptions.forEach(opt => {
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
const optionKey = category.name + (opt.name ? "." + opt.name : "");
|
|
342
|
+
const optEl = createOptionItem(optionKey, opt, scope, category);
|
|
343
|
+
|
|
344
|
+
optList.appendChild(optEl);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
body.appendChild(optList);
|
|
348
|
+
card.appendChild(header);
|
|
349
|
+
card.appendChild(body);
|
|
350
|
+
container.appendChild(card);
|
|
351
|
+
|
|
352
|
+
// Toggle collapse and persist state.
|
|
353
|
+
header.addEventListener("click", () => {
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
const wasOpen = body.classList.contains("open");
|
|
357
|
+
|
|
358
|
+
body.classList.toggle("open");
|
|
359
|
+
header.querySelector(".toggle-icon").className = "fas fa-chevron-" + (wasOpen ? "down" : "up") + " toggle-icon";
|
|
360
|
+
|
|
361
|
+
if(wasOpen) {
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
state.openCategories.delete(category.name);
|
|
365
|
+
} else {
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
state.openCategories.add(category.name);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Update modified summary.
|
|
374
|
+
const summaryEl = $("modifiedSummary");
|
|
375
|
+
|
|
376
|
+
/* eslint-disable-next-line no-restricted-syntax */
|
|
377
|
+
summaryEl.textContent = totalModified ? `(${totalModified})` : "";
|
|
378
|
+
summaryEl.className = totalModified ? "text-warning" : "";
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
const countModified = (categoryName, options, scope) => {
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
let count = 0;
|
|
385
|
+
|
|
386
|
+
for(const opt of options) {
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
const optionKey = categoryName + (opt.name ? "." + opt.name : "");
|
|
390
|
+
|
|
391
|
+
if(isOptionModified(optionKey, scope.id)) { count++; }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return count;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const countEnabled = (categoryName, options, scope) => {
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
let count = 0;
|
|
401
|
+
|
|
402
|
+
for(const opt of options) {
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
const optionKey = categoryName + (opt.name ? "." + opt.name : "");
|
|
406
|
+
const optState = getOptionState(optionKey, opt, scope);
|
|
407
|
+
|
|
408
|
+
if(optState.enabled) { count++; }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return count;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
415
|
+
|
|
416
|
+
const isOptionModified = (optionKey, scopeId) => {
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
const configuredOptions = state.pluginConfig[0].options || [];
|
|
420
|
+
const regex = scopeId ?
|
|
421
|
+
new RegExp("^(Enable|Disable)\\." + escapeRegex(optionKey) + "\\." + escapeRegex(scopeId) + "(\\..*)?$", "i") :
|
|
422
|
+
new RegExp("^(Enable|Disable)\\." + escapeRegex(optionKey) + "$", "i");
|
|
423
|
+
|
|
424
|
+
return configuredOptions.some(o => regex.test(o));
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const getOptionState = (optionKey, opt, scope) => {
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
const configuredOptions = state.pluginConfig[0].options || [];
|
|
431
|
+
const scopeId = scope.id;
|
|
432
|
+
|
|
433
|
+
// Check current scope first.
|
|
434
|
+
if(scopeId) {
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
const regex = new RegExp("^(Enable|Disable)\\." + escapeRegex(optionKey) + "\\." + escapeRegex(scopeId) + "$", "i");
|
|
438
|
+
|
|
439
|
+
for(const entry of configuredOptions) {
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
const match = regex.exec(entry);
|
|
443
|
+
|
|
444
|
+
if(match) { return { enabled: match[1].toLowerCase() === "enable", explicit: true, scope: scope.type }; }
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Check controller scope when viewing a device.
|
|
449
|
+
if(scope.type === "device") {
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
const ctrl = getControllers()[state.currentControllerIndex];
|
|
453
|
+
|
|
454
|
+
if(ctrl) {
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
const regex = new RegExp("^(Enable|Disable)\\." + escapeRegex(optionKey) + "\\." + escapeRegex(ctrl.address) + "$", "i");
|
|
458
|
+
|
|
459
|
+
for(const entry of configuredOptions) {
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
const match = regex.exec(entry);
|
|
463
|
+
|
|
464
|
+
if(match) { return { enabled: match[1].toLowerCase() === "enable", explicit: false, scope: "controller" }; }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Check global.
|
|
470
|
+
const globalRegex = new RegExp("^(Enable|Disable)\\." + escapeRegex(optionKey) + "$", "i");
|
|
471
|
+
|
|
472
|
+
for(const entry of configuredOptions) {
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
const match = globalRegex.exec(entry);
|
|
476
|
+
|
|
477
|
+
if(match) { return { enabled: match[1].toLowerCase() === "enable", explicit: scope.type === "global", scope: "global" }; }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return { enabled: opt.default, explicit: false, scope: "default" };
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const createOptionItem = (optionKey, opt, scope, category) => {
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
const el = document.createElement("div");
|
|
487
|
+
const optState = getOptionState(optionKey, opt, scope);
|
|
488
|
+
const switchId = "sw-" + optionKey.replace(/[^a-zA-Z0-9]/g, "-");
|
|
489
|
+
const isGrouped = opt.group && (opt.name !== opt.group);
|
|
490
|
+
|
|
491
|
+
// Build class list with scope-based styling.
|
|
492
|
+
let scopeClass = "";
|
|
493
|
+
|
|
494
|
+
if(optState.explicit) {
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
scopeClass = "scope-" + scope.type;
|
|
498
|
+
} else if(optState.scope === "controller") {
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
scopeClass = "scope-controller";
|
|
502
|
+
} else if((optState.scope === "global") && (scope.type !== "global")) {
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
scopeClass = "scope-global";
|
|
506
|
+
} else if(optState.enabled !== opt.default) {
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
scopeClass = "non-default";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
el.className = "list-group-item option-item " + scopeClass + (isGrouped ? " option-grouped" : "");
|
|
513
|
+
|
|
514
|
+
// Scope indicator badge.
|
|
515
|
+
let scopeIndicator = "";
|
|
516
|
+
|
|
517
|
+
if(optState.explicit) {
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
const scopeColor = scope.type === "device" ? "info" : scope.type === "controller" ? "success" : "warning";
|
|
521
|
+
const scopeLabel = scope.type === "device" ? "device" : scope.type === "controller" ? "controller" : "global";
|
|
522
|
+
|
|
523
|
+
/* eslint-disable-next-line no-restricted-syntax */
|
|
524
|
+
scopeIndicator = `<span class="badge bg-${scopeColor} ms-1">${scopeLabel}</span>`;
|
|
525
|
+
} else if(optState.scope === "controller") {
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
scopeIndicator = "<span class=\"badge bg-success bg-opacity-50 ms-1\"><i class=\"fas fa-arrow-down\" style=\"font-size:0.55rem\"></i> controller</span>";
|
|
529
|
+
} else if((optState.scope === "global") && (scope.type !== "global")) {
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
scopeIndicator = "<span class=\"badge bg-secondary bg-opacity-50 ms-1\"><i class=\"fas fa-arrow-down\" style=\"font-size:0.55rem\"></i> global</span>";
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Display name: use option name or category name for the unnamed device option.
|
|
536
|
+
const displayName = opt.name || category.description.replace(/ feature options\.?/i, "");
|
|
537
|
+
|
|
538
|
+
// Default value indicator.
|
|
539
|
+
/* eslint-disable no-restricted-syntax */
|
|
540
|
+
const defaultIndicator =
|
|
541
|
+
`<span class="default-indicator ${opt.default ? "default-on" : "default-off"} ms-2">default: ${opt.default ? "on" : "off"}</span>`;
|
|
542
|
+
|
|
543
|
+
const resetButton = optState.explicit ?
|
|
544
|
+
"<button class=\"btn btn-outline-secondary btn-sm reset-option-btn flex-shrink-0 mt-1\" title=\"Reset to inherited value\">" +
|
|
545
|
+
"<i class=\"fas fa-undo\"></i></button>" :
|
|
546
|
+
"";
|
|
547
|
+
|
|
548
|
+
el.innerHTML = `
|
|
549
|
+
<div class="d-flex justify-content-between align-items-start">
|
|
550
|
+
<div class="me-3 flex-grow-1">
|
|
551
|
+
<div class="form-check form-switch mb-1">
|
|
552
|
+
<input class="form-check-input" type="checkbox" id="${switchId}" ${optState.enabled ? "checked" : ""}>
|
|
553
|
+
<label class="form-check-label d-flex align-items-center flex-wrap" for="${switchId}">
|
|
554
|
+
<span class="option-name">${escapeHtml(displayName)}</span>
|
|
555
|
+
${defaultIndicator}
|
|
556
|
+
${scopeIndicator}
|
|
557
|
+
</label>
|
|
558
|
+
</div>
|
|
559
|
+
<div class="option-description text-muted ms-4">${escapeHtml(opt.description)}</div>
|
|
560
|
+
</div>
|
|
561
|
+
${resetButton}
|
|
562
|
+
</div>
|
|
563
|
+
`;
|
|
564
|
+
|
|
565
|
+
el.querySelector(`#${switchId}`).addEventListener("change", function() { /* eslint-enable no-restricted-syntax */
|
|
566
|
+
|
|
567
|
+
setOption(optionKey, this.checked, scope, opt);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const resetBtn = el.querySelector(".reset-option-btn");
|
|
571
|
+
|
|
572
|
+
if(resetBtn) {
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
resetBtn.addEventListener("click", (e) => {
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
e.stopPropagation();
|
|
579
|
+
removeOption(optionKey, scope);
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return el;
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const setOption = (optionKey, enabled, scope, opt) => {
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
state.pluginConfig[0].options ||= [];
|
|
590
|
+
|
|
591
|
+
const scopeId = scope.id;
|
|
592
|
+
const suffix = scopeId ? "." + scopeId : "";
|
|
593
|
+
|
|
594
|
+
// Remove existing entry at this scope.
|
|
595
|
+
const removeRegex = new RegExp("^(Enable|Disable)\\." + escapeRegex(optionKey) + (scopeId ? "\\." + escapeRegex(scopeId) : "") + "$", "i");
|
|
596
|
+
|
|
597
|
+
state.pluginConfig[0].options = state.pluginConfig[0].options.filter(o => !removeRegex.test(o));
|
|
598
|
+
|
|
599
|
+
// Only add an explicit entry if the value differs from the inherited/default state.
|
|
600
|
+
const inherited = opt ? getOptionState(optionKey, opt, scope) : null;
|
|
601
|
+
|
|
602
|
+
if(!inherited || (inherited.enabled !== enabled)) {
|
|
603
|
+
|
|
604
|
+
state.pluginConfig[0].options.push((enabled ? "Enable" : "Disable") + "." + optionKey + suffix);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
saveConfigSilent();
|
|
608
|
+
$("unsavedChanges").style.display = "block";
|
|
609
|
+
renderOptions();
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const removeOption = (optionKey, scope) => {
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
if(!state.pluginConfig[0].options) { return; }
|
|
616
|
+
|
|
617
|
+
const scopeId = scope.id;
|
|
618
|
+
const removeRegex = new RegExp("^(Enable|Disable)\\." + escapeRegex(optionKey) + (scopeId ? "\\." + escapeRegex(scopeId) : "") + "$", "i");
|
|
619
|
+
|
|
620
|
+
state.pluginConfig[0].options = state.pluginConfig[0].options.filter(o => !removeRegex.test(o));
|
|
621
|
+
|
|
622
|
+
saveConfigSilent();
|
|
623
|
+
$("unsavedChanges").style.display = "block";
|
|
624
|
+
renderOptions();
|
|
625
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const state = {
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
categories: [],
|
|
5
|
+
currentControllerIndex: null,
|
|
6
|
+
devices: [],
|
|
7
|
+
editingIndex: null,
|
|
8
|
+
openCategories: new Set(),
|
|
9
|
+
options: {},
|
|
10
|
+
pluginConfig: []
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const getControllers = () => state.pluginConfig[0]?.controllers || [];
|
|
14
|
+
|
|
15
|
+
export const saveConfig = async () => {
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
await homebridge.updatePluginConfig(state.pluginConfig);
|
|
19
|
+
await homebridge.savePluginConfig();
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const saveConfigSilent = async () => {
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
await homebridge.updatePluginConfig(state.pluginConfig);
|
|
26
|
+
};
|