Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b9db62294 | |||
| 5ca0c53f6a | |||
| 8070a85b52 |
@@ -1,5 +1,5 @@
|
||||
name: SillyHome Next
|
||||
version: "1.7.0"
|
||||
version: "1.7.3"
|
||||
slug: sillyhome_next
|
||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
44
app/main.py
44
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.3",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.settings = load_settings()
|
||||
@@ -255,6 +255,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
|
||||
auth_token = cast(str, settings.ha_token)
|
||||
ws_status = getattr(app.state, "ws_status", None)
|
||||
reconnect_delay = 1.0
|
||||
relevant_entity_ids: set[str] = set()
|
||||
relevant_loaded_at = 0.0
|
||||
while True:
|
||||
if ws_status is not None:
|
||||
ws_status.status = "connecting"
|
||||
@@ -283,6 +286,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
|
||||
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
|
||||
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
|
||||
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
|
||||
relevant_loaded_at = asyncio.get_running_loop().time()
|
||||
reconnect_delay = 1.0
|
||||
if ws_status is not None:
|
||||
ws_status.status = "connected"
|
||||
ws_status.error = None
|
||||
@@ -309,6 +315,12 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
entity_id = event_data.get("entity_id")
|
||||
if not entity_id:
|
||||
continue
|
||||
loop_time = asyncio.get_running_loop().time()
|
||||
if loop_time - relevant_loaded_at >= 10:
|
||||
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
|
||||
relevant_loaded_at = loop_time
|
||||
if entity_id not in relevant_entity_ids:
|
||||
continue
|
||||
new_state = event_data.get("new_state")
|
||||
_update_ha_state_cache(state_cache, entity_id, new_state)
|
||||
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
||||
@@ -328,17 +340,24 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||
websockets.exceptions.InvalidStatus,
|
||||
OSError,
|
||||
) as exc:
|
||||
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc)
|
||||
delay = reconnect_delay
|
||||
logger.warning(
|
||||
"WebSocket-Verbindung unterbrochen: %s. Wiederholung in %.0fs...",
|
||||
exc,
|
||||
delay,
|
||||
)
|
||||
if ws_status is not None:
|
||||
ws_status.status = "reconnecting"
|
||||
ws_status.error = str(exc)
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(delay)
|
||||
reconnect_delay = min(reconnect_delay * 2, 60.0)
|
||||
except Exception as exc:
|
||||
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
|
||||
if ws_status is not None:
|
||||
ws_status.status = "error"
|
||||
ws_status.error = str(exc)
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(reconnect_delay)
|
||||
reconnect_delay = min(reconnect_delay * 2, 60.0)
|
||||
|
||||
|
||||
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
|
||||
@@ -353,7 +372,7 @@ async def _fallback_prediction(app: FastAPI) -> None:
|
||||
await asyncio.sleep(
|
||||
app.state.settings.prediction_interval_seconds
|
||||
if websocket_connected
|
||||
else min(5, app.state.settings.prediction_interval_seconds)
|
||||
else max(30, app.state.settings.prediction_interval_seconds)
|
||||
)
|
||||
# Nur ausführen, wenn WebSocket nicht verbunden ist
|
||||
ws_status = getattr(app.state, "ws_status", None)
|
||||
@@ -389,15 +408,14 @@ def _update_ha_state_cache(
|
||||
)
|
||||
|
||||
|
||||
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool:
|
||||
def _relevant_entity_ids(store: ActuatorStore) -> set[str]:
|
||||
result: set[str] = set()
|
||||
for record in store.list():
|
||||
if record.actuator_entity_id == entity_id:
|
||||
return True
|
||||
if record.assignment.selected_numeric_entity_id == entity_id:
|
||||
return True
|
||||
if entity_id in record.assignment.selected_context_entity_ids:
|
||||
return True
|
||||
return False
|
||||
result.add(record.actuator_entity_id)
|
||||
if record.assignment.selected_numeric_entity_id:
|
||||
result.add(record.assignment.selected_numeric_entity_id)
|
||||
result.update(record.assignment.selected_context_entity_ids)
|
||||
return result
|
||||
|
||||
|
||||
def _ha_entity_from_event(
|
||||
|
||||
@@ -281,6 +281,7 @@ let discoveryLoadPromise = null;
|
||||
let overviewLoadPromise = null;
|
||||
let systemLoadPromise = null;
|
||||
let currentSensorWeightGroups = [];
|
||||
let latestSimulationResults = new Map();
|
||||
let visibleActuatorLimit = 24;
|
||||
const ACTUATOR_RESULT_LIMIT = 50;
|
||||
const STATUS_TIMEOUT_MS = 2000;
|
||||
@@ -1252,6 +1253,42 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
|
||||
</details>
|
||||
`;
|
||||
const simulationControls = weightedCandidates.length ? `
|
||||
<details class="manual-context" open>
|
||||
<summary>Aktor-Simulation</summary>
|
||||
<p class="muted">Teste Sensorzustände und Gewichtungen, ohne Home Assistant zu schalten. Danach kannst du die beste Gewichtung übernehmen oder direkt in den Dry-run wechseln.</p>
|
||||
<div class="card-list">
|
||||
${weightedCandidates.map(candidate => {
|
||||
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
|
||||
const currentState = candidate.state || "";
|
||||
const stateOptions = candidate.domain === "binary_sensor"
|
||||
? "off,on"
|
||||
: currentState;
|
||||
return `
|
||||
<article class="actuator-card">
|
||||
<div class="card-title">
|
||||
<div>
|
||||
<strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>
|
||||
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
|
||||
</div>
|
||||
<span class="chip">Simulation</span>
|
||||
</div>
|
||||
<label for="sim-state-${escapeHtml(candidate.entity_id)}">Simulierter Zustand</label>
|
||||
<input id="sim-state-${escapeHtml(candidate.entity_id)}" data-sim-state-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(currentState)}" placeholder="on, off, 12 ...">
|
||||
<label for="sim-options-${escapeHtml(candidate.entity_id)}">Zustände vergleichen</label>
|
||||
<input id="sim-options-${escapeHtml(candidate.entity_id)}" data-sim-options-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(stateOptions)}" placeholder="on,off">
|
||||
<label for="sim-weight-${escapeHtml(candidate.entity_id)}">Simulierte Gewichtung in %</label>
|
||||
<input id="sim-weight-${escapeHtml(candidate.entity_id)}" data-sim-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${effective}">
|
||||
</article>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" onclick="simulateActuator('${escapeHtml(record.actuator_entity_id)}')">Bestes Szenario berechnen</button>
|
||||
</div>
|
||||
<div id="simulation-result" class="decision-list"></div>
|
||||
</details>
|
||||
` : "<p class='muted'>Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.</p>";
|
||||
const currentContextControls = contexts.length
|
||||
? `<ul>${contexts.map(entityId => `
|
||||
<li>
|
||||
@@ -1510,6 +1547,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<h3>Sensor-Gewichtung</h3>
|
||||
${weightControls}
|
||||
${weightGroupControls}
|
||||
${simulationControls}
|
||||
<h3>Verwendete Sensoren/Zustände ändern</h3>
|
||||
${currentContextControls}
|
||||
${manualAssignment}
|
||||
@@ -1610,6 +1648,98 @@ async function saveWeightOverrides(actuatorId, includeNewGroup = false) {
|
||||
}
|
||||
}
|
||||
|
||||
async function simulateActuator(actuatorId) {
|
||||
const sensorStates = {};
|
||||
const sensorWeights = {};
|
||||
const stateOptions = {};
|
||||
for (const input of document.querySelectorAll("[data-sim-state-entity]")) {
|
||||
const value = input.value.trim();
|
||||
if (value) sensorStates[input.dataset.simStateEntity] = value;
|
||||
}
|
||||
for (const input of document.querySelectorAll("[data-sim-weight-entity]")) {
|
||||
const value = Number(input.value);
|
||||
if (Number.isFinite(value)) {
|
||||
sensorWeights[input.dataset.simWeightEntity] = Math.max(0, Math.min(100, value)) / 100;
|
||||
}
|
||||
}
|
||||
for (const input of document.querySelectorAll("[data-sim-options-entity]")) {
|
||||
const values = input.value.split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
|
||||
if (values.length) stateOptions[input.dataset.simOptionsEntity] = values;
|
||||
}
|
||||
const box = document.getElementById("simulation-result");
|
||||
box.innerHTML = "<p class='muted'>Simulation läuft ...</p>";
|
||||
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,
|
||||
}),
|
||||
});
|
||||
latestSimulationResults.set(actuatorId, results);
|
||||
box.innerHTML = results.length ? results.map((result, index) => {
|
||||
const prediction = result.prediction;
|
||||
const factors = result.decision_factors || [];
|
||||
return `
|
||||
<div class="decision-row">
|
||||
<header>
|
||||
<strong>${index === 0 ? "Bestes Szenario" : `Szenario ${index + 1}`}</strong>
|
||||
<span class="chip">${prediction ? `${Math.round(prediction.confidence * 100)} % · ${escapeHtml(prediction.target_state)}` : "keine Vorhersage"}</span>
|
||||
</header>
|
||||
<p>${escapeHtml(result.recommendation || "")}</p>
|
||||
<p class="muted">Zustände: ${Object.entries(result.sensor_states || {}).map(([entity, state]) => `${escapeHtml(entity)}=${escapeHtml(state)}`).join(", ") || "keine"}</p>
|
||||
<p class="muted">Gewichtung: ${Object.entries(result.sensor_weights || {}).map(([entity, weight]) => `${escapeHtml(entity)}=${Math.round(weight * 100)} %`).join(", ") || "Standard"}</p>
|
||||
${result.blockers?.length ? `<p class="warn">${result.blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Würde nach Sicherheitsprüfung schalten.</p>"}
|
||||
${factors.length ? `<ul>${factors.slice(0, 4).map(factor => `<li>${escapeHtml(factor.label)}: ${Math.round((factor.contribution || 0) * 100)} % Beitrag</li>`).join("")}</ul>` : ""}
|
||||
<div class="actions">
|
||||
<button class="secondary compact" onclick="applySimulationWeights('${escapeHtml(actuatorId)}', '${escapeHtml(result.scenario_id)}', false)">Gewichtung übernehmen</button>
|
||||
<button class="compact" onclick="applySimulationWeights('${escapeHtml(actuatorId)}', '${escapeHtml(result.scenario_id)}', true)">Übernehmen + Dry-run starten</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("") : "<p class='muted'>Keine Simulationsergebnisse.</p>";
|
||||
} catch (error) {
|
||||
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function applySimulationWeights(actuatorId, scenarioId, startDryRun) {
|
||||
const result = (latestSimulationResults.get(actuatorId) || [])
|
||||
.find(item => item.scenario_id === scenarioId);
|
||||
if (!result) {
|
||||
alert("Simulationsergebnis ist nicht mehr verfügbar. Bitte neu simulieren.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/weights`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sensor_weights: result.sensor_weights || {},
|
||||
sensor_weight_groups: currentSensorWeightGroups,
|
||||
note: `Aus Simulation ${scenarioId} übernommen`,
|
||||
}),
|
||||
});
|
||||
if (startDryRun) {
|
||||
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/dry-run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({enabled: true}),
|
||||
});
|
||||
}
|
||||
invalidateDashboardCache();
|
||||
await loadConfiguredActuators();
|
||||
await showActuator(
|
||||
actuatorId,
|
||||
startDryRun
|
||||
? "Simulation übernommen und Dry-run gestartet."
|
||||
: "Simulation übernommen.",
|
||||
);
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveManualAssignment(actuatorId) {
|
||||
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
|
||||
const selectedContextIds = Array.from(
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-next"
|
||||
version = "1.7.0"
|
||||
version = "1.7.3"
|
||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -126,6 +126,43 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
||||
assert mock_app.state.ws_status.error is None
|
||||
|
||||
|
||||
def test_ha_event_listener_skips_unrelated_state_change(tmp_path: Path) -> None:
|
||||
async def run_test() -> None:
|
||||
fake_ws = _FakeWebSocket(
|
||||
[
|
||||
'{"type":"auth_required"}',
|
||||
'{"type":"auth_ok"}',
|
||||
(
|
||||
'{"type":"event","event":{"event_type":"state_changed",'
|
||||
'"data":{"entity_id":"sensor.unused","new_state":{"state":"on"}}}}'
|
||||
),
|
||||
asyncio.CancelledError(),
|
||||
]
|
||||
)
|
||||
|
||||
with patch("websockets.connect", return_value=fake_ws):
|
||||
try:
|
||||
await _ha_event_listener(mock_app, mock_client)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.state.settings = MagicMock()
|
||||
mock_app.state.settings.ha_url = "http://homeassistant:8123"
|
||||
mock_app.state.settings.ha_token = "test-token"
|
||||
mock_app.state.ws_status = MagicMock()
|
||||
mock_engine = _RecordingBehaviorEngine(tmp_path)
|
||||
mock_app.state.behavior_engine = mock_engine
|
||||
mock_app.state.ha_reader = _FakeHaReader()
|
||||
mock_store = ActuatorStore(tmp_path / "store")
|
||||
mock_store.configure("light.test")
|
||||
mock_app.state.actuator_store = mock_store
|
||||
mock_client = MagicMock()
|
||||
|
||||
anyio.run(run_test)
|
||||
assert mock_engine.state_changes == []
|
||||
|
||||
|
||||
def test_lifespan_skips_event_listener_without_ha_config() -> None:
|
||||
app = FastAPI()
|
||||
app.state.settings = MagicMock()
|
||||
|
||||
Reference in New Issue
Block a user