4 Commits

Author SHA1 Message Date
bf832b49f4 Filter autopilot actuator candidates 2026-06-18 18:43:53 +02:00
8057751c11 Filter autopilot trigger candidates 2026-06-18 17:30:48 +02:00
2f750e3e41 Add autopilot light workflow 2026-06-18 17:27:25 +02:00
6e6031cfda Add HA history and model evaluation 2026-06-18 17:08:23 +02:00
8 changed files with 478 additions and 17 deletions

View File

@@ -2,7 +2,7 @@ FROM python:3.13-slim
WORKDIR /app
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.11
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"

View File

@@ -1,5 +1,5 @@
name: SillyHome Future
version: "2.0.0-alpha.11"
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

View File

@@ -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

View File

@@ -34,6 +34,12 @@ class JobStatus(StrEnum):
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
@@ -123,6 +129,19 @@ class ModelRecord(BaseModel):
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
@@ -139,11 +158,35 @@ class SensorWeightOverride(BaseModel):
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
@@ -181,13 +224,16 @@ class LearningState(BaseModel):
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):

View File

@@ -16,9 +16,12 @@ from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
from app.core.handoff import HandoffMatrix
from app.core.models import (
AuditEvent,
AutopilotSettings,
AutomationProposal,
BackupBundle,
BehaviorPatternV2,
CandidateRecommendation,
CandidateStatus,
ControlProfile,
ControlState,
EntityState,
@@ -28,6 +31,7 @@ from app.core.models import (
JobStatus,
LearningProfile,
LearningState,
ModelEvaluation,
ModelRecord,
ProposalStatus,
RuntimeState,
@@ -104,38 +108,54 @@ class AutomationProposalRequest(BaseModel):
class HistoryAnalysisRequest(BaseModel):
entity_ids: list[str] = Field(default_factory=list)
start_time: datetime | None = None
end_time: datetime | None = None
class TrainModelRequest(BaseModel):
actuator_entity_id: str | None = None
class EvaluateModelRequest(BaseModel):
model_id: str | None = None
actuator_entity_id: str | None = None
class WeightOverrideRequest(BaseModel):
sensor_weights: dict[str, float] = Field(default_factory=dict)
note: str | None = Field(default=None, max_length=500)
class AutopilotRunResult(BaseModel):
candidates: list[CandidateRecommendation]
trained_models: list[ModelRecord]
evaluations: list[ModelEvaluation]
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
global ha_client
listener_task: asyncio.Task[None] | None = None
autopilot_task: asyncio.Task[None] | None = None
ha_client = _ha_client_from_env()
if ha_client is not None:
_load_initial_ha_states(ha_client)
listener_task = asyncio.create_task(_ha_listener_loop(ha_client))
autopilot_task = asyncio.create_task(_autopilot_loop())
try:
yield
finally:
if listener_task is not None:
listener_task.cancel()
with suppress(asyncio.CancelledError):
await listener_task
for task in (listener_task, autopilot_task):
if task is not None:
task.cancel()
with suppress(asyncio.CancelledError):
await task
app = FastAPI(
title="SillyHome Future API",
description="SillyHome v2 event-core side project.",
version="2.0.0-alpha.11",
version="2.0.0-alpha.15",
lifespan=lifespan,
)
@@ -179,7 +199,7 @@ def dashboard_data() -> dict[str, object]:
actuator_entities = [
entity
for entity in runtime.entities.values()
if entity.domain in {"light", "switch", "fan", "cover", "humidifier"}
if _is_good_actuator(entity.entity_id, entity.friendly_name)
]
return {
"websocket_status": runtime.websocket_status,
@@ -191,6 +211,9 @@ def dashboard_data() -> dict[str, object]:
"automation_proposals": len(learning.automation_proposals),
"models": len(learning.models),
"jobs": len(learning.jobs),
"candidates": len(learning.candidates),
"autopilot_enabled": control.autopilot.enabled,
"autopilot_last_run_at": control.autopilot.last_run_at,
"rooms": list(learning.rooms.values()),
"scenes": list(learning.scenes.values()),
"audit": latest_audit,
@@ -223,11 +246,87 @@ def feature_parity() -> dict[str, object]:
"lokales Modelltraining",
"Sensor-Gewichte",
"Job-Queue",
"Autopilot light",
"Kandidaten-Vorschlaege",
"periodisches Training/Bewertung",
],
"next_to_expand": ["echte Langzeit-History aus HA", "fortgeschrittene Modellbewertung"],
"next_to_expand": ["Dashboard-Flaechen fuer Autopilot/History/Modelle"],
}
@app.get("/v2/autopilot/settings", response_model=AutopilotSettings)
def get_autopilot_settings() -> AutopilotSettings:
return stores.control().autopilot
@app.put("/v2/autopilot/settings", response_model=AutopilotSettings)
def put_autopilot_settings(settings: AutopilotSettings) -> AutopilotSettings:
control = stores.control().model_copy(update={"autopilot": settings})
stores.save_control(control)
_append_audit(
"autopilot",
None,
"Autopilot light aktiviert." if settings.enabled else "Autopilot light deaktiviert.",
)
return settings
@app.post("/v2/autopilot/run", response_model=AutopilotRunResult)
def run_autopilot() -> AutopilotRunResult:
return _run_autopilot_once()
@app.get("/v2/autopilot/candidates", response_model=list[CandidateRecommendation])
def list_candidates() -> list[CandidateRecommendation]:
return list(stores.learning().candidates.values())
@app.post("/v2/autopilot/candidates/{candidate_id}/accept", response_model=LearningProfile)
def accept_candidate(candidate_id: str) -> LearningProfile:
state = stores.learning()
candidate = state.candidates[candidate_id].model_copy(
update={"status": CandidateStatus.ACCEPTED}
)
state.candidates[candidate_id] = candidate
profile = state.profiles.get(
candidate.actuator_entity_id,
LearningProfile(actuator_entity_id=candidate.actuator_entity_id),
)
profile = profile.model_copy(
update={
"patterns": [
*profile.patterns,
BehaviorPatternV2(
actuator_entity_id=candidate.actuator_entity_id,
target_state=candidate.target_state,
trigger_entity_id=candidate.trigger_entity_id,
trigger_state=candidate.trigger_state,
support=3,
confidence=candidate.confidence,
source="autopilot",
),
],
"model_version": "autopilot-light",
}
)
state.profiles[candidate.actuator_entity_id] = profile
stores.save_learning(state)
_append_job("autopilot_candidate", f"Kandidat {candidate_id} akzeptiert.")
return profile
@app.post("/v2/autopilot/candidates/{candidate_id}/dismiss", response_model=CandidateRecommendation)
def dismiss_candidate(candidate_id: str) -> CandidateRecommendation:
state = stores.learning()
candidate = state.candidates[candidate_id].model_copy(
update={"status": CandidateStatus.DISMISSED}
)
state.candidates[candidate_id] = candidate
stores.save_learning(state)
_append_job("autopilot_candidate", f"Kandidat {candidate_id} verworfen.")
return candidate
@app.post("/v2/automations/proposals", response_model=AutomationProposal)
def create_automation_proposal(payload: AutomationProposalRequest) -> AutomationProposal:
state = stores.learning()
@@ -287,16 +386,23 @@ def automation_proposal_yaml(proposal_id: str) -> str:
def analyze_history(request: HistoryAnalysisRequest) -> list[HistoryAnalysis]:
runtime = stores.runtime()
ids = request.entity_ids or list(runtime.entities)[:50]
history: dict[str, list[StateEvent]] = {}
if ha_client is not None and request.start_time is not None and request.end_time is not None:
history = ha_client.read_history(ids, request.start_time, request.end_time)
result: list[HistoryAnalysis] = []
for entity_id in ids:
entity = runtime.entities.get(entity_id)
samples = history.get(entity_id, [])
matching_audit = [item for item in runtime.audit if item.entity_id == entity_id]
states = [sample.new_state for sample in samples if sample.new_state is not None]
result.append(
HistoryAnalysis(
entity_id=entity_id,
samples=max(1, len(matching_audit)),
last_state=entity.state if entity else None,
changed_at=entity.changed_at if entity else None,
samples=max(1, len(samples) or len(matching_audit)),
last_state=states[-1] if states else (entity.state if entity else None),
changed_at=samples[-1].changed_at if samples else (entity.changed_at if entity else None),
unique_states=len(set(states)) if states else int(entity is not None),
transitions=_count_transitions(states),
recommendation=(
"Als Trigger geeignet."
if entity is not None and entity.domain in {"binary_sensor", "sensor"}
@@ -341,6 +447,31 @@ def list_models() -> list[ModelRecord]:
return list(stores.learning().models.values())
@app.post("/v2/models/evaluate", response_model=list[ModelEvaluation])
def evaluate_models(request: EvaluateModelRequest) -> list[ModelEvaluation]:
state = stores.learning()
control = stores.control()
models = list(state.models.values())
if request.model_id:
models = [model for model in models if model.model_id == request.model_id]
if request.actuator_entity_id:
models = [model for model in models if model.actuator_entity_id == request.actuator_entity_id]
evaluations = [
_evaluate_model(model, state, control)
for model in models
]
for evaluation in evaluations:
state.model_evaluations[evaluation.evaluation_id] = evaluation
stores.save_learning(state)
_append_job("model_evaluation", f"{len(evaluations)} Modelle bewertet.")
return evaluations
@app.get("/v2/models/evaluations", response_model=list[ModelEvaluation])
def list_model_evaluations() -> list[ModelEvaluation]:
return list(stores.learning().model_evaluations.values())[-100:]
@app.get("/v2/jobs", response_model=list[JobQueueItem])
def list_jobs() -> list[JobQueueItem]:
return list(stores.learning().jobs.values())[-100:]
@@ -805,6 +936,158 @@ async def _ha_listener_loop(client: FutureHaClient) -> None:
await asyncio.sleep(2)
async def _autopilot_loop() -> None:
while True:
control = stores.control()
settings = control.autopilot
if settings.enabled:
await asyncio.to_thread(_run_autopilot_once)
await asyncio.sleep(stores.control().autopilot.interval_seconds)
def _run_autopilot_once() -> AutopilotRunResult:
control = stores.control()
settings = control.autopilot
if not settings.enabled:
return AutopilotRunResult(candidates=[], trained_models=[], evaluations=[])
learning = stores.learning()
learning.candidates = {
candidate_id: candidate
for candidate_id, candidate in learning.candidates.items()
if _is_good_actuator(candidate.actuator_entity_id, None)
and _is_good_trigger_id(candidate.trigger_entity_id)
}
candidates = _generate_candidates(settings)
for candidate in candidates:
learning.candidates[candidate.candidate_id] = candidate
stores.save_learning(learning)
updated_settings = settings.model_copy(update={"last_run_at": datetime.now(timezone.utc)})
stores.save_control(control.model_copy(update={"autopilot": updated_settings}))
trained = train_models(TrainModelRequest()) if settings.auto_train else []
evaluations = evaluate_models(EvaluateModelRequest()) if settings.auto_evaluate else []
_append_job("autopilot", f"Autopilot light: {len(candidates)} Kandidaten erzeugt.")
return AutopilotRunResult(candidates=candidates, trained_models=trained, evaluations=evaluations)
def _generate_candidates(settings: AutopilotSettings) -> list[CandidateRecommendation]:
runtime = stores.runtime()
existing = stores.learning().candidates
actuators = [
entity
for entity in runtime.entities.values()
if _is_good_actuator(entity.entity_id, entity.friendly_name)
][:150]
triggers = [
entity
for entity in runtime.entities.values()
if entity.domain == "binary_sensor" and _is_good_trigger_id(entity.entity_id)
][:500]
result: list[CandidateRecommendation] = []
for actuator in actuators:
best_trigger: EntityState | None = None
best_score = 0.0
best_reason = ""
for trigger in triggers:
score, reason = _candidate_score(actuator, trigger)
if score > best_score:
best_score = score
best_reason = reason
best_trigger = trigger
if best_trigger is None or best_score < settings.min_candidate_confidence:
continue
candidate_id = f"{actuator.entity_id}:{best_trigger.entity_id}:on"
if candidate_id in existing:
continue
result.append(
CandidateRecommendation(
candidate_id=candidate_id,
actuator_entity_id=actuator.entity_id,
trigger_entity_id=best_trigger.entity_id,
trigger_state="on" if best_trigger.domain == "binary_sensor" else None,
target_state="open" if actuator.domain == "cover" else "on",
confidence=round(best_score, 2),
reason=best_reason,
)
)
if len(result) >= 20:
break
return result
def _candidate_score(actuator: EntityState, trigger: EntityState) -> tuple[float, str]:
if actuator.area_name and actuator.area_name == trigger.area_name:
return 0.82, f"Gleicher Raum: {actuator.area_name}"
actuator_tokens = _entity_tokens(actuator)
trigger_tokens = _entity_tokens(trigger)
overlap = actuator_tokens & trigger_tokens
if overlap:
return min(0.78, 0.55 + (0.08 * len(overlap))), (
"Aehnliche Namen: " + ", ".join(sorted(overlap)[:4])
)
if trigger.domain == "binary_sensor" and actuator.domain in {"light", "switch"}:
return 0.62, "Binary-Sensor passt grundsaetzlich zu Licht/Schalter."
return 0.0, ""
def _is_good_trigger_id(entity_id: str) -> bool:
raw = entity_id.lower()
bad = {
"battery",
"batterie",
"low",
"update",
"problem",
"connectivity",
"linkquality",
"tamper",
}
good = {
"door",
"tuer",
"ture",
"window",
"fenster",
"motion",
"pir",
"occupancy",
"presence",
"kontakt",
"contact",
}
return not any(token in raw for token in bad) and any(token in raw for token in good)
def _is_good_actuator(entity_id: str, friendly_name: str | None) -> bool:
domain = entity_id.split(".", 1)[0]
if domain not in {"light", "switch", "fan", "cover", "humidifier"}:
return False
if domain != "switch":
return True
raw = f"{entity_id} {friendly_name or ''}".lower()
bad = {
"alarm",
"battery",
"batterie",
"detection",
"linkquality",
"low",
"motion",
"occupancy",
"people",
"presence",
"problem",
"tamper",
"trigger",
"update",
}
return not any(token in raw for token in bad)
def _entity_tokens(entity: EntityState) -> set[str]:
raw = f"{entity.entity_id} {entity.friendly_name or ''}".lower()
return {part for part in raw.replace(".", "_").split("_") if len(part) >= 4}
def _set_websocket_status(status: str) -> None:
runtime = stores.runtime()
if runtime.websocket_status == status:
@@ -884,6 +1167,64 @@ def _decide_automation_proposal(
return updated
def _count_transitions(states: list[str]) -> int:
if not states:
return 0
transitions = 0
previous = states[0]
for state in states[1:]:
transitions += int(state != previous)
previous = state
return transitions
def _evaluate_model(
model: ModelRecord,
learning: LearningState,
control: ControlState,
) -> ModelEvaluation:
profile = learning.profiles.get(
model.actuator_entity_id,
LearningProfile(actuator_entity_id=model.actuator_entity_id),
)
control_profile = control.profiles.get(
model.actuator_entity_id,
ControlProfile(actuator_entity_id=model.actuator_entity_id),
)
coverage = min(1.0, model.pattern_count / 5)
dry_run_events = max(1, control_profile.dry_run_events)
dry_run_success_rate = control_profile.dry_run_successes / dry_run_events
feedback_total = profile.feedback_positive + profile.feedback_negative
feedback_score = (
profile.feedback_positive / feedback_total if feedback_total else 0.5
)
score = round(
(model.confidence * 0.35)
+ (coverage * 0.2)
+ (dry_run_success_rate * 0.3)
+ (feedback_score * 0.15),
4,
)
reasons = [
f"Modell-Confidence {model.confidence:.0%}",
f"Pattern-Abdeckung {coverage:.0%}",
f"Dry-run-Erfolg {dry_run_success_rate:.0%}",
f"Feedback-Score {feedback_score:.0%}",
]
verdict = "bereit" if score >= 0.82 and control_profile.active_ready else "weiter testen"
return ModelEvaluation(
evaluation_id=f"eval-{model.model_id}-{len(learning.model_evaluations) + 1}",
model_id=model.model_id,
actuator_entity_id=model.actuator_entity_id,
score=score,
coverage=coverage,
dry_run_success_rate=dry_run_success_rate,
feedback_score=feedback_score,
verdict=verdict,
reasons=reasons,
)
def _execute_ha_decision(decision: object) -> bool:
if ha_client is None or not hasattr(decision, "actuator_entity_id"):
return False
@@ -1069,6 +1410,8 @@ def _dashboard_html() -> str:
['Vorschlaege', data.automation_proposals],
['Modelle', data.models],
['Jobs', data.jobs],
['Kandidaten', data.candidates],
['Autopilot', data.autopilot_enabled ? 'aktiv' : 'aus'],
['Räume', data.rooms.length],
['Szenen', data.scenes.length],
];

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-future"
version = "2.0.0-alpha.11"
version = "2.0.0-alpha.15"
description = "SillyHome v2 event-core prototype"
requires-python = ">=3.11"
dependencies = [

View File

@@ -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.11"
assert config["version"] == "2.0.0-alpha.15"
assert config["ingress"] is True
assert config["ingress_port"] == 8099
assert config["homeassistant_api"] is True

View File

@@ -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.11"
assert health.json()["version"] == "2.0.0-alpha.15"
assert backup.status_code == 200
assert restore.status_code == 200
assert restore.json() == {"status": "restored"}
@@ -39,6 +39,19 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
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",
),
}
)
@@ -102,6 +115,13 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
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")
@@ -109,6 +129,7 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
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"
@@ -141,8 +162,20 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
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"] == 1
assert dashboard.json()["actuator_count"] == 2
assert dashboard.json()["automation_proposals"] == 1