Add room management settings
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-07-26 22:44:56 +02:00
parent 954c4511a7
commit 9ff005f089
9 changed files with 647 additions and 5 deletions

View File

@@ -546,6 +546,25 @@ class ActuatorReconciliationService:
if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS]
top_contexts = [candidate.entity_id for candidate in accepted_contexts]
if (
top_numeric is not None
and actuator.domain in {"light", "switch"}
and any(
(candidate.device_class or "") in {"door", "garage_door", "opening", "window"}
for candidate in accepted_contexts
)
):
return AssignmentSelection(
selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts,
source=AssignmentSource.AUTOMATIC,
confidence=max(candidate.confidence for candidate in accepted_contexts),
review_required=False,
reason=(
"Tür-/Öffnungskontext automatisch erkannt. Für diese "
"direkte Schaltlogik ist kein Helligkeitssensor erforderlich."
),
)
if top_numeric is None:
if accepted_contexts:
return AssignmentSelection(

View File

@@ -13,6 +13,8 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import (
ActuatorRecord,
AnomalyEvent,
AssignmentCandidate,
BehaviorPattern,
FeedbackKind,
ReconciliationState,
SensorWeightGroup,
@@ -177,6 +179,49 @@ class AnomalyOverview(BaseModel):
anomalies: list[AnomalyEvent] = Field(default_factory=list)
class RoomManagementSensor(BaseModel):
entity_id: str
domain: str
role: str
category: str
friendly_name: str | None = None
device_class: str | None = None
state: str | None = None
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
active: bool = False
optional: bool = False
not_required: bool = False
reason: str
class RoomManagementActuator(BaseModel):
actuator_entity_id: str
friendly_name: str | None = None
domain: str
behavior_mode: str
behavior_status: str
lifecycle_status: str
sample_count: int = 0
selected_numeric_entity_id: str | None = None
selected_context_entity_ids: list[str] = Field(default_factory=list)
sensors: list[RoomManagementSensor] = Field(default_factory=list)
prediction_rules: list[str] = Field(default_factory=list)
management_hint: str
class RoomManagementGroup(BaseModel):
room: str
actuator_count: int
sensors: list[RoomManagementSensor] = Field(default_factory=list)
actuators: list[RoomManagementActuator] = Field(default_factory=list)
prediction_rules: list[str] = Field(default_factory=list)
class RoomManagementOverview(BaseModel):
rooms: list[RoomManagementGroup] = Field(default_factory=list)
unmanaged_actuators: list[ActuatorSuggestion] = Field(default_factory=list)
@router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(
request: Request,
@@ -444,6 +489,96 @@ def list_anomalies(request: Request) -> list[AnomalyOverview]:
return overview
@router.get("/settings/rooms", response_model=RoomManagementOverview)
def room_management_overview(request: Request) -> RoomManagementOverview:
service = _service(request)
records = service.list_configured()
try:
entities = {entity.entity_id: entity for entity in service._ha_reader.read_entities()}
except Exception:
entities = _load_cached_entity_map(
request,
{
entity_id
for record in records
for entity_id in [
record.actuator_entity_id,
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
*[candidate.entity_id for candidate in record.numeric_candidates[:8]],
*[candidate.entity_id for candidate in record.context_candidates[:12]],
]
if entity_id
},
)
configured_ids = {record.actuator_entity_id for record in records}
rooms: dict[str, RoomManagementGroup] = {}
for record in records:
actuator = entities.get(record.actuator_entity_id)
room = (
actuator.area_name
if actuator is not None and actuator.area_name
else _candidate_room(record)
) or "Ohne Raum"
selected_context_ids = set(record.assignment.selected_context_entity_ids)
selected_numeric_id = record.assignment.selected_numeric_entity_id
selected_ids = {selected_numeric_id, *selected_context_ids} - {None}
ranked_candidates = _rank_management_candidates(record)
has_opening_context = any(
candidate.entity_id in selected_context_ids
and (candidate.device_class or "") in {"door", "garage_door", "opening", "window"}
for candidate in ranked_candidates
)
sensors = [
_management_sensor(
candidate,
entities.get(candidate.entity_id),
active=candidate.entity_id in selected_ids,
optional=(
candidate.role is EntityRole.MEASUREMENT
and candidate.entity_id != selected_numeric_id
),
not_required=(
record.actuator_entity_id.startswith(("light.", "switch."))
and has_opening_context
and (candidate.device_class or "") == "illuminance"
),
)
for candidate in ranked_candidates[:12]
]
actuator_group = RoomManagementActuator(
actuator_entity_id=record.actuator_entity_id,
friendly_name=actuator.friendly_name if actuator is not None else None,
domain=record.actuator_entity_id.split(".", 1)[0],
behavior_mode=record.behavior.mode.value,
behavior_status=record.behavior.status.value,
lifecycle_status=record.lifecycle.status.value,
sample_count=record.behavior.sample_count,
selected_numeric_entity_id=selected_numeric_id,
selected_context_entity_ids=record.assignment.selected_context_entity_ids,
sensors=sensors,
prediction_rules=_prediction_rule_lines(record, ranked_candidates),
management_hint=_management_hint(record, has_opening_context),
)
if room not in rooms:
rooms[room] = RoomManagementGroup(room=room, actuator_count=0)
rooms[room].actuators.append(actuator_group)
rooms[room].actuator_count += 1
rooms[room].prediction_rules = _unique_lines([
*rooms[room].prediction_rules,
*actuator_group.prediction_rules,
])[:8]
rooms[room].sensors = _merge_room_sensors(rooms[room].sensors, sensors)
unmanaged = [
suggestion for suggestion in suggest_actuators(request, service._ha_reader)
if suggestion.entity_id not in configured_ids
][:10]
return RoomManagementOverview(
rooms=sorted(rooms.values(), key=lambda item: item.room.lower()),
unmanaged_actuators=unmanaged,
)
@router.get("/backup/export", response_model=BackupPayload)
def export_backup(request: Request) -> BackupPayload:
store = getattr(request.app.state, "actuator_store", None)
@@ -922,6 +1057,220 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
def _candidate_room(record: ActuatorRecord) -> str | None:
for candidate in [*record.context_candidates, *record.numeric_candidates]:
if candidate.area_name:
return candidate.area_name
return None
def _rank_management_candidates(record: ActuatorRecord) -> list[AssignmentCandidate]:
selected_ids = {
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
}
candidates = {
candidate.entity_id: candidate
for candidate in [*record.context_candidates, *record.numeric_candidates]
}
ranked = sorted(
candidates.values(),
key=lambda item: (
item.entity_id not in selected_ids,
_management_sort_group(item),
-item.confidence,
-item.score,
item.entity_id,
),
)
return ranked
def _management_sort_group(candidate: AssignmentCandidate) -> str:
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
return "01_opening"
if device_class in {"motion", "occupancy", "presence"}:
return "02_presence"
if device_class == "illuminance":
return "03_brightness"
if device_class in {"humidity", "moisture"}:
return "04_humidity"
if candidate.role is EntityRole.MEASUREMENT:
return "08_measurement"
return f"20_{candidate.domain}_{device_class}"
def _management_sensor(
candidate: AssignmentCandidate,
entity: HaEntitySummary | None,
*,
active: bool,
optional: bool,
not_required: bool,
) -> RoomManagementSensor:
if not_required:
reason = "Nicht nötig, weil ein Tür-/Öffnungskontakt die Lichtlogik direkt erklärt."
elif active:
reason = "Wird aktuell für Lernen und Vorhersage verwendet."
elif optional:
reason = "Optionaler Messwert; nur verwenden, wenn Helligkeit oder Verbrauch wirklich steuern soll."
else:
reason = ", ".join(candidate.evidence[:2]) or "Naheliegender Kontext aus Raum, Gerät oder Namen."
return RoomManagementSensor(
entity_id=candidate.entity_id,
domain=candidate.domain,
role=candidate.role.value,
category=_sensor_category_label(candidate),
friendly_name=candidate.friendly_name,
device_class=candidate.device_class,
state=entity.state if entity is not None else None,
confidence=candidate.confidence,
active=active,
optional=optional,
not_required=not_required,
reason=reason,
)
def _sensor_category_label(candidate: AssignmentCandidate) -> str:
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
return "Tür/Fenster"
if device_class in {"motion", "occupancy", "presence"}:
return "Präsenz"
if device_class == "illuminance":
return "Helligkeit"
if device_class in {"humidity", "moisture"}:
return "Luftfeuchtigkeit"
if device_class in {"power", "energy", "current", "voltage"}:
return "Energie"
if candidate.domain in {"cover"}:
return "Rollo/Cover"
if candidate.domain in {"zone", "person", "device_tracker"}:
return "Zone/Person"
return "Kontext"
def _merge_room_sensors(
existing: list[RoomManagementSensor],
incoming: list[RoomManagementSensor],
) -> list[RoomManagementSensor]:
by_id = {sensor.entity_id: sensor for sensor in existing}
for sensor in incoming:
current = by_id.get(sensor.entity_id)
if current is None:
by_id[sensor.entity_id] = sensor
continue
by_id[sensor.entity_id] = current.model_copy(
update={
"active": current.active or sensor.active,
"optional": current.optional and sensor.optional,
"not_required": current.not_required and sensor.not_required,
"confidence": max(current.confidence, sensor.confidence),
}
)
return sorted(
by_id.values(),
key=lambda item: (
not item.active,
item.not_required,
item.category,
item.friendly_name or item.entity_id,
),
)[:18]
def _prediction_rule_lines(
record: ActuatorRecord,
candidates: list[AssignmentCandidate],
) -> list[str]:
lines = _pattern_rule_lines(record.behavior.patterns)
if lines:
return lines[:8]
selected_contexts = [
candidate
for candidate in candidates
if candidate.entity_id in set(record.assignment.selected_context_entity_ids)
]
result: list[str] = []
for candidate in selected_contexts:
label = candidate.friendly_name or candidate.entity_id
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
result.extend([
f"{label} geöffnet -> {record.actuator_entity_id} an.",
f"{label} geschlossen -> {record.actuator_entity_id} aus.",
])
elif device_class in {"motion", "occupancy", "presence"}:
result.extend([
f"{label} erkannt -> {record.actuator_entity_id} an, bei Licht bevorzugt gedimmt.",
f"{label} aus -> {record.actuator_entity_id} verzögert ausschalten.",
])
elif device_class in {"humidity", "moisture"}:
result.append(f"{label} hoch -> {record.actuator_entity_id} einschalten, bis Feuchte wieder normal ist.")
if record.assignment.selected_numeric_entity_id:
result.append(
f"{record.assignment.selected_numeric_entity_id} nur als Messwert verwenden, nicht als Pflichtsensor."
)
return _unique_lines(result)[:8] or ["Noch keine stabile Vorhersage; erst Kontext prüfen und weiter beobachten."]
def _pattern_rule_lines(patterns: list[BehaviorPattern]) -> list[str]:
buckets: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {}
attrs: dict[tuple[str, tuple[tuple[str, str], ...]], dict[str, object]] = {}
for pattern in patterns[-120:]:
context = tuple(sorted(pattern.context_states.items()))
key = (pattern.target_state, context)
buckets[key] = buckets.get(key, 0) + 1
attrs[key] = pattern.target_attributes
ordered = sorted(buckets.items(), key=lambda item: (-item[1], item[0]))
lines: list[str] = []
for (target_state, context), count in ordered[:8]:
conditions = ", ".join(f"{entity}={state}" for entity, state in context[:3])
if not conditions:
conditions = "aktueller Zeit-/Nutzungskontext passt"
attr_text = _attribute_text(attrs.get((target_state, context), {}))
lines.append(f"{conditions} -> {target_state}{attr_text} ({count}x gelernt).")
return lines
def _attribute_text(attributes: dict[str, object]) -> str:
if not attributes:
return ""
brightness = attributes.get("brightness")
if isinstance(brightness, int | float):
percent = round(max(0, min(255, float(brightness))) / 255 * 100)
return f", Helligkeit {percent} %"
return ""
def _management_hint(record: ActuatorRecord, has_opening_context: bool) -> str:
if has_opening_context and record.actuator_entity_id.startswith(("light.", "switch.")):
return "Direkte Türlogik: kein Helligkeitssensor nötig, Sensor und Aktor reichen."
if record.behavior.activation_ready:
return "Regeln sind lernbereit; vor Aktivierung Vorhersagen prüfen."
if record.assignment.review_required:
return "Kontext prüfen: Vorschläge übernehmen oder unpassende Sensoren entfernen."
return "Weiter beobachten, bis genug eindeutige Schaltbeispiele vorhanden sind."
def _unique_lines(lines: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for line in lines:
normalized = line.strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
result.append(normalized)
return result
def _validate_simulation_payload(payload: SimulationRequest) -> None:
for entity_id in [
*payload.sensor_states.keys(),

View File

@@ -100,6 +100,14 @@
.card-title .chip { flex:0 1 auto; white-space:normal; text-align:center; }
.actuator-card strong { overflow-wrap:anywhere; word-break:break-word; }
.entity-id { overflow-wrap:anywhere; word-break:break-word; font-weight:800; }
.room-board { display:grid; gap:10px; margin-top:12px; }
.room-card { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; }
.room-card header { padding:0; border:0; background:transparent; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.sensor-strip { display:flex; flex-wrap:wrap; gap:6px; margin:8px 0; }
.sensor-pill { border:1px solid var(--border); border-radius:8px; padding:6px 8px; background:#17202b; max-width:100%; overflow-wrap:anywhere; }
.sensor-pill.active { border-color:var(--ok); }
.sensor-pill.optional { border-color:var(--complement); }
.sensor-pill.not-required { opacity:.72; border-style:dashed; }
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
.metric { background:var(--panel-soft); border:1px solid var(--border); border-radius:8px; padding:8px; min-width:0; overflow-wrap:anywhere; word-break:break-word; }
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
@@ -246,8 +254,9 @@
<div class="panel-title">
<div>
<h2>Einstellungen</h2>
<p class="muted">Sprache und Standardwerte für die Bedienoberfläche.</p>
<p class="muted">Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort.</p>
</div>
<button class="secondary compact" onclick="loadRoomManagement(true)">Räume laden</button>
</div>
<div class="grid-two">
<div>
@@ -257,6 +266,13 @@
<option value="en">English</option>
</select>
</div>
<div>
<label for="room-management-filter">Raum suchen</label>
<input id="room-management-filter" placeholder="z. B. Abstellkammer, Wohnbereich" oninput="renderRoomManagement()">
</div>
</div>
<div id="room-management" class="room-board">
<div class="empty-state">Lade die Raumverwaltung, um Sensoren, Aktoren und Vorhersagen zusammen zu pflegen.</div>
</div>
</section>
</main>
@@ -276,6 +292,7 @@ let cachedEntities = null;
let cachedDiscovery = null;
let cachedSystemOverview = null;
let cachedDashboardOverview = null;
let cachedRoomManagement = null;
let cachedDetailHtml = new Map();
let discoveryLoadPromise = null;
let overviewLoadPromise = null;
@@ -334,8 +351,12 @@ const I18N = {
check_status: "Status prüfen",
checking: "Prüfung läuft ...",
settings: "Einstellungen",
settings_hint: "Sprache und Standardwerte für die Bedienoberfläche.",
settings_hint: "Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort.",
language: "Sprache",
load_rooms: "Räume laden",
room_search: "Raum suchen",
room_search_placeholder: "z. B. Abstellkammer, Wohnbereich",
room_management_empty: "Lade die Raumverwaltung, um Sensoren, Aktoren und Vorhersagen zusammen zu pflegen.",
no_prediction: "Keine fällige Aktion",
open: "offen",
no_area: "Ohne Bereich",
@@ -469,8 +490,12 @@ const I18N = {
check_status: "Check status",
checking: "Checking ...",
settings: "Settings",
settings_hint: "Language and UI defaults.",
settings_hint: "Language, rooms, sensors, actuators, and predictions in one place.",
language: "Language",
load_rooms: "Load rooms",
room_search: "Search room",
room_search_placeholder: "e.g. storage room, living area",
room_management_empty: "Load room management to maintain sensors, actuators, and predictions together.",
no_prediction: "No due action",
open: "open",
no_area: "No area",
@@ -767,6 +792,7 @@ function showView(viewId) {
if (cachedDiscovery) renderActuatorDiscovery();
} else if (viewId === "settings") {
syncSettingsView();
void loadRoomManagement();
}
document.getElementById(viewId)?.scrollIntoView({behavior: "smooth", block: "start"});
}
@@ -790,6 +816,9 @@ function setLanguage(language) {
function syncSettingsView() {
const select = document.getElementById("language-select");
if (select) select.value = uiLang;
if (cachedRoomManagement) {
renderRoomManagement();
}
}
function translate(group, value, fallback = "") {
@@ -939,6 +968,14 @@ function applyStaticTranslations() {
setText("#settings h2", "settings");
setText("#settings .muted", "settings_hint");
setText("label[for='language-select']", "language");
const roomLoadButton = document.querySelector("#settings .panel-title button");
if (roomLoadButton) roomLoadButton.textContent = ui("load_rooms");
setText("label[for='room-management-filter']", "room_search");
setPlaceholder("#room-management-filter", "room_search_placeholder");
const roomManagement = document.getElementById("room-management");
if (roomManagement && !cachedRoomManagement) {
roomManagement.innerHTML = `<div class="empty-state">${escapeHtml(ui("room_management_empty"))}</div>`;
}
localizeFragment(document);
}
@@ -960,6 +997,7 @@ function invalidateDashboardCache() {
cachedDiscovery = null;
cachedDashboardOverview = null;
cachedSystemOverview = null;
cachedRoomManagement = null;
cachedDetailHtml.clear();
}
@@ -1343,6 +1381,168 @@ function renderJobQueue(jobs) {
localizeFragment(jobsBox);
}
async function loadRoomManagement(force = false) {
const box = document.getElementById("room-management");
if (!box) return;
if (cachedRoomManagement && !force) {
renderRoomManagement();
return;
}
box.innerHTML = `<p class='muted'>${escapeHtml(uiLang === "en" ? "Loading rooms ..." : "Räume werden geladen ...")}</p>`;
try {
cachedRoomManagement = await api("v1/actuators/settings/rooms");
renderRoomManagement();
} catch (error) {
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
}
localizeFragment(box);
}
function renderRoomManagement() {
const box = document.getElementById("room-management");
if (!box) return;
const overview = cachedRoomManagement;
if (!overview) {
box.innerHTML = `<div class="empty-state">${escapeHtml(ui("room_management_empty"))}</div>`;
return;
}
const query = normalizedSearch(document.getElementById("room-management-filter")?.value || "");
const rooms = (overview.rooms || []).filter(room =>
!query
|| normalizedSearch(room.room).includes(query)
|| (room.actuators || []).some(actuator =>
matchesSearch({
entity_id: actuator.actuator_entity_id,
friendly_name: actuator.friendly_name,
area_name: room.room,
}, query)
)
);
const unmanaged = overview.unmanaged_actuators || [];
box.innerHTML = `
${rooms.length ? rooms.map(room => `
<article class="room-card">
<header>
<div>
<h3>${escapeHtml(room.room)}</h3>
<p class="muted">${escapeHtml(room.actuator_count)} ${escapeHtml(uiLang === "en" ? "actuator(s)" : "Aktor(en)")}</p>
</div>
<span class="chip">${escapeHtml((room.sensors || []).filter(sensor => sensor.active).length)} ${escapeHtml(uiLang === "en" ? "active sensors" : "aktive Sensoren")}</span>
</header>
<div class="sensor-strip">
${(room.sensors || []).slice(0, 12).map(sensor => sensorPill(sensor)).join("") || `<span class="muted">${escapeHtml(uiLang === "en" ? "No sensors selected yet." : "Noch keine Sensoren ausgewählt.")}</span>`}
</div>
<div class="decision-list">
${(room.prediction_rules || []).slice(0, 5).map(rule => `<div class="decision-row">${escapeHtml(rule)}</div>`).join("")}
</div>
<div class="card-list">
${(room.actuators || []).map(actuator => roomActuatorCard(actuator)).join("")}
</div>
</article>
`).join("") : `<div class="empty-state">${escapeHtml(uiLang === "en" ? "No matching rooms." : "Keine passenden Räume.")}</div>`}
${unmanaged.length ? `
<article class="room-card">
<header>
<h3>${escapeHtml(uiLang === "en" ? "Unmanaged suggestions" : "Noch nicht verwaltete Vorschläge")}</h3>
<span class="chip">${escapeHtml(unmanaged.length)}</span>
</header>
<div class="card-list">
${unmanaged.slice(0, 6).map(item => `
<article class="actuator-card">
<div class="card-title">
<div>
<strong>${escapeHtml(item.friendly_name || item.entity_id)}</strong>
<div class="entity-id">${escapeHtml(item.entity_id)}</div>
<div class="muted">${escapeHtml(item.area_name || item.device_name || item.domain)}</div>
</div>
<span class="chip">${Math.round(item.confidence * 100)} %</span>
</div>
<p class="muted">${escapeHtml(item.reason)}</p>
<button class="secondary" onclick="configureSuggestedActuator('${escapeHtml(item.entity_id)}')">${escapeHtml(uiLang === "en" ? "Manage" : "Verwalten")}</button>
</article>
`).join("")}
</div>
</article>
` : ""}
`;
localizeFragment(box);
}
function sensorPill(sensor) {
const classes = [
"sensor-pill",
sensor.active ? "active" : "",
sensor.optional ? "optional" : "",
sensor.not_required ? "not-required" : "",
].filter(Boolean).join(" ");
const state = sensor.state == null ? "" : ` · ${sensor.state}`;
const status = sensor.not_required
? (uiLang === "en" ? "not needed" : "nicht nötig")
: sensor.active
? (uiLang === "en" ? "active" : "aktiv")
: sensor.optional
? (uiLang === "en" ? "optional" : "optional")
: (uiLang === "en" ? "suggested" : "Vorschlag");
return `
<span class="${classes}" title="${escapeHtml(sensor.reason)}">
<strong>${escapeHtml(sensor.friendly_name || sensor.entity_id)}</strong>
<span class="muted"> · ${escapeHtml(sensor.category)} · ${escapeHtml(status)}${escapeHtml(state)}</span>
</span>
`;
}
function roomActuatorCard(actuator) {
const activeSensorIds = (actuator.sensors || [])
.filter(sensor => sensor.active && !sensor.not_required)
.map(sensor => sensor.entity_id);
const selectedContextIds = (actuator.sensors || [])
.filter(sensor => sensor.active && sensor.entity_id !== actuator.selected_numeric_entity_id)
.map(sensor => sensor.entity_id);
return `
<article class="actuator-card">
<div class="card-title">
<div>
<strong>${escapeHtml(actuator.friendly_name || actuator.actuator_entity_id)}</strong>
<div class="entity-id">${escapeHtml(actuator.actuator_entity_id)}</div>
<div class="muted">${escapeHtml(behaviorLabel(actuator))} · ${escapeHtml(lifecycleLabel(actuator))}</div>
</div>
<span class="chip">${escapeHtml(actuator.sample_count)} ${escapeHtml(uiLang === "en" ? "actions" : "Handlungen")}</span>
</div>
<p class="muted">${escapeHtml(actuator.management_hint)}</p>
<div class="sensor-strip">
${(actuator.sensors || []).slice(0, 8).map(sensor => sensorPill(sensor)).join("")}
</div>
<div class="decision-list">
${(actuator.prediction_rules || []).slice(0, 4).map(rule => `<div class="decision-row">${escapeHtml(rule)}</div>`).join("")}
</div>
<div class="actions">
<button class="secondary" onclick="showActuator('${escapeHtml(actuator.actuator_entity_id)}')">${escapeHtml(uiLang === "en" ? "Open details" : "Details öffnen")}</button>
<button onclick="saveRoomAssignment('${escapeHtml(actuator.actuator_entity_id)}', '${escapeHtml(actuator.selected_numeric_entity_id || "")}', '${escapeHtml(selectedContextIds.join(","))}')">${escapeHtml(uiLang === "en" ? "Save selection" : "Auswahl speichern")}</button>
</div>
<p class="muted">${escapeHtml(uiLang === "en" ? "Current selection:" : "Aktuelle Auswahl:")} ${escapeHtml(activeSensorIds.join(", ") || (uiLang === "en" ? "none" : "keine"))}</p>
</article>
`;
}
async function saveRoomAssignment(actuatorId, numericEntityId, contextCsv) {
const contextEntityIds = contextCsv.split(",").map(item => item.trim()).filter(Boolean);
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numericEntityId || null,
context_entity_ids: contextEntityIds,
note: "In der Raumverwaltung gespeichert",
}),
});
invalidateDashboardCache();
cachedRoomManagement = null;
await loadRoomManagement(true);
} catch (error) {
alert(error.message);
}
}
async function loadSummaryData() {
const actuators = await api("v1/actuators/summary");
cachedActuators = actuators;