Compare commits
11 Commits
v2.0.0-alp
...
v2.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| bf832b49f4 | |||
| 8057751c11 | |||
| 2f750e3e41 | |||
| 6e6031cfda | |||
| 07e3e96c30 | |||
| e4b860571a | |||
| 3c6fe24b03 | |||
| 01e5464ec1 | |||
| 937c79a19b | |||
| 149f11a18e | |||
| 38a85fce74 |
@@ -2,7 +2,7 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG SILLYHOME_FUTURE_REF=main
|
||||
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.15
|
||||
RUN python -m pip install --no-cache-dir \
|
||||
"http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz"
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
name: SillyHome Future
|
||||
version: "2.0.0-alpha.4"
|
||||
version: "2.0.0-alpha.15"
|
||||
slug: sillyhome_future
|
||||
description: Event-first SillyHome v2 test controller
|
||||
url: http://192.168.6.31:3000/Otto/sillyhome-future
|
||||
arch:
|
||||
- amd64
|
||||
startup: application
|
||||
boot: manual
|
||||
boot: auto
|
||||
watchdog: http://[HOST]:[PORT:8099]/health
|
||||
init: false
|
||||
ingress: true
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -72,6 +72,46 @@ class FutureHaClient:
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
def read_history(
|
||||
self,
|
||||
entity_ids: list[str],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> dict[str, list[StateEvent]]:
|
||||
params = {
|
||||
"filter_entity_id": ",".join(entity_ids),
|
||||
"end_time": end_time.isoformat(),
|
||||
"minimal_response": "1",
|
||||
}
|
||||
with httpx.Client(timeout=self._config.timeout_seconds) as client:
|
||||
response = client.get(
|
||||
f"{self._core_url}/api/history/period/{start_time.isoformat()}",
|
||||
headers=self._headers,
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
result: dict[str, list[StateEvent]] = {entity_id: [] for entity_id in entity_ids}
|
||||
for series in payload if isinstance(payload, list) else []:
|
||||
if not isinstance(series, list):
|
||||
continue
|
||||
for item in series:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
entity_id = item.get("entity_id")
|
||||
if not isinstance(entity_id, str):
|
||||
continue
|
||||
result.setdefault(entity_id, []).append(
|
||||
StateEvent(
|
||||
entity_id=entity_id,
|
||||
new_state=item.get("state") if isinstance(item.get("state"), str) else None,
|
||||
changed_at=_parse_datetime(
|
||||
item.get("last_changed") or item.get("last_updated")
|
||||
),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
async def listen_state_events(self) -> AsyncIterator[StateEvent]:
|
||||
websocket_url = self._config.websocket_url or _default_websocket_url(self._core_url)
|
||||
async with websockets.connect(websocket_url, ping_interval=None) as websocket:
|
||||
@@ -160,4 +200,3 @@ def _parse_datetime(value: object) -> datetime:
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
@@ -21,6 +21,25 @@ class SafetyStage(StrEnum):
|
||||
BLOCKED = "blocked"
|
||||
|
||||
|
||||
class ProposalStatus(StrEnum):
|
||||
DRAFT = "draft"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class JobStatus(StrEnum):
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
SUCCEEDED = "succeeded"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class CandidateStatus(StrEnum):
|
||||
PROPOSED = "proposed"
|
||||
ACCEPTED = "accepted"
|
||||
DISMISSED = "dismissed"
|
||||
|
||||
|
||||
class EntityState(BaseModel):
|
||||
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
domain: str
|
||||
@@ -67,6 +86,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):
|
||||
@@ -84,6 +108,88 @@ class SceneProfile(BaseModel):
|
||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class AutomationProposal(BaseModel):
|
||||
proposal_id: str
|
||||
name: str
|
||||
trigger_entity_id: str
|
||||
trigger_state: str | None = None
|
||||
actuator_entity_id: str
|
||||
target_state: str
|
||||
status: ProposalStatus = ProposalStatus.DRAFT
|
||||
revision: int = 1
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
decided_at: datetime | None = None
|
||||
|
||||
|
||||
class ModelRecord(BaseModel):
|
||||
model_id: str
|
||||
actuator_entity_id: str
|
||||
pattern_count: int
|
||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
trained_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ModelEvaluation(BaseModel):
|
||||
evaluation_id: str
|
||||
model_id: str
|
||||
actuator_entity_id: str
|
||||
score: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
coverage: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
dry_run_success_rate: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
feedback_score: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
verdict: str
|
||||
reasons: list[str] = Field(default_factory=list)
|
||||
evaluated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class JobQueueItem(BaseModel):
|
||||
job_id: str
|
||||
kind: str
|
||||
status: JobStatus = JobStatus.QUEUED
|
||||
message: str = ""
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
finished_at: datetime | None = None
|
||||
|
||||
|
||||
class SensorWeightOverride(BaseModel):
|
||||
actuator_entity_id: str
|
||||
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class AutopilotSettings(BaseModel):
|
||||
enabled: bool = True
|
||||
interval_seconds: int = Field(default=3600, ge=300)
|
||||
min_candidate_confidence: float = Field(default=0.65, ge=0.0, le=1.0)
|
||||
auto_train: bool = True
|
||||
auto_evaluate: bool = True
|
||||
auto_activate: bool = False
|
||||
last_run_at: datetime | None = None
|
||||
|
||||
|
||||
class CandidateRecommendation(BaseModel):
|
||||
candidate_id: str
|
||||
actuator_entity_id: str
|
||||
trigger_entity_id: str
|
||||
trigger_state: str | None = None
|
||||
target_state: str
|
||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||
reason: str
|
||||
status: CandidateStatus = CandidateStatus.PROPOSED
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class HistoryAnalysis(BaseModel):
|
||||
entity_id: str
|
||||
samples: int
|
||||
last_state: str | None = None
|
||||
changed_at: datetime | None = None
|
||||
unique_states: int = 0
|
||||
transitions: int = 0
|
||||
recommendation: str
|
||||
|
||||
|
||||
class Decision(BaseModel):
|
||||
actuator_entity_id: str
|
||||
target_state: str | None = None
|
||||
@@ -116,10 +222,18 @@ class LearningState(BaseModel):
|
||||
profiles: dict[str, LearningProfile] = Field(default_factory=dict)
|
||||
rooms: dict[str, RoomProfile] = Field(default_factory=dict)
|
||||
scenes: dict[str, SceneProfile] = Field(default_factory=dict)
|
||||
automation_proposals: dict[str, AutomationProposal] = Field(default_factory=dict)
|
||||
models: dict[str, ModelRecord] = Field(default_factory=dict)
|
||||
model_evaluations: dict[str, ModelEvaluation] = Field(default_factory=dict)
|
||||
jobs: dict[str, JobQueueItem] = Field(default_factory=dict)
|
||||
weight_overrides: dict[str, SensorWeightOverride] = Field(default_factory=dict)
|
||||
candidates: dict[str, CandidateRecommendation] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ControlState(BaseModel):
|
||||
profiles: dict[str, ControlProfile] = Field(default_factory=dict)
|
||||
global_enabled: bool = True
|
||||
autopilot: AutopilotSettings = Field(default_factory=AutopilotSettings)
|
||||
|
||||
|
||||
class BackupBundle(BaseModel):
|
||||
@@ -127,4 +241,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)
|
||||
|
||||
|
||||
1284
app/main.py
1284
app/main.py
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-future"
|
||||
version = "2.0.0-alpha.4"
|
||||
version = "2.0.0-alpha.15"
|
||||
description = "SillyHome v2 event-core prototype"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -9,10 +9,12 @@ 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.4"
|
||||
assert config["version"] == "2.0.0-alpha.15"
|
||||
assert config["ingress"] is True
|
||||
assert config["ingress_port"] == 8099
|
||||
assert config["homeassistant_api"] is True
|
||||
assert config["boot"] == "auto"
|
||||
assert config["watchdog"] == "http://[HOST]:[PORT:8099]/health"
|
||||
|
||||
|
||||
def test_repository_points_to_gitea_repo() -> None:
|
||||
|
||||
@@ -2,20 +2,180 @@ from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app, stores
|
||||
import app.main as main
|
||||
from app.core.event_core import EventCore
|
||||
from app.core.models import EntityState, RuntimeState
|
||||
from app.core.stores import FutureStores
|
||||
|
||||
|
||||
def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.setenv("SILLYHOME_FUTURE_STORE", str(tmp_path))
|
||||
client = TestClient(app)
|
||||
test_stores = FutureStores(tmp_path)
|
||||
monkeypatch.setattr(main, "stores", test_stores)
|
||||
monkeypatch.setattr(main, "event_core", EventCore(test_stores))
|
||||
client = TestClient(main.app)
|
||||
|
||||
health = client.get("/health")
|
||||
backup = client.get("/v2/backup/export")
|
||||
restore = client.post("/v2/backup/restore", json=backup.json())
|
||||
|
||||
assert stores is not None
|
||||
assert health.status_code == 200
|
||||
assert health.json()["version"] == "2.0.0-alpha.4"
|
||||
assert health.json()["version"] == "2.0.0-alpha.15"
|
||||
assert backup.status_code == 200
|
||||
assert restore.status_code == 200
|
||||
assert restore.json() == {"status": "restored"}
|
||||
|
||||
|
||||
def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
test_stores = FutureStores(tmp_path)
|
||||
test_stores.save_runtime(
|
||||
RuntimeState(
|
||||
entities={
|
||||
"light.storage": EntityState(
|
||||
entity_id="light.storage",
|
||||
domain="light",
|
||||
state="off",
|
||||
),
|
||||
"binary_sensor.storage_door": EntityState(
|
||||
entity_id="binary_sensor.storage_door",
|
||||
domain="binary_sensor",
|
||||
state="off",
|
||||
area_name="Storage",
|
||||
),
|
||||
"light.storage_door": EntityState(
|
||||
entity_id="light.storage_door",
|
||||
domain="light",
|
||||
state="off",
|
||||
area_name="Storage",
|
||||
),
|
||||
"switch.storage_motion": EntityState(
|
||||
entity_id="switch.storage_motion",
|
||||
domain="switch",
|
||||
state="off",
|
||||
area_name="Storage",
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(main, "stores", test_stores)
|
||||
monkeypatch.setattr(main, "event_core", EventCore(test_stores))
|
||||
client = TestClient(main.app)
|
||||
|
||||
entities = client.get("/v2/entities?domain=light,binary_sensor")
|
||||
control = client.post(
|
||||
"/v2/control/light.storage/stage",
|
||||
json={
|
||||
"stage": "dry_run",
|
||||
"min_confidence": 0.75,
|
||||
"manual_block": False,
|
||||
"cooldown_seconds": 120,
|
||||
},
|
||||
)
|
||||
learning = client.post(
|
||||
"/v2/learning/light.storage/patterns",
|
||||
json={
|
||||
"trigger_entity_id": "binary_sensor.storage_door",
|
||||
"trigger_state": "on",
|
||||
"target_state": "on",
|
||||
"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")
|
||||
feedback = client.post("/v2/control/light.storage/feedback", json={"kind": "wrong"})
|
||||
summary = client.get("/v2/actuators/summary")
|
||||
parity = client.get("/v2/feature-parity")
|
||||
anomalies = client.get("/v2/anomalies")
|
||||
proposal = client.post(
|
||||
"/v2/automations/proposals",
|
||||
json={
|
||||
"name": "Storage light",
|
||||
"trigger_entity_id": "binary_sensor.storage_door",
|
||||
"trigger_state": "on",
|
||||
"actuator_entity_id": "light.storage",
|
||||
"target_state": "on",
|
||||
},
|
||||
)
|
||||
proposal_yaml = client.get("/v2/automations/proposals/proposal-1/yaml")
|
||||
approved = client.post("/v2/automations/proposals/proposal-1/approve")
|
||||
weights = client.post(
|
||||
"/v2/control/light.storage/weights",
|
||||
json={"sensor_weights": {"binary_sensor.storage_door": 1.0}, "note": "door"},
|
||||
)
|
||||
history = client.post(
|
||||
"/v2/history/analyze",
|
||||
json={"entity_ids": ["binary_sensor.storage_door"]},
|
||||
)
|
||||
models = client.post("/v2/models/train", json={"actuator_entity_id": "light.storage"})
|
||||
evaluation = client.post("/v2/models/evaluate", json={"actuator_entity_id": "light.storage"})
|
||||
evaluations = client.get("/v2/models/evaluations")
|
||||
autopilot = client.post("/v2/autopilot/run")
|
||||
candidates = client.get("/v2/autopilot/candidates")
|
||||
accepted = client.post(
|
||||
f"/v2/autopilot/candidates/{autopilot.json()['candidates'][0]['candidate_id']}/accept"
|
||||
)
|
||||
jobs = client.get("/v2/jobs")
|
||||
dashboard = client.get("/v2/dashboard")
|
||||
|
||||
assert entities.status_code == 200
|
||||
assert [item["entity_id"] for item in entities.json()] == [
|
||||
"binary_sensor.storage_door",
|
||||
"light.storage",
|
||||
"light.storage_door",
|
||||
]
|
||||
assert control.status_code == 200
|
||||
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 feedback.status_code == 200
|
||||
assert feedback.json()["feedback_negative"] == 1
|
||||
assert summary.status_code == 200
|
||||
assert summary.json()[0]["actuator_entity_id"] == "light.storage"
|
||||
assert parity.status_code == 200
|
||||
assert "Job-Queue" in parity.json()["implemented"]
|
||||
assert anomalies.status_code == 200
|
||||
assert proposal.status_code == 200
|
||||
assert proposal.json()["status"] == "draft"
|
||||
assert proposal_yaml.status_code == 200
|
||||
assert "alias: Storage light" in proposal_yaml.text
|
||||
assert approved.status_code == 200
|
||||
assert approved.json()["status"] == "approved"
|
||||
assert weights.status_code == 200
|
||||
assert weights.json()["sensor_weights"]["binary_sensor.storage_door"] == 1.0
|
||||
assert history.status_code == 200
|
||||
assert history.json()[0]["entity_id"] == "binary_sensor.storage_door"
|
||||
assert models.status_code == 200
|
||||
assert models.json()[0]["actuator_entity_id"] == "light.storage"
|
||||
assert evaluation.status_code == 200
|
||||
assert evaluation.json()[0]["verdict"] in {"bereit", "weiter testen"}
|
||||
assert evaluations.status_code == 200
|
||||
assert evaluations.json()
|
||||
assert autopilot.status_code == 200
|
||||
assert autopilot.json()["candidates"]
|
||||
assert all(
|
||||
candidate["actuator_entity_id"] != "switch.storage_motion"
|
||||
for candidate in autopilot.json()["candidates"]
|
||||
)
|
||||
assert candidates.status_code == 200
|
||||
assert accepted.status_code == 200
|
||||
assert jobs.status_code == 200
|
||||
assert jobs.json()
|
||||
assert dashboard.status_code == 200
|
||||
assert dashboard.json()["actuator_count"] == 2
|
||||
assert dashboard.json()["automation_proposals"] == 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