${escapeHtml(room.room)}
+${escapeHtml(room.actuator_count)} ${escapeHtml(uiLang === "en" ? "actuator(s)" : "Aktor(en)")}
+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 @@
Sprache und Standardwerte für die Bedienoberfläche.
+Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort.
${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(room.actuator_count)} ${escapeHtml(uiLang === "en" ? "actuator(s)" : "Aktor(en)")}
+${escapeHtml(item.reason)}
+ +${escapeHtml(actuator.management_hint)}
+${escapeHtml(uiLang === "en" ? "Current selection:" : "Aktuelle Auswahl:")} ${escapeHtml(activeSensorIds.join(", ") || (uiLang === "en" ? "none" : "keine"))}
+