diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a1c87..b9a941e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 1.7.7 - 2026-07-26 +- Raumverwaltung erzeugt jetzt eine vollständige Übersicht aus allen + Home-Assistant-Bereichen, nicht nur aus bereits konfigurierten Aktoren. +- Räume zeigen Sensoren, unverwaltete Aktoren und passende Handlungs- + Vorschläge für Licht, Strom, Schalter, Heizung, Wasser, Belüftung, + Sicherheit, Rollos und weitere steuerbare Geräte. +- Jede vorgeschlagene Handlung liefert Bedingung, Aktion, Begründung, + Sicherheit und Lernbarkeit, damit klar ist, was wann warum eintreten könnte. +- Startup- und geplante Reconciliation aktualisieren nun auch Evaluation und + Planungs-Insights kontinuierlich. + ## 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 diff --git a/addon/config.yaml b/addon/config.yaml index 6a5337b..25a799d 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "1.7.6" +version: "1.7.7" 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/api/v1/actuators.py b/app/api/v1/actuators.py index a1f7133..c25f95b 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -194,6 +194,19 @@ class RoomManagementSensor(BaseModel): reason: str +class RoomManagementAction(BaseModel): + action_id: str + category: str + actuator_entity_id: str + title: str + when: str + then: str + why: str + confidence: float = Field(default=0.0, ge=0.0, le=1.0) + learnable: bool = True + sort_key: str = "" + + class RoomManagementActuator(BaseModel): actuator_entity_id: str friendly_name: str | None = None @@ -206,15 +219,20 @@ class RoomManagementActuator(BaseModel): 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) + suggested_actions: list[RoomManagementAction] = Field(default_factory=list) management_hint: str class RoomManagementGroup(BaseModel): room: str actuator_count: int + sensor_count: int = 0 + action_count: int = 0 sensors: list[RoomManagementSensor] = Field(default_factory=list) actuators: list[RoomManagementActuator] = Field(default_factory=list) prediction_rules: list[str] = Field(default_factory=list) + suggested_actions: list[RoomManagementAction] = Field(default_factory=list) + continuous_hint: str = "Wird bei Discovery, Reconciliation und Lernrefresh automatisch neu bewertet." class RoomManagementOverview(BaseModel): @@ -511,8 +529,9 @@ def room_management_overview(request: Request) -> RoomManagementOverview: if entity_id }, ) + discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))} configured_ids = {record.actuator_entity_id for record in records} - rooms: dict[str, RoomManagementGroup] = {} + rooms = _build_room_shells(entities, discovered) for record in records: actuator = entities.get(record.actuator_entity_id) room = ( @@ -558,6 +577,12 @@ def room_management_overview(request: Request) -> RoomManagementOverview: selected_context_entity_ids=record.assignment.selected_context_entity_ids, sensors=sensors, prediction_rules=_prediction_rule_lines(record, ranked_candidates), + suggested_actions=_suggest_room_actions( + actuator_id=record.actuator_entity_id, + domain=record.actuator_entity_id.split(".", 1)[0], + sensors=sensors, + configured=True, + ), management_hint=_management_hint(record, has_opening_context), ) if room not in rooms: @@ -569,6 +594,52 @@ def room_management_overview(request: Request) -> RoomManagementOverview: *actuator_group.prediction_rules, ])[:8] rooms[room].sensors = _merge_room_sensors(rooms[room].sensors, sensors) + rooms[room].suggested_actions = _merge_room_actions( + rooms[room].suggested_actions, + actuator_group.suggested_actions, + ) + for entity_id, descriptor in discovered.items(): + if descriptor.role is not EntityRole.ACTUATOR or entity_id in configured_ids: + continue + actuator = entities.get(entity_id) + if actuator is None: + continue + room = _entity_room(actuator) + if room not in rooms: + rooms[room] = RoomManagementGroup(room=room, actuator_count=0) + room_sensors = _room_sensors_for_actuator(actuator, rooms[room].sensors) + actions = _suggest_room_actions( + actuator_id=entity_id, + domain=actuator.domain, + sensors=room_sensors, + configured=False, + ) + rooms[room].actuators.append( + RoomManagementActuator( + actuator_entity_id=entity_id, + friendly_name=actuator.friendly_name, + domain=actuator.domain, + behavior_mode="unmanaged", + behavior_status="suggested", + lifecycle_status="unconfigured", + sensors=room_sensors, + prediction_rules=[_action_rule_line(action) for action in actions[:5]], + suggested_actions=actions, + management_hint=( + "Noch nicht verwaltet: übernehmen, wenn diese Handlung gelernt oder vorgeschlagen werden soll." + ), + ) + ) + rooms[room].actuator_count += 1 + rooms[room].prediction_rules = _unique_lines([ + *rooms[room].prediction_rules, + *[_action_rule_line(action) for action in actions], + ])[:8] + rooms[room].suggested_actions = _merge_room_actions(rooms[room].suggested_actions, actions) + for room in rooms.values(): + room.sensors = _merge_room_sensors([], room.sensors) + room.sensor_count = len(room.sensors) + room.action_count = len(room.suggested_actions) unmanaged = [ suggestion for suggestion in suggest_actuators(request, service._ha_reader) if suggestion.entity_id not in configured_ids @@ -1057,6 +1128,119 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None: raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}") +def _build_room_shells( + entities: dict[str, HaEntitySummary], + discovered: dict[str, DiscoveredEntity], +) -> dict[str, RoomManagementGroup]: + rooms: dict[str, RoomManagementGroup] = {} + for entity in entities.values(): + room = _entity_room(entity) + if room not in rooms: + rooms[room] = RoomManagementGroup(room=room, actuator_count=0) + descriptor = discovered.get(entity.entity_id) + if descriptor is None or descriptor.role is EntityRole.ACTUATOR: + continue + sensor = _entity_management_sensor(entity, descriptor) + if sensor is not None: + rooms[room].sensors = _merge_room_sensors(rooms[room].sensors, [sensor]) + return rooms + + +def _entity_room(entity: HaEntitySummary) -> str: + room = entity.area_name or _room_from_text(entity.friendly_name or entity.device_name or entity.entity_id) + return room or "Ohne Raum" + + +def _room_from_text(value: str) -> str | None: + normalized = value.replace("_", " ").replace("-", " ").strip() + if not normalized: + return None + known_rooms = { + "abstellkammer": "Abstellkammer", + "abstellraum": "Abstellkammer", + "bad": "Bad", + "badezimmer": "Bad", + "buro": "Büro", + "buero": "Büro", + "flur": "Flur", + "gaeste wc": "Gäste WC", + "gaste wc": "Gäste WC", + "keller": "Keller", + "kuche": "Küche", + "kueche": "Küche", + "schlafzimmer": "Schlafzimmer", + "terrasse": "Terrasse", + "wohnbereich": "Wohnbereich", + "wohnzimmer": "Wohnbereich", + } + lowered = normalized.lower() + for token, room in known_rooms.items(): + if token in lowered: + return room + return None + + +def _entity_management_sensor( + entity: HaEntitySummary, + descriptor: DiscoveredEntity, +) -> RoomManagementSensor | None: + if descriptor.role not in {EntityRole.MEASUREMENT, EntityRole.BINARY_CONTEXT, EntityRole.CONTEXT}: + return None + candidate = AssignmentCandidate( + entity_id=entity.entity_id, + domain=entity.domain, + role=descriptor.role, + device_class=entity.device_class, + state_class=entity.state_class, + unit_of_measurement=entity.unit_of_measurement, + friendly_name=entity.friendly_name, + area_name=entity.area_name, + device_name=entity.device_name, + score=0.55, + confidence=0.55, + evidence=["Gehört laut Home Assistant zu diesem Bereich."], + ) + return _management_sensor( + candidate, + entity, + active=False, + optional=descriptor.role is EntityRole.MEASUREMENT, + not_required=False, + ) + + +def _room_sensors_for_actuator( + actuator: HaEntitySummary, + sensors: list[RoomManagementSensor], +) -> list[RoomManagementSensor]: + preferred = _preferred_sensor_categories(actuator.domain) + ranked = sorted( + sensors, + key=lambda sensor: ( + sensor.category not in preferred, + preferred.index(sensor.category) if sensor.category in preferred else 99, + -sensor.confidence, + sensor.friendly_name or sensor.entity_id, + ), + ) + return ranked[:12] + + +def _preferred_sensor_categories(domain: str) -> list[str]: + mapping = { + "climate": ["Temperatur", "Luftfeuchtigkeit", "Tür/Fenster", "Präsenz", "Energie"], + "cover": ["Helligkeit", "Präsenz", "Tür/Fenster", "Temperatur"], + "fan": ["Luftfeuchtigkeit", "Präsenz", "Temperatur", "Tür/Fenster", "Energie"], + "humidifier": ["Luftfeuchtigkeit", "Temperatur", "Präsenz"], + "light": ["Präsenz", "Tür/Fenster", "Helligkeit", "Zone/Person"], + "lock": ["Tür/Fenster", "Präsenz", "Zone/Person"], + "siren": ["Sicherheit", "Tür/Fenster", "Präsenz"], + "switch": ["Präsenz", "Tür/Fenster", "Energie", "Luftfeuchtigkeit", "Helligkeit"], + "valve": ["Wasser", "Luftfeuchtigkeit", "Temperatur", "Tür/Fenster"], + } + return mapping.get(domain, ["Präsenz", "Tür/Fenster", "Energie", "Kontext"]) + + def _candidate_room(record: ActuatorRecord) -> str | None: for candidate in [*record.context_candidates, *record.numeric_candidates]: if candidate.area_name: @@ -1147,8 +1331,14 @@ def _sensor_category_label(candidate: AssignmentCandidate) -> str: return "Helligkeit" if device_class in {"humidity", "moisture"}: return "Luftfeuchtigkeit" + if device_class == "temperature": + return "Temperatur" if device_class in {"power", "energy", "current", "voltage"}: return "Energie" + if device_class in {"gas", "water"} or candidate.unit_of_measurement in {"m3", "L", "l"}: + return "Wasser" + if device_class in {"problem", "safety", "smoke", "vibration"}: + return "Sicherheit" if candidate.domain in {"cover"}: return "Rollo/Cover" if candidate.domain in {"zone", "person", "device_tracker"}: @@ -1220,6 +1410,116 @@ def _prediction_rule_lines( return _unique_lines(result)[:8] or ["Noch keine stabile Vorhersage; erst Kontext prüfen und weiter beobachten."] +def _suggest_room_actions( + *, + actuator_id: str, + domain: str, + sensors: list[RoomManagementSensor], + configured: bool, +) -> list[RoomManagementAction]: + sensor_categories = {sensor.category for sensor in sensors} + sensor_labels = { + sensor.category: sensor.friendly_name or sensor.entity_id + for sensor in sensors + } + confidence_base = 0.78 if configured else 0.58 + actions: list[RoomManagementAction] = [] + + def add(category: str, title: str, when: str, then: str, why: str, confidence: float) -> None: + actions.append( + RoomManagementAction( + action_id=f"{actuator_id}:{category}:{len(actions)}", + category=category, + actuator_entity_id=actuator_id, + title=title, + when=when, + then=then, + why=why, + confidence=round(min(1.0, confidence), 4), + learnable=True, + sort_key=f"{category}:{actuator_id}:{len(actions):02d}", + ) + ) + + presence = sensor_labels.get("Präsenz") + opening = sensor_labels.get("Tür/Fenster") + brightness = sensor_labels.get("Helligkeit") + humidity = sensor_labels.get("Luftfeuchtigkeit") + temperature = sensor_labels.get("Temperatur") + energy = sensor_labels.get("Energie") + water = sensor_labels.get("Wasser") + safety = sensor_labels.get("Sicherheit") + zone = sensor_labels.get("Zone/Person") + + if domain == "light": + if opening: + add("licht", "Türlicht", f"{opening} öffnet oder schließt", "Licht passend an/aus schalten.", "Türkontakt erklärt kleine Räume ohne Helligkeitssensor.", confidence_base + 0.12) + if presence: + when = f"{presence} erkennt Anwesenheit" + if brightness: + when += f" und {brightness} ist dunkel" + add("licht", "Präsenzlicht", when, "Licht gedimmt einschalten und bei Abwesenheit verzögert ausschalten.", "Anwesenheit plus Helligkeit vermeidet unnötiges Licht.", confidence_base + (0.12 if brightness else 0.04)) + if zone: + add("licht", "Zonenstimmung", f"{zone} wird betreten oder verlassen", "Beim Betreten dimmen, beim Aufstehen heller/weiß stellen und später vorherige Stimmung wiederherstellen.", "Zonen wie Sofa brauchen andere Helligkeit als Durchgang oder Aktivität.", confidence_base) + elif domain in {"switch", "input_boolean"}: + if energy: + add("strom", "Verbrauchssteuerung", f"{energy} zeigt Standby oder Last", "Steckdose/Schalter bei Bedarf schalten oder Standby reduzieren.", "Stromwerte zeigen, ob ein Verbraucher wirklich gebraucht wird.", confidence_base + 0.1) + if presence: + add("strom", "Anwesenheitsschalter", f"{presence} aus", "Verbraucher verzögert ausschalten.", "Schalter und Steckdosen sollen Räume nicht unnötig versorgen.", confidence_base) + if opening: + add("schalter", "Kontaktlogik", f"{opening} wechselt", "Schalter passend zum Öffnen/Schließen setzen.", "Kontaktzustände sind direkte, leicht prüfbare Auslöser.", confidence_base) + elif domain == "climate": + if temperature: + add("heizung", "Temperaturregelung", f"{temperature} weicht vom Ziel ab", "Heizung nach Lernprofil anpassen.", "Temperaturverlauf und Anwesenheit erklären Heizbedarf.", confidence_base + 0.12) + if opening: + add("heizung", "Fenster-Offen-Schutz", f"{opening} offen", "Heizung pausieren oder Sollwert senken.", "Offene Fenster/Türen sollen nicht gegen die Heizung arbeiten.", confidence_base + 0.1) + if presence: + add("heizung", "Anwesenheitswärme", f"{presence} an/aus", "Komforttemperatur nur bei Nutzung halten.", "Anwesenheit macht Heizprofile einfacher und sparsamer.", confidence_base) + elif domain in {"fan", "humidifier"}: + if humidity: + add("belueftung", "Feuchteführung", f"{humidity} steigt oder bleibt hoch", "Lüftung/Entfeuchtung einschalten, später zurücknehmen.", "Feuchtigkeit ist der wichtigste Kontext für Lüftung.", confidence_base + 0.16) + if presence: + add("belueftung", "Nutzungsabhängige Lüftung", f"{presence} aktiv", "Lüftung leise/bedarfsgerecht führen.", "Nutzung erklärt Gerüche, Feuchte und Komfort.", confidence_base) + elif domain == "cover": + if brightness: + add("rollo", "Sonnen-/Dunkellogik", f"{brightness} sehr hell oder dunkel", "Rollo passend beschatten oder öffnen.", "Helligkeit steuert Blendung, Wärme und Tageslicht.", confidence_base + 0.12) + if presence: + add("rollo", "Privatsphäre", f"{presence} und Abend/Dunkelheit", "Rollo für Privatsphäre schließen.", "Anwesenheit und Lichtlage erklären Rollo-Bedarf.", confidence_base) + elif domain in {"valve"}: + if water or humidity: + add("wasser", "Wasser-/Leckschutz", f"{water or humidity} auffällig", "Ventil schließen oder Sperre vorschlagen.", "Wasser- und Feuchtesensoren sind Sicherheitskontext.", confidence_base + 0.14) + elif domain in {"lock", "siren"}: + if opening or safety: + add("sicherheit", "Sicherheitszustand", f"{opening or safety} meldet Änderung", "Sicherheitsaktion vorschlagen, aber nicht ohne Freigabe aktiv ausführen.", "Sicherheitsaktionen brauchen hohe Sicherheit und klare Erklärung.", confidence_base) + + if not actions: + add( + domain, + "Allgemeine Lernregel", + "passende Sensoren in diesem Raum ändern sich", + "Aktor im Shadow-Modus beobachten und Vorschläge sammeln.", + "Noch fehlen eindeutige Kontextsensoren; Discovery prüft den Raum weiter.", + max(0.35, confidence_base - 0.18), + ) + return sorted(actions, key=lambda item: (-item.confidence, item.sort_key))[:8] + + +def _merge_room_actions( + existing: list[RoomManagementAction], + incoming: list[RoomManagementAction], +) -> list[RoomManagementAction]: + by_key = {action.action_id: action for action in existing} + for action in incoming: + current = by_key.get(action.action_id) + if current is None or action.confidence > current.confidence: + by_key[action.action_id] = action + return sorted(by_key.values(), key=lambda item: (-item.confidence, item.sort_key))[:18] + + +def _action_rule_line(action: RoomManagementAction) -> str: + return f"{action.when} -> {action.then}" + + 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]] = {} diff --git a/app/main.py b/app/main.py index ec5206e..1bb01cf 100644 --- a/app/main.py +++ b/app/main.py @@ -117,7 +117,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="1.7.4", + version="1.7.7", lifespan=lifespan, ) app.state.settings = load_settings() @@ -167,6 +167,8 @@ async def _periodic_reconciliation(app: FastAPI) -> None: engine = getattr(app.state, "behavior_engine", None) if isinstance(engine, BehaviorEngine): await asyncio.to_thread(engine.train_all) + await asyncio.to_thread(engine.evaluate_all) + await asyncio.to_thread(engine.refresh_planning_insights) except Exception: logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.") @@ -221,6 +223,7 @@ async def _startup_reconciliation(app: FastAPI) -> None: await asyncio.to_thread(service.reconcile_all, "startup") await asyncio.to_thread(engine.train_all) await asyncio.to_thread(engine.evaluate_all) + await asyncio.to_thread(engine.refresh_planning_insights) logger.info("Startup-Reconciliation erfolgreich abgeschlossen.") return except Exception as exc: diff --git a/app/static/index.html b/app/static/index.html index 5d4a563..26b0111 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -1410,14 +1410,24 @@ function renderRoomManagement() { 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) - ) - ); + || (room.actuators || []).some(actuator => + matchesSearch({ + entity_id: actuator.actuator_entity_id, + friendly_name: actuator.friendly_name, + area_name: room.room, + }, query) + ) + || (room.sensors || []).some(sensor => + matchesSearch({ + entity_id: sensor.entity_id, + friendly_name: sensor.friendly_name, + area_name: room.room, + }, query) + ) + || (room.suggested_actions || []).some(action => + normalizedSearch(`${action.category} ${action.title} ${action.when} ${action.then}`).includes(query) + ) + ); const unmanaged = overview.unmanaged_actuators || []; box.innerHTML = ` ${rooms.length ? rooms.map(room => ` @@ -1425,9 +1435,9 @@ function renderRoomManagement() {

${escapeHtml(room.room)}

-

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

+

${escapeHtml(room.actuator_count)} ${escapeHtml(uiLang === "en" ? "actuator(s)" : "Aktor(en)")} · ${escapeHtml(room.sensor_count || 0)} ${escapeHtml(uiLang === "en" ? "sensor(s)" : "Sensor(en)")}

- ${escapeHtml((room.sensors || []).filter(sensor => sensor.active).length)} ${escapeHtml(uiLang === "en" ? "active sensors" : "aktive Sensoren")} + ${escapeHtml(room.action_count || 0)} ${escapeHtml(uiLang === "en" ? "actions" : "Handlungen")}
${(room.sensors || []).slice(0, 12).map(sensor => sensorPill(sensor)).join("") || `${escapeHtml(uiLang === "en" ? "No sensors selected yet." : "Noch keine Sensoren ausgewählt.")}`} @@ -1435,6 +1445,7 @@ function renderRoomManagement() {
${(room.prediction_rules || []).slice(0, 5).map(rule => `
${escapeHtml(rule)}
`).join("")}
+ ${roomActions(room.suggested_actions || [])}
${(room.actuators || []).map(actuator => roomActuatorCard(actuator)).join("")}
@@ -1515,6 +1526,7 @@ function roomActuatorCard(actuator) {
${(actuator.prediction_rules || []).slice(0, 4).map(rule => `
${escapeHtml(rule)}
`).join("")}
+ ${roomActions(actuator.suggested_actions || [])}
@@ -1524,6 +1536,21 @@ function roomActuatorCard(actuator) { `; } +function roomActions(actions) { + if (!actions.length) return ""; + return ` +
+ ${actions.slice(0, 6).map(action => ` +
+ ${escapeHtml(action.title)} + · ${escapeHtml(action.category)} · ${Math.round((action.confidence || 0) * 100)} %
+ ${escapeHtml(action.when)} -> ${escapeHtml(action.then)} +
+ `).join("")} +
+ `; +} + async function saveRoomAssignment(actuatorId, numericEntityId, contextCsv) { const contextEntityIds = contextCsv.split(",").map(item => item.trim()).filter(Boolean); try { diff --git a/pyproject.toml b/pyproject.toml index 3e32196..1447d80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "1.7.6" +version = "1.7.7" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" requires-python = ">=3.11" dependencies = [ diff --git a/tests/api/test_actuators.py b/tests/api/test_actuators.py index 494bbe9..60fbcae 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -127,6 +127,22 @@ def _install_service(tmp_path: Path) -> None: area_name="Abstellkammer", state="off", ), + HaEntitySummary( + entity_id="fan.bad_luefter", + domain="fan", + friendly_name="Bad Lüfter", + area_name="Bad", + ), + HaEntitySummary( + entity_id="sensor.bad_luftfeuchtigkeit", + domain="sensor", + device_class="humidity", + state_class="measurement", + unit_of_measurement="%", + friendly_name="Bad Luftfeuchtigkeit", + area_name="Bad", + state="68", + ), HaEntitySummary( entity_id="sensor.pfsense_interface_vpn_inbytes", domain="sensor", @@ -516,7 +532,7 @@ def test_dashboard_overview_uses_cache_without_ha_roundtrip(tmp_path: Path) -> N assert reader.read_entities_calls == calls_before payload = response.json() assert payload["cache"]["available"] is True - assert payload["cache"]["entity_count"] == 4 + assert payload["cache"]["entity_count"] == 6 assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht" assert payload["discovery_groups"] assert payload["jobs"]["jobs"][-1]["kind"] == "discovery" @@ -618,7 +634,7 @@ def test_dashboard_system_and_start_do_not_materialize_entity_cache( assert system_response.status_code == 200 assert system_response.json()["actuators"] == [] - assert system_response.json()["cache"]["entity_count"] == 4 + assert system_response.json()["cache"]["entity_count"] == 6 assert start_response.status_code == 200 assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht" @@ -639,8 +655,15 @@ def test_room_management_overview_groups_actuators_with_sensors_and_rules(tmp_pa assert actuator["actuator_entity_id"] == "light.abstellkammer" assert actuator["sensors"] assert actuator["prediction_rules"] + assert room["suggested_actions"] + assert room["sensor_count"] >= 2 assert any(sensor["entity_id"] == "binary_sensor.abstellkammer_motion" for sensor in room["sensors"]) + bad = next(item for item in payload["rooms"] if item["room"] == "Bad") + assert bad["actuator_count"] == 1 + assert bad["actuators"][0]["lifecycle_status"] == "unconfigured" + assert any(action["category"] == "belueftung" for action in bad["suggested_actions"]) + def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None: with TestClient(app) as client: