|
|
|
|
@@ -51,6 +51,10 @@
|
|
|
|
|
.actions button { flex:1 1 180px; margin-top:0; }
|
|
|
|
|
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
|
|
|
|
|
.manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
|
|
|
|
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
|
|
|
|
|
.manual-entry { min-height:80px; resize:vertical; }
|
|
|
|
|
textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
|
|
|
|
|
optgroup { color:#cfe0ec; background:#101820; }
|
|
|
|
|
code { color:#cfe0ec; overflow-wrap:anywhere; }
|
|
|
|
|
@media (max-width: 760px) {
|
|
|
|
|
header { padding:18px 14px; }
|
|
|
|
|
@@ -123,6 +127,23 @@
|
|
|
|
|
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
|
|
|
|
|
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
|
|
|
|
|
<datalist id="actuator-options"></datalist>
|
|
|
|
|
<div class="inline-controls">
|
|
|
|
|
<div>
|
|
|
|
|
<label for="actuator-domain-filter">Typ</label>
|
|
|
|
|
<select id="actuator-domain-filter" onchange="renderActuatorSelect()">
|
|
|
|
|
<option value="">Alle steuerbaren Typen</option>
|
|
|
|
|
<option value="light">Lichter</option>
|
|
|
|
|
<option value="switch">Schalter / Helper</option>
|
|
|
|
|
<option value="cover">Rollläden / Cover</option>
|
|
|
|
|
<option value="fan">Lüftung / Ventilatoren</option>
|
|
|
|
|
<option value="humidifier">Befeuchter / Entfeuchter</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label for="actuator-search">Liste durchsuchen</label>
|
|
|
|
|
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" autocomplete="off">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<label for="actuator-select">Oder aus Liste wählen</label>
|
|
|
|
|
<select id="actuator-select" onchange="selectActuatorFromList()">
|
|
|
|
|
<option value="">Geräteliste wird geladen ...</option>
|
|
|
|
|
@@ -153,6 +174,7 @@ const escapeHtml = value => String(value ?? "")
|
|
|
|
|
let currentActuatorId = null;
|
|
|
|
|
let actuatorChoices = [];
|
|
|
|
|
let contextOptions = [];
|
|
|
|
|
let manualContextState = {options: [], selected: new Set()};
|
|
|
|
|
|
|
|
|
|
async function api(path, options = {}) {
|
|
|
|
|
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
|
|
|
|
|
@@ -194,6 +216,61 @@ function predictionLabel(record) {
|
|
|
|
|
: "Keine fällige Aktion";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function entityLabel(entity) {
|
|
|
|
|
const area = entity.area_name || "Ohne Bereich";
|
|
|
|
|
const name = entity.friendly_name || entity.entity_id;
|
|
|
|
|
return `${area} - ${name} (${entity.entity_id})`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizedSearch(value) {
|
|
|
|
|
return String(value || "").toLowerCase().replaceAll("_", " ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function matchesSearch(entity, query) {
|
|
|
|
|
if (!query) return true;
|
|
|
|
|
return normalizedSearch([
|
|
|
|
|
entity.entity_id,
|
|
|
|
|
entity.friendly_name,
|
|
|
|
|
entity.area_name,
|
|
|
|
|
entity.device_name,
|
|
|
|
|
entity.device_class,
|
|
|
|
|
entity.domain,
|
|
|
|
|
].filter(Boolean).join(" ")).includes(query);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function categoryForEntity(entity) {
|
|
|
|
|
const cls = entity.device_class || "";
|
|
|
|
|
if (entity.domain === "light") return "Lichtzustände";
|
|
|
|
|
if (entity.domain === "switch") return "Schalter / Helper";
|
|
|
|
|
if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz";
|
|
|
|
|
if (["illuminance"].includes(cls)) return "Helligkeit";
|
|
|
|
|
if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster";
|
|
|
|
|
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
|
|
|
|
|
if (["temperature"].includes(cls)) return "Temperatur";
|
|
|
|
|
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
|
|
|
|
|
if (entity.domain === "binary_sensor") return "Binäre Sensoren";
|
|
|
|
|
if (entity.domain === "sensor") return "Weitere Messsensoren";
|
|
|
|
|
return "Weitere Zustände";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function optionGroups(entities, selectedIds = new Set()) {
|
|
|
|
|
const groups = new Map();
|
|
|
|
|
for (const entity of entities) {
|
|
|
|
|
const category = categoryForEntity(entity);
|
|
|
|
|
if (!groups.has(category)) groups.set(category, []);
|
|
|
|
|
groups.get(category).push(entity);
|
|
|
|
|
}
|
|
|
|
|
return Array.from(groups.entries()).map(([label, items]) => `
|
|
|
|
|
<optgroup label="${escapeHtml(label)}">
|
|
|
|
|
${items.map(entity => `
|
|
|
|
|
<option value="${escapeHtml(entity.entity_id)}" ${selectedIds.has(entity.entity_id) ? "selected" : ""}>
|
|
|
|
|
${escapeHtml(entityLabel(entity))}
|
|
|
|
|
</option>
|
|
|
|
|
`).join("")}
|
|
|
|
|
</optgroup>
|
|
|
|
|
`).join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadOverview() {
|
|
|
|
|
const status = document.getElementById("status");
|
|
|
|
|
const chips = document.getElementById("status-chips");
|
|
|
|
|
@@ -217,7 +294,7 @@ async function loadOverview() {
|
|
|
|
|
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
|
|
|
|
chips.innerHTML = "";
|
|
|
|
|
}
|
|
|
|
|
await Promise.all([loadActuatorDiscovery(), loadContextOptions(), loadConfiguredActuators()]);
|
|
|
|
|
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadActuatorDiscovery() {
|
|
|
|
|
@@ -230,24 +307,53 @@ async function loadActuatorDiscovery() {
|
|
|
|
|
]);
|
|
|
|
|
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
|
|
|
|
|
actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
|
|
|
|
|
options.innerHTML = actuatorChoices.map(entity =>
|
|
|
|
|
options.innerHTML = actuatorChoices.slice(0, 120).map(entity =>
|
|
|
|
|
`<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`
|
|
|
|
|
).join("");
|
|
|
|
|
select.innerHTML = [
|
|
|
|
|
`<option value="">Gerät auswählen ...</option>`,
|
|
|
|
|
...actuatorChoices.map(entity =>
|
|
|
|
|
`<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.area_name || "Ohne Bereich")} - ${escapeHtml(entity.friendly_name || entity.entity_id)} (${escapeHtml(entity.entity_id)})</option>`
|
|
|
|
|
),
|
|
|
|
|
].join("");
|
|
|
|
|
renderActuatorSelect();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
options.innerHTML = "";
|
|
|
|
|
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden</option>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadContextOptions() {
|
|
|
|
|
function actuatorGroupLabel(domain) {
|
|
|
|
|
const labels = {
|
|
|
|
|
light: "Lichter",
|
|
|
|
|
switch: "Schalter / Helper",
|
|
|
|
|
cover: "Rollläden / Cover",
|
|
|
|
|
fan: "Lüftung / Ventilatoren",
|
|
|
|
|
humidifier: "Befeuchter / Entfeuchter",
|
|
|
|
|
};
|
|
|
|
|
return labels[domain] || domain;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderActuatorSelect() {
|
|
|
|
|
const select = document.getElementById("actuator-select");
|
|
|
|
|
if (!select) return;
|
|
|
|
|
const domain = document.getElementById("actuator-domain-filter")?.value || "";
|
|
|
|
|
const query = normalizedSearch(document.getElementById("actuator-search")?.value || "");
|
|
|
|
|
const filtered = actuatorChoices
|
|
|
|
|
.filter(entity => !domain || entity.domain === domain)
|
|
|
|
|
.filter(entity => matchesSearch(entity, query))
|
|
|
|
|
.slice(0, 120);
|
|
|
|
|
const domains = [...new Set(filtered.map(entity => entity.domain))].sort();
|
|
|
|
|
select.innerHTML = [
|
|
|
|
|
`<option value="">${filtered.length ? "Gerät auswählen ..." : "Keine passenden Geräte gefunden"}</option>`,
|
|
|
|
|
...domains.map(group => `
|
|
|
|
|
<optgroup label="${escapeHtml(actuatorGroupLabel(group))}">
|
|
|
|
|
${filtered
|
|
|
|
|
.filter(entity => entity.domain === group)
|
|
|
|
|
.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`)
|
|
|
|
|
.join("")}
|
|
|
|
|
</optgroup>
|
|
|
|
|
`),
|
|
|
|
|
].join("");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadContextOptions(actuatorId) {
|
|
|
|
|
try {
|
|
|
|
|
contextOptions = await api("v1/actuators/context-options");
|
|
|
|
|
contextOptions = await api(`v1/actuators/context-options?actuator_entity_id=${encodeURIComponent(actuatorId)}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
contextOptions = [];
|
|
|
|
|
}
|
|
|
|
|
@@ -258,6 +364,31 @@ function selectActuatorFromList() {
|
|
|
|
|
if (value) document.getElementById("actuator-input").value = value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderManualContextSelect() {
|
|
|
|
|
const select = document.getElementById("manual-context-select");
|
|
|
|
|
if (!select) return;
|
|
|
|
|
const category = document.getElementById("manual-context-category")?.value || "";
|
|
|
|
|
const query = normalizedSearch(document.getElementById("manual-context-filter")?.value || "");
|
|
|
|
|
const selectedNow = new Set([
|
|
|
|
|
...manualContextState.selected,
|
|
|
|
|
...Array.from(select.selectedOptions).map(option => option.value),
|
|
|
|
|
]);
|
|
|
|
|
const filtered = manualContextState.options
|
|
|
|
|
.filter(entity => !category || categoryForEntity(entity) === category)
|
|
|
|
|
.filter(entity => matchesSearch(entity, query))
|
|
|
|
|
.slice(0, 80);
|
|
|
|
|
select.innerHTML = filtered.length
|
|
|
|
|
? optionGroups(filtered, selectedNow)
|
|
|
|
|
: `<option value="">Keine passenden Vorschläge</option>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseEntityIds(value) {
|
|
|
|
|
return String(value || "")
|
|
|
|
|
.split(/[\s,;]+/)
|
|
|
|
|
.map(item => item.trim())
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function configureActuator() {
|
|
|
|
|
const actuatorId = (
|
|
|
|
|
document.getElementById("actuator-input").value.trim()
|
|
|
|
|
@@ -328,6 +459,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|
|
|
|
} catch (_) {
|
|
|
|
|
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
|
|
|
|
|
}
|
|
|
|
|
await loadContextOptions(actuatorId);
|
|
|
|
|
const contexts = [
|
|
|
|
|
record.assignment.selected_numeric_entity_id,
|
|
|
|
|
...record.assignment.selected_context_entity_ids,
|
|
|
|
|
@@ -343,32 +475,50 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|
|
|
|
const relatedAutomations = record.behavior.related_automations || [];
|
|
|
|
|
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
|
|
|
|
|
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
|
|
|
|
|
const suggestedIds = new Set(contextOptions.map(entity => entity.entity_id));
|
|
|
|
|
const manualOnlyIds = [
|
|
|
|
|
record.assignment.selected_numeric_entity_id,
|
|
|
|
|
...manualContextIds,
|
|
|
|
|
].filter(entityId => entityId && !suggestedIds.has(entityId));
|
|
|
|
|
const contextCategories = [...new Set(contextOptions
|
|
|
|
|
.filter(entity => entity.entity_id !== record.actuator_entity_id)
|
|
|
|
|
.map(categoryForEntity))]
|
|
|
|
|
.sort();
|
|
|
|
|
manualContextState = {
|
|
|
|
|
options: contextOptions.filter(entity => entity.entity_id !== record.actuator_entity_id),
|
|
|
|
|
selected: manualContextIds,
|
|
|
|
|
};
|
|
|
|
|
const manualAssignment = `
|
|
|
|
|
<div class="manual-context">
|
|
|
|
|
<h3>Kontext selbst festlegen</h3>
|
|
|
|
|
<p class="muted">Hier kannst du Sensoren und Zustände ergänzen, die deiner Meinung nach wichtig für den Schaltvorgang sind. Beispiele: PIR, Helligkeit außen, Luftfeuchtigkeit innen/außen oder ein Lichtzustand.</p>
|
|
|
|
|
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p>
|
|
|
|
|
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
|
|
|
|
|
<select id="manual-numeric-select">
|
|
|
|
|
<option value="">Keinen numerischen Hauptsensor verwenden</option>
|
|
|
|
|
${numericOptions.map(entity => `
|
|
|
|
|
<option value="${escapeHtml(entity.entity_id)}" ${record.assignment.selected_numeric_entity_id === entity.entity_id ? "selected" : ""}>
|
|
|
|
|
${escapeHtml(entity.area_name || "Ohne Bereich")} - ${escapeHtml(entity.friendly_name || entity.entity_id)} (${escapeHtml(entity.entity_id)})
|
|
|
|
|
</option>
|
|
|
|
|
`).join("")}
|
|
|
|
|
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
|
|
|
|
|
</select>
|
|
|
|
|
<label for="manual-context-select">Zusätzliche Kontext-Entities</label>
|
|
|
|
|
<div class="inline-controls">
|
|
|
|
|
<div>
|
|
|
|
|
<label for="manual-context-category">Kategorie</label>
|
|
|
|
|
<select id="manual-context-category" onchange="renderManualContextSelect()">
|
|
|
|
|
<option value="">Alle relevanten Vorschläge</option>
|
|
|
|
|
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label for="manual-context-filter">Vorschläge durchsuchen</label>
|
|
|
|
|
<input id="manual-context-filter" placeholder="z. B. treppe, bewegung, lux" oninput="renderManualContextSelect()" autocomplete="off">
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<label for="manual-context-select">Zusätzliche Kontext-Entities aus Vorschlägen</label>
|
|
|
|
|
<select id="manual-context-select" multiple>
|
|
|
|
|
${contextOptions
|
|
|
|
|
.filter(entity => entity.entity_id !== record.actuator_entity_id)
|
|
|
|
|
.map(entity => `
|
|
|
|
|
<option value="${escapeHtml(entity.entity_id)}" ${manualContextIds.has(entity.entity_id) ? "selected" : ""}>
|
|
|
|
|
${escapeHtml(entity.area_name || "Ohne Bereich")} - ${escapeHtml(entity.friendly_name || entity.entity_id)} (${escapeHtml(entity.entity_id)})
|
|
|
|
|
</option>
|
|
|
|
|
`).join("")}
|
|
|
|
|
${optionGroups(manualContextState.options.slice(0, 80), manualContextIds)}
|
|
|
|
|
</select>
|
|
|
|
|
<label for="manual-context-freeform">Entity-IDs manuell ergänzen</label>
|
|
|
|
|
<textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea>
|
|
|
|
|
<div class="actions">
|
|
|
|
|
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button>
|
|
|
|
|
<button class="secondary" onclick="loadContextOptions().then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Listen neu laden</button>
|
|
|
|
|
<button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
@@ -439,9 +589,14 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|
|
|
|
|
|
|
|
|
async function saveManualAssignment(actuatorId) {
|
|
|
|
|
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
|
|
|
|
|
const contextEntityIds = Array.from(
|
|
|
|
|
const selectedContextIds = Array.from(
|
|
|
|
|
document.getElementById("manual-context-select").selectedOptions,
|
|
|
|
|
).map(option => option.value);
|
|
|
|
|
).map(option => option.value).filter(value => value.includes("."));
|
|
|
|
|
const freeformContextIds = parseEntityIds(
|
|
|
|
|
document.getElementById("manual-context-freeform").value,
|
|
|
|
|
);
|
|
|
|
|
const contextEntityIds = [...new Set([...selectedContextIds, ...freeformContextIds])]
|
|
|
|
|
.filter(entityId => entityId !== numericEntityId);
|
|
|
|
|
try {
|
|
|
|
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
|