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.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.room)}
-