diff --git a/addon/config.yaml b/addon/config.yaml
index 66336d0..cee6b68 100644
--- a/addon/config.yaml
+++ b/addon/config.yaml
@@ -1,5 +1,5 @@
name: SillyHome Next
-version: "1.7.0"
+version: "1.7.1"
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/models.py b/app/actuators/models.py
index 2d74730..3bad3cd 100644
--- a/app/actuators/models.py
+++ b/app/actuators/models.py
@@ -154,6 +154,19 @@ class DecisionFactor(BaseModel):
evidence: list[str] = Field(default_factory=list)
+class SimulationOutcome(BaseModel):
+ scenario_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
+ actuator_entity_id: str
+ sensor_states: dict[str, str] = Field(default_factory=dict)
+ sensor_weights: dict[str, float] = Field(default_factory=dict)
+ prediction: BehaviorPrediction | None = None
+ decision_factors: list[DecisionFactor] = Field(default_factory=list)
+ would_execute: bool = False
+ blockers: list[str] = Field(default_factory=list)
+ score: float = Field(default=0.0, ge=0.0, le=1.0)
+ recommendation: str = Field(default="", max_length=700)
+
+
class DecisionTrace(BaseModel):
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py
index f26f8a8..13a19e3 100644
--- a/app/api/v1/actuators.py
+++ b/app/api/v1/actuators.py
@@ -10,7 +10,14 @@ from pydantic import BaseModel, Field
from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService
-from app.actuators.models import ActuatorRecord, AnomalyEvent, FeedbackKind, ReconciliationState, SensorWeightGroup
+from app.actuators.models import (
+ ActuatorRecord,
+ AnomalyEvent,
+ FeedbackKind,
+ ReconciliationState,
+ SensorWeightGroup,
+ SimulationOutcome,
+)
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
@@ -52,6 +59,14 @@ class WeightOverrideRequest(BaseModel):
note: str | None = Field(default=None, max_length=500)
+class SimulationRequest(BaseModel):
+ sensor_states: dict[str, str] = Field(default_factory=dict)
+ sensor_weights: dict[str, float] = Field(default_factory=dict)
+ state_options: dict[str, list[str]] = Field(default_factory=dict)
+ include_current: bool = True
+ max_results: int = Field(default=8, ge=1, le=20)
+
+
class FeedbackRequest(BaseModel):
correct: bool
expected_state: str | None = Field(default=None, max_length=100)
@@ -567,6 +582,28 @@ def evaluate_actuator(
raise HTTPException(status_code=404, detail=str(exc)) from exc
+@router.post("/{actuator_entity_id}/simulate", response_model=list[SimulationOutcome])
+def simulate_actuator(
+ actuator_entity_id: str,
+ payload: SimulationRequest,
+ request: Request,
+) -> list[SimulationOutcome]:
+ try:
+ _validate_simulation_payload(payload)
+ return _behavior(request).simulate(
+ actuator_entity_id,
+ sensor_states=payload.sensor_states,
+ sensor_weights=payload.sensor_weights,
+ state_options=payload.state_options,
+ include_current=payload.include_current,
+ max_results=payload.max_results,
+ )
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+
+
@router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord)
def record_feedback(
actuator_entity_id: str,
@@ -885,6 +922,22 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
+def _validate_simulation_payload(payload: SimulationRequest) -> None:
+ for entity_id in [
+ *payload.sensor_states.keys(),
+ *payload.sensor_weights.keys(),
+ *payload.state_options.keys(),
+ ]:
+ if "." not in entity_id:
+ raise ValueError(f"Ungültige Entity-ID: {entity_id}")
+ for entity_id, weight in payload.sensor_weights.items():
+ if not 0.0 <= weight <= 1.0:
+ raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}")
+ for entity_id, states in payload.state_options.items():
+ if not states:
+ raise ValueError(f"Keine Zustände für {entity_id} angegeben.")
+
+
def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
diff --git a/app/behavior/engine.py b/app/behavior/engine.py
index c6c7c89..cd7ebd4 100644
--- a/app/behavior/engine.py
+++ b/app/behavior/engine.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
+from itertools import product
from collections.abc import Sequence
from datetime import datetime, timedelta, timezone
from time import perf_counter
@@ -29,6 +30,7 @@ from app.actuators.models import (
SafetyProfile,
SafetyStage,
SceneSuggestion,
+ SimulationOutcome,
TimeProfile,
)
from app.actuators.store import ActuatorStore
@@ -333,6 +335,7 @@ class BehaviorEngine:
record.behavior.patterns,
current_context=current_context,
current_context_changed_at=current_context_changed_at,
+ context_weights=_context_weights_for(record),
now=now,
min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes,
@@ -514,6 +517,116 @@ class BehaviorEngine:
)
return self._save_behavior(record, behavior)
+ def simulate(
+ self,
+ actuator_entity_id: str,
+ *,
+ sensor_states: dict[str, str],
+ sensor_weights: dict[str, float],
+ state_options: dict[str, list[str]],
+ max_results: int,
+ include_current: bool = True,
+ ) -> list[SimulationOutcome]:
+ record = self._store.get(actuator_entity_id)
+ now = datetime.now(timezone.utc)
+ current_entities = self._ha_reader.read_entities()
+ entities = {entity.entity_id: entity for entity in current_entities}
+ actuator = entities.get(actuator_entity_id)
+ if actuator is None:
+ raise KeyError("Aktor ist aktuell nicht in Home Assistant verfügbar.")
+ selected_context_ids = [
+ entity_id
+ for entity_id in [
+ record.assignment.selected_numeric_entity_id,
+ *record.assignment.selected_context_entity_ids,
+ ]
+ if entity_id
+ ]
+ if not selected_context_ids:
+ return []
+ base_context = {
+ entity_id: entities[entity_id].state
+ for entity_id in selected_context_ids
+ if entity_id in entities and entities[entity_id].state is not None
+ }
+ base_changed_at = {
+ entity_id: entities[entity_id].last_changed
+ for entity_id in base_context
+ }
+ context_weights = _context_weights_for(record)
+ for entity_id, weight in sensor_weights.items():
+ if entity_id in selected_context_ids:
+ context_weights[entity_id] = max(0.0, min(1.0, weight))
+ scenarios = _simulation_contexts(
+ base_context,
+ sensor_states=sensor_states,
+ state_options=state_options,
+ selected_context_ids=selected_context_ids,
+ include_current=include_current,
+ )
+ outcomes: list[SimulationOutcome] = []
+ for index, context in enumerate(scenarios[:64], start=1):
+ prediction_context: dict[str, str | None] = dict(context)
+ changed_at = dict(base_changed_at)
+ for entity_id, state in context.items():
+ if base_context.get(entity_id) != state:
+ changed_at[entity_id] = now
+ prediction = predict_behavior(
+ record.behavior.patterns,
+ current_context=prediction_context,
+ current_context_changed_at=changed_at,
+ context_weights=context_weights,
+ now=now,
+ min_support=self._settings.min_behavior_actions,
+ window_minutes=self._settings.prediction_window_minutes,
+ causal_window_seconds=self._settings.prediction_interval_seconds * 2,
+ timezone_name=self._settings.timezone,
+ )
+ if prediction is not None:
+ would_execute, blockers = self._assess_safety(record, actuator.state, prediction, now)
+ recommendation = (
+ f"Bestes Szenario: {prediction.target_state} mit {prediction.confidence:.0%}."
+ if would_execute
+ else (
+ f"Vorhersage {prediction.target_state} mit {prediction.confidence:.0%}, "
+ "aber blockiert: " + " ".join(blockers)
+ )
+ )
+ else:
+ would_execute = False
+ blockers = ["Keine fällige Vorhersage."]
+ recommendation = "Dieses Szenario erzeugt keine fällige Vorhersage."
+ outcomes.append(
+ SimulationOutcome(
+ scenario_id=f"scenario-{index}",
+ actuator_entity_id=actuator_entity_id,
+ sensor_states=context,
+ sensor_weights={
+ entity_id: round(context_weights.get(entity_id, 1.0), 4)
+ for entity_id in context
+ },
+ prediction=prediction,
+ decision_factors=_decision_factors_for(
+ record,
+ prediction_context,
+ prediction,
+ context_weights=context_weights,
+ ),
+ would_execute=would_execute,
+ blockers=blockers,
+ score=round(prediction.confidence if prediction is not None else 0.0, 4),
+ recommendation=recommendation,
+ )
+ )
+ return sorted(
+ outcomes,
+ key=lambda item: (
+ item.prediction is None,
+ -item.score,
+ item.scenario_id,
+ ),
+ )[:max_results]
+
def record_feedback(
self,
actuator_entity_id: str,
@@ -1379,15 +1492,21 @@ def _decision_factors_for(
record: ActuatorRecord,
current_context: dict[str, str | None],
prediction: BehaviorPrediction | None,
+ *,
+ context_weights: dict[str, float] | None = None,
) -> list[DecisionFactor]:
factors: list[DecisionFactor] = []
+ weights = context_weights or {}
candidates = {
candidate.entity_id: candidate
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
for entity_id, state in current_context.items():
candidate = candidates.get(entity_id)
- weight = candidate.effective_weight if candidate is not None else 1.0
+ weight = weights.get(
+ entity_id,
+ candidate.effective_weight if candidate is not None else 1.0,
+ )
relevance = candidate.confidence if candidate is not None else 0.5
contribution = round(min(1.0, weight * relevance), 4)
factors.append(
@@ -1423,6 +1542,62 @@ def _decision_factors_for(
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
+def _context_weights_for(record: ActuatorRecord) -> dict[str, float]:
+ weights = {
+ candidate.entity_id: candidate.effective_weight
+ for candidate in [*record.numeric_candidates, *record.context_candidates]
+ }
+ override = record.manual_override
+ if override is not None:
+ for entity_id, weight in override.sensor_weights.items():
+ weights[entity_id] = max(0.0, min(1.0, weight))
+ for group in override.sensor_weight_groups:
+ for entity_id in group.entity_ids:
+ weights[entity_id] = max(0.0, min(1.0, group.weight))
+ return weights
+
+
+def _simulation_contexts(
+ base_context: dict[str, str | None],
+ *,
+ sensor_states: dict[str, str],
+ state_options: dict[str, list[str]],
+ selected_context_ids: list[str],
+ include_current: bool,
+) -> list[dict[str, str]]:
+ selected = set(selected_context_ids)
+ base = {
+ entity_id: state
+ for entity_id, state in base_context.items()
+ if entity_id in selected and state is not None
+ }
+ for entity_id, state in sensor_states.items():
+ if entity_id in selected:
+ base[entity_id] = state
+ option_items = [
+ (
+ entity_id,
+ list(dict.fromkeys(state for state in states if state))[:6],
+ )
+ for entity_id, states in state_options.items()
+ if entity_id in selected and states
+ ][:6]
+ contexts: list[dict[str, str]] = []
+ if include_current or not option_items:
+ contexts.append(dict(base))
+ if option_items:
+ keys = [item[0] for item in option_items]
+ value_lists = [item[1] for item in option_items]
+ for values in product(*value_lists):
+ context = dict(base)
+ context.update(dict(zip(keys, values, strict=True)))
+ if context not in contexts:
+ contexts.append(context)
+ if len(contexts) >= 64:
+ break
+ return contexts
+
+
def _knowledge_lines(
record: ActuatorRecord,
sample_count: int,
@@ -1756,6 +1931,7 @@ def predict_behavior(
min_support: int,
window_minutes: int,
current_context_changed_at: dict[str, datetime | None] | None = None,
+ context_weights: dict[str, float] | None = None,
causal_window_seconds: int = 120,
timezone_name: str = "Europe/Berlin",
) -> BehaviorPrediction | None:
@@ -1786,14 +1962,10 @@ def predict_behavior(
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
- context_score = (
- sum(
- current_context[entity_id] == expected
- for entity_id, expected in comparable
- )
- / len(comparable)
- if comparable
- else 0.5
+ context_score = _weighted_context_score(
+ comparable,
+ current_context,
+ context_weights or {},
)
score = pattern.weight * (0.85 + 0.15 * context_score)
by_state.setdefault(pattern.target_state, []).append(score)
@@ -1817,11 +1989,10 @@ def predict_behavior(
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
- context_score = (
- sum(current_context[entity_id] == expected for entity_id, expected in comparable)
- / len(comparable)
- if comparable
- else 0.5
+ context_score = _weighted_context_score(
+ comparable,
+ current_context,
+ context_weights or {},
)
score = pattern.weight * (
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
@@ -1854,6 +2025,25 @@ def predict_behavior(
)
+def _weighted_context_score(
+ comparable: list[tuple[str, str]],
+ current_context: dict[str, str | None],
+ context_weights: dict[str, float],
+) -> float:
+ if not comparable:
+ return 0.5
+ total_weight = 0.0
+ matched_weight = 0.0
+ for entity_id, expected in comparable:
+ weight = max(0.0, min(1.0, context_weights.get(entity_id, 1.0)))
+ total_weight += weight
+ if current_context.get(entity_id) == expected:
+ matched_weight += weight
+ if total_weight <= 0:
+ return 0.5
+ return matched_weight / total_weight
+
+
def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
diff --git a/app/main.py b/app/main.py
index de6aa2c..1533acc 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.0",
+ version="1.7.1",
lifespan=lifespan,
)
app.state.settings = load_settings()
diff --git a/app/static/index.html b/app/static/index.html
index 820ed19..31d9a1a 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -1252,6 +1252,42 @@ async function showActuator(actuatorId, evaluationMessage = "") {
`;
+ const simulationControls = weightedCandidates.length ? `
+ Teste Sensorzustände und Gewichtungen, ohne Home Assistant zu schalten.Aktor-Simulation
+
Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.
"; const currentContextControls = contexts.length ? `Simulation läuft ...
"; + try { + const results = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/simulate`, { + method: "POST", + body: JSON.stringify({ + sensor_states: sensorStates, + sensor_weights: sensorWeights, + state_options: stateOptions, + max_results: 6, + }), + }); + box.innerHTML = results.length ? results.map((result, index) => { + const prediction = result.prediction; + const factors = result.decision_factors || []; + return ` +${escapeHtml(result.recommendation || "")}
+Zustände: ${Object.entries(result.sensor_states || {}).map(([entity, state]) => `${escapeHtml(entity)}=${escapeHtml(state)}`).join(", ") || "keine"}
+Gewichtung: ${Object.entries(result.sensor_weights || {}).map(([entity, weight]) => `${escapeHtml(entity)}=${Math.round(weight * 100)} %`).join(", ") || "Standard"}
+ ${result.blockers?.length ? `${result.blockers.map(escapeHtml).join(" ")}
` : "Würde nach Sicherheitsprüfung schalten.
"} + ${factors.length ? `Keine Simulationsergebnisse.
"; + } catch (error) { + box.innerHTML = `${escapeHtml(error.message)}
`; + } +} + async function saveManualAssignment(actuatorId) { const numericEntityId = document.getElementById("manual-numeric-select").value || null; const selectedContextIds = Array.from( diff --git a/pyproject.toml b/pyproject.toml index 1bf6865..d2e5ed7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-next" -version = "1.7.0" +version = "1.7.1" 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 2872ee5..cc198bc 100644 --- a/tests/api/test_actuators.py +++ b/tests/api/test_actuators.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from pathlib import Path from time import perf_counter +from zoneinfo import ZoneInfo import pytest from fastapi.testclient import TestClient @@ -10,7 +11,7 @@ from fastapi.testclient import TestClient from app.api.v1.actuators import _deduplicate_actuator_ids from app.actuators.cache_db import DashboardCache from app.actuators.lifecycle import ActuatorReconciliationService -from app.actuators.models import JobStatus, ModelSnapshot +from app.actuators.models import BehaviorPattern, JobStatus, ModelSnapshot from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine from app.config import Settings @@ -272,6 +273,91 @@ def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> No assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75 +def test_actuator_simulation_ranks_sensor_states_without_switching(tmp_path: Path) -> None: + with TestClient(app) as client: + _install_service(tmp_path) + client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) + client.post( + "/v1/actuators/light.abstellkammer/assignment", + json={ + "numeric_entity_id": "sensor.abstellkammer_illuminance", + "context_entity_ids": ["binary_sensor.abstellkammer_motion"], + }, + ) + store = app.state.actuator_store + record = store.get("light.abstellkammer") + now = datetime.now(timezone.utc) + local = now.astimezone(ZoneInfo("Europe/Berlin")) + local_minute = local.hour * 60 + local.minute + patterns = [ + BehaviorPattern( + target_state="on", + minute_of_day=local_minute, + weekday=now.weekday(), + context_states={ + "sensor.abstellkammer_illuminance": "12", + "binary_sensor.abstellkammer_motion": "on", + }, + source="user", + weight=1.0, + observed_at=now, + ) + for _ in range(3) + ] + patterns.extend( + [ + BehaviorPattern( + target_state="off", + minute_of_day=local_minute, + weekday=now.weekday(), + context_states={ + "sensor.abstellkammer_illuminance": "12", + "binary_sensor.abstellkammer_motion": "off", + }, + source="user", + weight=0.5, + observed_at=now, + ) + for _ in range(3) + ] + ) + store.upsert( + record.model_copy( + update={ + "behavior": record.behavior.model_copy( + update={ + "patterns": patterns, + "sample_count": len(patterns), + "high_confidence_sample_count": len(patterns), + "activation_ready": True, + "activation_reason": "Testfreigabe.", + } + ) + } + ) + ) + + response = client.post( + "/v1/actuators/light.abstellkammer/simulate", + json={ + "state_options": {"binary_sensor.abstellkammer_motion": ["off", "on"]}, + "sensor_weights": { + "binary_sensor.abstellkammer_motion": 1.0, + "sensor.abstellkammer_illuminance": 0.25, + }, + "max_results": 2, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert len(payload) == 2 + assert payload[0]["prediction"]["target_state"] == "on" + assert payload[0]["sensor_states"]["binary_sensor.abstellkammer_motion"] == "on" + assert payload[0]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.25 + assert app.state.ha_reader.service_calls == [] + + def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None: with TestClient(app) as client: _install_service(tmp_path)