Compare commits
1 Commits
v2.0.0-alp
...
v2.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c6fe24b03 |
@@ -2,7 +2,7 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.8
|
||||
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.9
|
||||
RUN python -m pip install --no-cache-dir \
|
||||
"http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: SillyHome Future
|
||||
version: "2.0.0-alpha.8"
|
||||
version: "2.0.0-alpha.9"
|
||||
slug: sillyhome_future
|
||||
description: Event-first SillyHome v2 test controller
|
||||
url: http://192.168.6.31:3000/Otto/sillyhome-future
|
||||
|
||||
@@ -27,6 +27,8 @@ class DecisionEngineV2:
|
||||
blockers.append(f"Domain {domain} ist nicht fuer Active-Control freigegeben.")
|
||||
if control.manual_block:
|
||||
blockers.append("Manuelle Sicherheitssperre ist aktiv.")
|
||||
if control.stage is SafetyStage.ACTIVE and not control.active_ready:
|
||||
blockers.append("Active ist noch nicht freigegeben. Erst Dry-run-Reife erreichen.")
|
||||
best = None
|
||||
for pattern in learning.patterns:
|
||||
if pattern.trigger_entity_id != trigger_entity_id:
|
||||
@@ -63,4 +65,3 @@ class DecisionEngineV2:
|
||||
blockers=blockers,
|
||||
trigger_entity_id=trigger_entity_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,6 +76,17 @@ class EventCore:
|
||||
learning=learning_profile,
|
||||
control=control_profile,
|
||||
)
|
||||
if not control.global_enabled and decision.allowed:
|
||||
decision = decision.model_copy(
|
||||
update={
|
||||
"allowed": False,
|
||||
"reason": "Globaler Not-Aus ist aktiv.",
|
||||
"blockers": [*decision.blockers, "Globaler Not-Aus ist aktiv."],
|
||||
}
|
||||
)
|
||||
if decision.dry_run:
|
||||
control_profile = _record_dry_run(control_profile, decision)
|
||||
control.profiles[actuator_id] = control_profile
|
||||
if callable(execute) and decision.allowed and not decision.dry_run:
|
||||
decision = _execute_decision(decision, execute)
|
||||
audit.append(
|
||||
@@ -110,3 +121,25 @@ def _execute_decision(decision: Decision, execute: Callable[[Decision], bool]) -
|
||||
}
|
||||
)
|
||||
return decision.model_copy(update={"executed": bool(result)})
|
||||
|
||||
|
||||
def _record_dry_run(profile: ControlProfile, decision: Decision) -> ControlProfile:
|
||||
events = profile.dry_run_events + 1
|
||||
successes = profile.dry_run_successes + int(decision.allowed and not decision.blockers)
|
||||
failures = profile.dry_run_failures + int(bool(decision.blockers))
|
||||
success_rate = successes / events if events else 0.0
|
||||
ready = events >= 5 and success_rate >= 0.8 and failures <= 1
|
||||
reason = (
|
||||
f"Dry-run {successes}/{events} erfolgreich."
|
||||
if ready
|
||||
else f"Dry-run braucht mindestens 5 Events und 80% Treffer; aktuell {successes}/{events}."
|
||||
)
|
||||
return profile.model_copy(
|
||||
update={
|
||||
"dry_run_events": events,
|
||||
"dry_run_successes": successes,
|
||||
"dry_run_failures": failures,
|
||||
"active_ready": ready,
|
||||
"active_readiness_reason": reason,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -67,6 +67,11 @@ class ControlProfile(BaseModel):
|
||||
handoff_mode: HandoffMode = HandoffMode.SHADOW
|
||||
related_automation_ids: list[str] = Field(default_factory=list)
|
||||
paused_automation_ids: list[str] = Field(default_factory=list)
|
||||
dry_run_events: int = Field(default=0, ge=0)
|
||||
dry_run_successes: int = Field(default=0, ge=0)
|
||||
dry_run_failures: int = Field(default=0, ge=0)
|
||||
active_ready: bool = False
|
||||
active_readiness_reason: str = "Noch nicht bewertet."
|
||||
|
||||
|
||||
class RoomProfile(BaseModel):
|
||||
@@ -120,6 +125,7 @@ class LearningState(BaseModel):
|
||||
|
||||
class ControlState(BaseModel):
|
||||
profiles: dict[str, ControlProfile] = Field(default_factory=dict)
|
||||
global_enabled: bool = True
|
||||
|
||||
|
||||
class BackupBundle(BaseModel):
|
||||
@@ -127,4 +133,3 @@ class BackupBundle(BaseModel):
|
||||
runtime: RuntimeState
|
||||
learning: LearningState
|
||||
control: ControlState
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import TypeVar
|
||||
from typing import cast, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -18,13 +18,20 @@ class JsonDocumentStore:
|
||||
self.root = Path(root).resolve()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = RLock()
|
||||
self._cache: dict[str, BaseModel] = {}
|
||||
|
||||
def load(self, name: str, model: type[T], default: T) -> T:
|
||||
path = self.root / name
|
||||
with self._lock:
|
||||
cached = self._cache.get(name)
|
||||
if cached is not None:
|
||||
return cast(T, cached)
|
||||
if not path.exists():
|
||||
self._cache[name] = default
|
||||
return default
|
||||
return model.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
value = model.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
self._cache[name] = value
|
||||
return value
|
||||
|
||||
def save(self, name: str, value: T) -> T:
|
||||
path = self.root / name
|
||||
@@ -35,6 +42,7 @@ class JsonDocumentStore:
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
self._cache[name] = value
|
||||
return value
|
||||
|
||||
|
||||
@@ -71,4 +79,3 @@ class FutureStores:
|
||||
self.save_runtime(bundle.runtime)
|
||||
self.save_learning(bundle.learning)
|
||||
self.save_control(bundle.control)
|
||||
|
||||
|
||||
188
app/main.py
188
app/main.py
@@ -9,6 +9,7 @@ from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.decision import DecisionEngineV2
|
||||
from app.core.event_core import EventCore
|
||||
from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
|
||||
from app.core.handoff import HandoffMatrix
|
||||
@@ -50,6 +51,25 @@ class PatternCreateRequest(BaseModel):
|
||||
source: str = Field(default="dashboard", max_length=40)
|
||||
|
||||
|
||||
class GlobalControlUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class SimulationRequest(BaseModel):
|
||||
actuator_entity_id: str
|
||||
trigger_entity_id: str
|
||||
trigger_state: str | None = None
|
||||
|
||||
|
||||
class ActiveReadiness(BaseModel):
|
||||
actuator_entity_id: str
|
||||
ready: bool
|
||||
reason: str
|
||||
dry_run_events: int
|
||||
dry_run_successes: int
|
||||
dry_run_failures: int
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
global ha_client
|
||||
@@ -70,7 +90,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
app = FastAPI(
|
||||
title="SillyHome Future API",
|
||||
description="SillyHome v2 event-core side project.",
|
||||
version="2.0.0-alpha.8",
|
||||
version="2.0.0-alpha.9",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -85,6 +105,21 @@ def health() -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v2/health")
|
||||
def detailed_health() -> dict[str, object]:
|
||||
runtime = stores.runtime()
|
||||
control = stores.control()
|
||||
return {
|
||||
"api": "ok",
|
||||
"version": app.version,
|
||||
"websocket": runtime.websocket_status,
|
||||
"store": "ok",
|
||||
"global_enabled": control.global_enabled,
|
||||
"entities": len(runtime.entities),
|
||||
"audit_events": len(runtime.audit),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def dashboard() -> str:
|
||||
return _dashboard_html()
|
||||
@@ -105,6 +140,7 @@ def dashboard_data() -> dict[str, object]:
|
||||
"websocket_status": runtime.websocket_status,
|
||||
"entity_count": len(runtime.entities),
|
||||
"actuator_count": len(actuator_entities),
|
||||
"global_enabled": control.global_enabled,
|
||||
"learning_profiles": len(learning.profiles),
|
||||
"control_profiles": len(control.profiles),
|
||||
"rooms": list(learning.rooms.values()),
|
||||
@@ -140,6 +176,18 @@ def list_entities(domain: str | None = None, q: str | None = None) -> list[Entit
|
||||
return sorted(entities, key=lambda entity: entity.entity_id)[:500]
|
||||
|
||||
|
||||
@app.get("/v2/entities/groups")
|
||||
def entity_groups() -> dict[str, list[EntityState]]:
|
||||
grouped: dict[str, list[EntityState]] = {}
|
||||
for entity in stores.runtime().entities.values():
|
||||
key = entity.area_name or entity.domain
|
||||
grouped.setdefault(key, []).append(entity)
|
||||
return {
|
||||
key: sorted(values, key=lambda entity: entity.entity_id)[:250]
|
||||
for key, values in sorted(grouped.items())
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v2/learning", response_model=LearningState)
|
||||
def get_learning() -> LearningState:
|
||||
return stores.learning()
|
||||
@@ -155,6 +203,18 @@ def get_control() -> ControlState:
|
||||
return stores.control()
|
||||
|
||||
|
||||
@app.post("/v2/control/global", response_model=ControlState)
|
||||
def set_global_control(update: GlobalControlUpdate) -> ControlState:
|
||||
state = stores.control().model_copy(update={"global_enabled": update.enabled})
|
||||
stores.save_control(state)
|
||||
_append_audit(
|
||||
"safety",
|
||||
None,
|
||||
"Globaler Not-Aus deaktiviert." if update.enabled else "Globaler Not-Aus aktiviert.",
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
@app.put("/v2/control/{actuator_entity_id}", response_model=ControlProfile)
|
||||
def put_control(actuator_entity_id: str, profile: ControlProfile) -> ControlProfile:
|
||||
state = stores.control()
|
||||
@@ -173,9 +233,12 @@ def update_control_stage(
|
||||
actuator_entity_id,
|
||||
ControlProfile(actuator_entity_id=actuator_entity_id),
|
||||
)
|
||||
requested_stage = update.stage
|
||||
if requested_stage is SafetyStage.ACTIVE and not profile.active_ready:
|
||||
requested_stage = SafetyStage.DRY_RUN
|
||||
updated = profile.model_copy(
|
||||
update={
|
||||
"stage": update.stage,
|
||||
"stage": requested_stage,
|
||||
"min_confidence": update.min_confidence,
|
||||
"manual_block": update.manual_block,
|
||||
"cooldown_seconds": update.cooldown_seconds,
|
||||
@@ -184,9 +247,32 @@ def update_control_stage(
|
||||
)
|
||||
state.profiles[actuator_entity_id] = updated
|
||||
stores.save_control(state)
|
||||
_append_audit(
|
||||
"safety",
|
||||
actuator_entity_id,
|
||||
f"Stage gesetzt auf {updated.stage}."
|
||||
if updated.stage == update.stage
|
||||
else f"Active blockiert: {updated.active_readiness_reason}",
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
@app.get("/v2/control/{actuator_entity_id}/readiness", response_model=ActiveReadiness)
|
||||
def active_readiness(actuator_entity_id: str) -> ActiveReadiness:
|
||||
profile = stores.control().profiles.get(
|
||||
actuator_entity_id,
|
||||
ControlProfile(actuator_entity_id=actuator_entity_id),
|
||||
)
|
||||
return ActiveReadiness(
|
||||
actuator_entity_id=actuator_entity_id,
|
||||
ready=profile.active_ready,
|
||||
reason=profile.active_readiness_reason,
|
||||
dry_run_events=profile.dry_run_events,
|
||||
dry_run_successes=profile.dry_run_successes,
|
||||
dry_run_failures=profile.dry_run_failures,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/v2/learning/{actuator_entity_id}/patterns", response_model=LearningProfile)
|
||||
def create_learning_pattern(
|
||||
actuator_entity_id: str,
|
||||
@@ -219,6 +305,53 @@ def create_learning_pattern(
|
||||
return updated
|
||||
|
||||
|
||||
@app.post("/v2/simulate")
|
||||
def simulate_decision(request: SimulationRequest) -> dict[str, object]:
|
||||
runtime = stores.runtime()
|
||||
learning = stores.learning()
|
||||
control = stores.control()
|
||||
runtime.entities[request.trigger_entity_id] = EntityState(
|
||||
entity_id=request.trigger_entity_id,
|
||||
domain=request.trigger_entity_id.split(".", 1)[0],
|
||||
state=request.trigger_state,
|
||||
)
|
||||
profile = learning.profiles.get(
|
||||
request.actuator_entity_id,
|
||||
LearningProfile(actuator_entity_id=request.actuator_entity_id),
|
||||
)
|
||||
control_profile = control.profiles.get(
|
||||
request.actuator_entity_id,
|
||||
ControlProfile(actuator_entity_id=request.actuator_entity_id),
|
||||
)
|
||||
decision = DecisionEngineV2().decide(
|
||||
actuator_entity_id=request.actuator_entity_id,
|
||||
trigger_entity_id=request.trigger_entity_id,
|
||||
runtime=runtime,
|
||||
learning=profile,
|
||||
control=control_profile,
|
||||
)
|
||||
service = service_for_state(
|
||||
request.actuator_entity_id.split(".", 1)[0],
|
||||
decision.target_state,
|
||||
)
|
||||
return {
|
||||
"decision": decision,
|
||||
"would_call_service": service is not None and decision.allowed and not decision.dry_run,
|
||||
"service": service,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v2/audit/{actuator_entity_id}", response_model=list[AuditEvent])
|
||||
def actuator_audit(actuator_entity_id: str) -> list[AuditEvent]:
|
||||
return [
|
||||
item
|
||||
for item in stores.runtime().audit
|
||||
if item.entity_id == actuator_entity_id or (
|
||||
item.decision is not None and item.decision.actuator_entity_id == actuator_entity_id
|
||||
)
|
||||
][-50:]
|
||||
|
||||
|
||||
@app.post("/v2/handoff/{actuator_entity_id}/assume", response_model=ControlProfile)
|
||||
def assume_control(actuator_entity_id: str) -> ControlProfile:
|
||||
state = stores.control()
|
||||
@@ -358,6 +491,18 @@ def _merge_unrouted_events(events: list[StateEvent]) -> None:
|
||||
stores.save_runtime(runtime)
|
||||
|
||||
|
||||
def _append_audit(kind: str, entity_id: str | None, message: str) -> None:
|
||||
runtime = stores.runtime()
|
||||
event = AuditEvent(
|
||||
event_id=f"{kind}-{len(runtime.audit) + 1}",
|
||||
kind=kind,
|
||||
entity_id=entity_id,
|
||||
message=message,
|
||||
)
|
||||
runtime.audit = [*runtime.audit, event][-200:]
|
||||
stores.save_runtime(runtime)
|
||||
|
||||
|
||||
def _execute_ha_decision(decision: object) -> bool:
|
||||
if ha_client is None or not hasattr(decision, "actuator_entity_id"):
|
||||
return False
|
||||
@@ -427,6 +572,8 @@ def _dashboard_html() -> str:
|
||||
<label><input id="manualBlock" type="checkbox" style="width:auto;margin-right:6px"> Manuell blockieren</label>
|
||||
<div class="stages" id="stages"></div>
|
||||
<button class="primary" id="saveControl">Control speichern</button>
|
||||
<button id="globalToggle">Globaler Not-Aus</button>
|
||||
<div class="status" id="readiness">Ready-Status wird geladen...</div>
|
||||
<div class="status" id="controlStatus"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
@@ -452,6 +599,7 @@ def _dashboard_html() -> str:
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary" id="addPattern">Pattern anlegen</button>
|
||||
<button id="simulatePattern">Pattern simulieren</button>
|
||||
<div class="status" id="patternStatus"></div>
|
||||
<h2>Aktuelles Profil</h2>
|
||||
<pre id="profile">Lade...</pre>
|
||||
@@ -528,6 +676,7 @@ def _dashboard_html() -> str:
|
||||
['WebSocket', data.websocket_status],
|
||||
['Entities', data.entity_count],
|
||||
['Actuators', data.actuator_count],
|
||||
['Global', data.global_enabled ? 'on' : 'off'],
|
||||
['Learning', data.learning_profiles],
|
||||
['Control', data.control_profiles],
|
||||
['Rooms', data.rooms.length],
|
||||
@@ -537,11 +686,21 @@ def _dashboard_html() -> str:
|
||||
`<div class="card"><div class="label">${label}</div><div class="value">${value}</div></div>`
|
||||
).join('');
|
||||
renderEntities();
|
||||
await loadReadiness();
|
||||
document.getElementById('audit').textContent = JSON.stringify(data.audit, null, 2);
|
||||
}
|
||||
|
||||
async function loadReadiness() {
|
||||
const id = document.getElementById('actuator').value;
|
||||
if (!id) return;
|
||||
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/readiness`);
|
||||
const data = await response.json();
|
||||
document.getElementById('readiness').textContent =
|
||||
`${data.ready ? 'ready for active' : 'nicht active-ready'} - ${data.reason}`;
|
||||
}
|
||||
|
||||
document.getElementById('entitySearch').oninput = renderEntities;
|
||||
document.getElementById('actuator').onchange = renderProfile;
|
||||
document.getElementById('actuator').onchange = () => { renderProfile(); loadReadiness(); };
|
||||
document.getElementById('saveControl').onclick = async () => {
|
||||
const id = document.getElementById('actuator').value;
|
||||
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/stage`, {
|
||||
@@ -557,6 +716,7 @@ def _dashboard_html() -> str:
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
setStatus('controlStatus', 'Gespeichert');
|
||||
await loadDashboard();
|
||||
await loadReadiness();
|
||||
};
|
||||
document.getElementById('addPattern').onclick = async () => {
|
||||
const id = document.getElementById('actuator').value;
|
||||
@@ -574,6 +734,28 @@ def _dashboard_html() -> str:
|
||||
setStatus('patternStatus', 'Pattern angelegt');
|
||||
await loadDashboard();
|
||||
};
|
||||
document.getElementById('simulatePattern').onclick = async () => {
|
||||
const id = document.getElementById('actuator').value;
|
||||
const response = await fetch('/v2/simulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
actuator_entity_id: id,
|
||||
trigger_entity_id: document.getElementById('trigger').value,
|
||||
trigger_state: document.getElementById('triggerState').value || null,
|
||||
}),
|
||||
});
|
||||
document.getElementById('profile').textContent = JSON.stringify(await response.json(), null, 2);
|
||||
};
|
||||
document.getElementById('globalToggle').onclick = async () => {
|
||||
const dash = await (await fetch('/v2/dashboard')).json();
|
||||
await fetch('/v2/control/global', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !dash.global_enabled }),
|
||||
});
|
||||
await loadDashboard();
|
||||
};
|
||||
renderStages();
|
||||
loadDashboard();
|
||||
setInterval(loadDashboard, 5000);
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-future"
|
||||
version = "2.0.0-alpha.8"
|
||||
version = "2.0.0-alpha.9"
|
||||
description = "SillyHome v2 event-core prototype"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -9,7 +9,7 @@ def test_addon_config_declares_future_addon() -> None:
|
||||
config = yaml.safe_load(Path("addon/config.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
assert config["slug"] == "sillyhome_future"
|
||||
assert config["version"] == "2.0.0-alpha.8"
|
||||
assert config["version"] == "2.0.0-alpha.9"
|
||||
assert config["ingress"] is True
|
||||
assert config["ingress_port"] == 8099
|
||||
assert config["homeassistant_api"] is True
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ig
|
||||
restore = client.post("/v2/backup/restore", json=backup.json())
|
||||
|
||||
assert health.status_code == 200
|
||||
assert health.json()["version"] == "2.0.0-alpha.8"
|
||||
assert health.json()["version"] == "2.0.0-alpha.9"
|
||||
assert backup.status_code == 200
|
||||
assert restore.status_code == 200
|
||||
assert restore.json() == {"status": "restored"}
|
||||
@@ -66,6 +66,17 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
|
||||
"confidence": 0.91,
|
||||
},
|
||||
)
|
||||
simulation = client.post(
|
||||
"/v2/simulate",
|
||||
json={
|
||||
"actuator_entity_id": "light.storage",
|
||||
"trigger_entity_id": "binary_sensor.storage_door",
|
||||
"trigger_state": "on",
|
||||
},
|
||||
)
|
||||
readiness = client.get("/v2/control/light.storage/readiness")
|
||||
global_control = client.post("/v2/control/global", json={"enabled": False})
|
||||
detailed_health = client.get("/v2/health")
|
||||
dashboard = client.get("/v2/dashboard")
|
||||
|
||||
assert entities.status_code == 200
|
||||
@@ -77,5 +88,13 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
|
||||
assert control.json()["stage"] == "dry_run"
|
||||
assert learning.status_code == 200
|
||||
assert learning.json()["patterns"][0]["trigger_entity_id"] == "binary_sensor.storage_door"
|
||||
assert simulation.status_code == 200
|
||||
assert simulation.json()["decision"]["target_state"] == "on"
|
||||
assert readiness.status_code == 200
|
||||
assert readiness.json()["ready"] is False
|
||||
assert global_control.status_code == 200
|
||||
assert global_control.json()["global_enabled"] is False
|
||||
assert detailed_health.status_code == 200
|
||||
assert detailed_health.json()["global_enabled"] is False
|
||||
assert dashboard.status_code == 200
|
||||
assert dashboard.json()["actuator_count"] == 1
|
||||
|
||||
@@ -134,6 +134,7 @@ def test_event_core_executes_allowed_active_decision(tmp_path: Path) -> None:
|
||||
actuator_entity_id="light.storage",
|
||||
stage=SafetyStage.ACTIVE,
|
||||
min_confidence=0.8,
|
||||
active_ready=True,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -153,3 +154,58 @@ def test_event_core_executes_allowed_active_decision(tmp_path: Path) -> None:
|
||||
decision = [item.decision for item in audit if item.decision is not None][0]
|
||||
assert calls == ["light.storage"]
|
||||
assert decision.executed is True
|
||||
|
||||
|
||||
def test_active_requires_dry_run_readiness(tmp_path: Path) -> None:
|
||||
stores = FutureStores(tmp_path)
|
||||
stores.save_runtime(
|
||||
RuntimeState(
|
||||
entities={
|
||||
"light.storage": EntityState(
|
||||
entity_id="light.storage",
|
||||
domain="light",
|
||||
state="off",
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
stores.save_learning(
|
||||
LearningState(
|
||||
profiles={
|
||||
"light.storage": LearningProfile(
|
||||
actuator_entity_id="light.storage",
|
||||
patterns=[
|
||||
BehaviorPatternV2(
|
||||
actuator_entity_id="light.storage",
|
||||
target_state="on",
|
||||
trigger_entity_id="binary_sensor.storage_door",
|
||||
trigger_state="on",
|
||||
support=3,
|
||||
confidence=0.95,
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
stores.save_control(
|
||||
stores.control().model_copy(
|
||||
update={
|
||||
"profiles": {
|
||||
"light.storage": ControlProfile(
|
||||
actuator_entity_id="light.storage",
|
||||
stage=SafetyStage.ACTIVE,
|
||||
min_confidence=0.8,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
audit = EventCore(stores).process_state_event(
|
||||
StateEvent(entity_id="binary_sensor.storage_door", new_state="on")
|
||||
)
|
||||
|
||||
decision = [item.decision for item in audit if item.decision is not None][0]
|
||||
assert decision.allowed is False
|
||||
assert "Active ist noch nicht freigegeben" in decision.blockers[0]
|
||||
|
||||
Reference in New Issue
Block a user