diff --git a/CHANGELOG.md b/CHANGELOG.md index f9a619d..17a1c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.7.6 - 2026-07-26 +- Einstellungen um eine Raumverwaltung erweitert: Räume zeigen Aktoren, + aktive/optionale/nicht nötige Sensoren und lesbare Vorhersage-Regeln in + einer gemeinsamen Ansicht. +- Neue API `/v1/actuators/settings/rooms` liefert kompakte Verwaltungsdaten + für Raumkarten, Sensorvorschläge, Aktoren und noch nicht verwaltete + Vorschläge. +- Licht-/Schalter-Zuordnung darf bei eindeutigem Tür-/Öffnungskontext ohne + numerischen Helligkeitssensor arbeiten, z. B. Tür auf -> Licht an und Tür zu + -> Licht aus. + ## 1.7.5 - 2026-07-26 - Dashboard-Sprachumschaltung übersetzt jetzt auch dynamisch gerenderte Status-, Discovery-, Detail-, Listen-, Button- und Aufklapptexte. diff --git a/addon/config.yaml b/addon/config.yaml index f29fcd4..6a5337b 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "1.7.5" +version: "1.7.6" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/actuators/lifecycle.py b/app/actuators/lifecycle.py index 0ecc947..db02904 100644 --- a/app/actuators/lifecycle.py +++ b/app/actuators/lifecycle.py @@ -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( diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 13a19e3..a1f7133 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -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(), diff --git a/app/static/index.html b/app/static/index.html index ea4907f..5d4a563 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -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 @@

Einstellungen

-

Sprache und Standardwerte für die Bedienoberfläche.

+

Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort.

+
@@ -257,6 +266,13 @@
+
+ + +
+
+
+
Lade die Raumverwaltung, um Sensoren, Aktoren und Vorhersagen zusammen zu pflegen.
@@ -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 = `
${escapeHtml(ui("room_management_empty"))}
`; + } 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 = `

${escapeHtml(uiLang === "en" ? "Loading rooms ..." : "Räume werden geladen ...")}

`; + try { + cachedRoomManagement = await api("v1/actuators/settings/rooms"); + renderRoomManagement(); + } catch (error) { + box.innerHTML = `

${escapeHtml(error.message)}

`; + } + localizeFragment(box); +} + +function renderRoomManagement() { + const box = document.getElementById("room-management"); + if (!box) return; + const overview = cachedRoomManagement; + if (!overview) { + box.innerHTML = `
${escapeHtml(ui("room_management_empty"))}
`; + 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 => ` +
+
+
+

${escapeHtml(room.room)}

+

${escapeHtml(room.actuator_count)} ${escapeHtml(uiLang === "en" ? "actuator(s)" : "Aktor(en)")}

+
+ ${escapeHtml((room.sensors || []).filter(sensor => sensor.active).length)} ${escapeHtml(uiLang === "en" ? "active sensors" : "aktive Sensoren")} +
+
+ ${(room.sensors || []).slice(0, 12).map(sensor => sensorPill(sensor)).join("") || `${escapeHtml(uiLang === "en" ? "No sensors selected yet." : "Noch keine Sensoren ausgewählt.")}`} +
+
+ ${(room.prediction_rules || []).slice(0, 5).map(rule => `
${escapeHtml(rule)}
`).join("")} +
+
+ ${(room.actuators || []).map(actuator => roomActuatorCard(actuator)).join("")} +
+
+ `).join("") : `
${escapeHtml(uiLang === "en" ? "No matching rooms." : "Keine passenden Räume.")}
`} + ${unmanaged.length ? ` +
+
+

${escapeHtml(uiLang === "en" ? "Unmanaged suggestions" : "Noch nicht verwaltete Vorschläge")}

+ ${escapeHtml(unmanaged.length)} +
+
+ ${unmanaged.slice(0, 6).map(item => ` +
+
+
+ ${escapeHtml(item.friendly_name || item.entity_id)} +
${escapeHtml(item.entity_id)}
+
${escapeHtml(item.area_name || item.device_name || item.domain)}
+
+ ${Math.round(item.confidence * 100)} % +
+

${escapeHtml(item.reason)}

+ +
+ `).join("")} +
+
+ ` : ""} + `; + 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 ` + + ${escapeHtml(sensor.friendly_name || sensor.entity_id)} + · ${escapeHtml(sensor.category)} · ${escapeHtml(status)}${escapeHtml(state)} + + `; +} + +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 ` +
+
+
+ ${escapeHtml(actuator.friendly_name || actuator.actuator_entity_id)} +
${escapeHtml(actuator.actuator_entity_id)}
+
${escapeHtml(behaviorLabel(actuator))} · ${escapeHtml(lifecycleLabel(actuator))}
+
+ ${escapeHtml(actuator.sample_count)} ${escapeHtml(uiLang === "en" ? "actions" : "Handlungen")} +
+

${escapeHtml(actuator.management_hint)}

+
+ ${(actuator.sensors || []).slice(0, 8).map(sensor => sensorPill(sensor)).join("")} +
+
+ ${(actuator.prediction_rules || []).slice(0, 4).map(rule => `
${escapeHtml(rule)}
`).join("")} +
+
+ + +
+

${escapeHtml(uiLang === "en" ? "Current selection:" : "Aktuelle Auswahl:")} ${escapeHtml(activeSensorIds.join(", ") || (uiLang === "en" ? "none" : "keine"))}

+
+ `; +} + +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; diff --git a/pyproject.toml b/pyproject.toml index 4aef309..3e32196 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "1.7.5" +version = "1.7.6" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/actuators/test_lifecycle.py b/tests/actuators/test_lifecycle.py index da7af5f..d1cda7f 100644 --- a/tests/actuators/test_lifecycle.py +++ b/tests/actuators/test_lifecycle.py @@ -141,6 +141,46 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors +def test_light_with_opening_context_does_not_require_brightness_sensor(tmp_path: Path) -> None: + start = datetime.now(timezone.utc) - timedelta(days=1) + entities = [ + HaEntitySummary( + entity_id="light.abstellkammer", + domain="light", + friendly_name="Abstellkammer Licht", + area_name="Abstellkammer", + ), + HaEntitySummary( + entity_id="sensor.abstellkammer_illuminance", + domain="sensor", + device_class="illuminance", + state_class="measurement", + unit_of_measurement="lx", + friendly_name="Abstellkammer Helligkeit", + area_name="Abstellkammer", + ), + HaEntitySummary( + entity_id="binary_sensor.abstellkammer_tuer", + domain="binary_sensor", + device_class="door", + friendly_name="Tür Abstellkammer", + area_name="Abstellkammer", + ), + ] + service = _service( + tmp_path, + entities, + {"sensor.abstellkammer_illuminance": _points(8, start, 10.0)}, + ) + + record = service.configure_actuator("light.abstellkammer") + + assert record.assignment.selected_numeric_entity_id is None + assert record.assignment.selected_context_entity_ids == ["binary_sensor.abstellkammer_tuer"] + assert record.assignment.review_required is False + assert "kein Helligkeitssensor erforderlich" in record.assignment.reason + + def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None: start = datetime(2026, 6, 1, tzinfo=timezone.utc) entities = [ diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py index cc198bc..494bbe9 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -623,6 +623,25 @@ def test_dashboard_system_and_start_do_not_materialize_entity_cache( assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht" +def test_room_management_overview_groups_actuators_with_sensors_and_rules(tmp_path: Path) -> None: + with TestClient(app) as client: + _install_service(tmp_path) + client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) + + response = client.get("/v1/actuators/settings/rooms") + + assert response.status_code == 200 + payload = response.json() + room = payload["rooms"][0] + assert room["room"] == "Abstellkammer" + assert room["actuator_count"] == 1 + actuator = room["actuators"][0] + assert actuator["actuator_entity_id"] == "light.abstellkammer" + assert actuator["sensors"] + assert actuator["prediction_rules"] + assert any(sensor["entity_id"] == "binary_sensor.abstellkammer_motion" for sensor in room["sensors"]) + + def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None: with TestClient(app) as client: _install_service(tmp_path) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index df0c571..c9ab7e4 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -22,6 +22,10 @@ def test_dashboard_is_served_at_root() -> None: assert "Ohne deine spätere Freigabe wird nichts geschaltet" not in response.text assert "Du wählst keine Sensoren und erstellst keine Regeln" not in response.text assert "Freigabestatus" in response.text + assert "Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort." in response.text + assert "room-management" in response.text + assert 'api("v1/actuators/settings/rooms")' in response.text + assert "Auswahl speichern" in response.text assert "SillyHome übernehmen lassen" in response.text assert "Passende Home-Assistant-Automationen" in response.text assert "Pausieren" in response.text