5 Commits

Author SHA1 Message Date
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
07e3e96c30 Add Future parity feature blocks 2026-06-18 16:20:30 +02:00
e4b860571a Enable watchdog and localize dashboard 2026-06-18 14:26:26 +02:00
8 changed files with 958 additions and 32 deletions

View File

@@ -2,7 +2,7 @@ FROM python:3.13-slim
WORKDIR /app WORKDIR /app
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.9 ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.14
RUN python -m pip install --no-cache-dir \ RUN python -m pip install --no-cache-dir \
"http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz" "http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz"

View File

@@ -1,12 +1,12 @@
name: SillyHome Future name: SillyHome Future
version: "2.0.0-alpha.9" version: "2.0.0-alpha.14"
slug: sillyhome_future slug: sillyhome_future
description: Event-first SillyHome v2 test controller description: Event-first SillyHome v2 test controller
url: http://192.168.6.31:3000/Otto/sillyhome-future url: http://192.168.6.31:3000/Otto/sillyhome-future
arch: arch:
- amd64 - amd64
startup: application startup: application
boot: manual boot: auto
watchdog: http://[HOST]:[PORT:8099]/health watchdog: http://[HOST]:[PORT:8099]/health
init: false init: false
ingress: true ingress: true

View File

@@ -72,6 +72,46 @@ class FutureHaClient:
) )
response.raise_for_status() 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]: async def listen_state_events(self) -> AsyncIterator[StateEvent]:
websocket_url = self._config.websocket_url or _default_websocket_url(self._core_url) websocket_url = self._config.websocket_url or _default_websocket_url(self._core_url)
async with websockets.connect(websocket_url, ping_interval=None) as websocket: 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: if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc) return parsed.replace(tzinfo=timezone.utc)
return parsed return parsed

View File

@@ -21,6 +21,25 @@ class SafetyStage(StrEnum):
BLOCKED = "blocked" 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): class EntityState(BaseModel):
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
domain: str domain: str
@@ -89,6 +108,88 @@ class SceneProfile(BaseModel):
confidence: float = Field(default=0.0, ge=0.0, le=1.0) 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): class Decision(BaseModel):
actuator_entity_id: str actuator_entity_id: str
target_state: str | None = None target_state: str | None = None
@@ -121,11 +222,18 @@ class LearningState(BaseModel):
profiles: dict[str, LearningProfile] = Field(default_factory=dict) profiles: dict[str, LearningProfile] = Field(default_factory=dict)
rooms: dict[str, RoomProfile] = Field(default_factory=dict) rooms: dict[str, RoomProfile] = Field(default_factory=dict)
scenes: dict[str, SceneProfile] = 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): class ControlState(BaseModel):
profiles: dict[str, ControlProfile] = Field(default_factory=dict) profiles: dict[str, ControlProfile] = Field(default_factory=dict)
global_enabled: bool = True global_enabled: bool = True
autopilot: AutopilotSettings = Field(default_factory=AutopilotSettings)
class BackupBundle(BaseModel): class BackupBundle(BaseModel):

View File

@@ -4,6 +4,7 @@ import asyncio
import os import os
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from datetime import datetime, timezone
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -15,16 +16,27 @@ from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
from app.core.handoff import HandoffMatrix from app.core.handoff import HandoffMatrix
from app.core.models import ( from app.core.models import (
AuditEvent, AuditEvent,
AutopilotSettings,
AutomationProposal,
BackupBundle, BackupBundle,
BehaviorPatternV2, BehaviorPatternV2,
CandidateRecommendation,
CandidateStatus,
ControlProfile, ControlProfile,
ControlState, ControlState,
EntityState, EntityState,
HistoryAnalysis,
HandoffMode, HandoffMode,
JobQueueItem,
JobStatus,
LearningProfile, LearningProfile,
LearningState, LearningState,
ModelEvaluation,
ModelRecord,
ProposalStatus,
RuntimeState, RuntimeState,
SafetyStage, SafetyStage,
SensorWeightOverride,
StateEvent, StateEvent,
) )
from app.core.stores import FutureStores from app.core.stores import FutureStores
@@ -70,27 +82,80 @@ class ActiveReadiness(BaseModel):
dry_run_failures: int dry_run_failures: int
class FeedbackRequest(BaseModel):
kind: str = Field(default="correct", max_length=40)
expected_state: str | None = Field(default=None, max_length=100)
note: str | None = Field(default=None, max_length=500)
class ActuatorSummary(BaseModel):
actuator_entity_id: str
stage: SafetyStage
handoff_mode: HandoffMode
active_ready: bool
pattern_count: int
feedback_positive: int
feedback_negative: int
class AutomationProposalRequest(BaseModel):
name: str = Field(max_length=120)
trigger_entity_id: str
trigger_state: str | None = None
actuator_entity_id: str
target_state: str
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 @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
global ha_client global ha_client
listener_task: asyncio.Task[None] | None = None listener_task: asyncio.Task[None] | None = None
autopilot_task: asyncio.Task[None] | None = None
ha_client = _ha_client_from_env() ha_client = _ha_client_from_env()
if ha_client is not None: if ha_client is not None:
_load_initial_ha_states(ha_client) _load_initial_ha_states(ha_client)
listener_task = asyncio.create_task(_ha_listener_loop(ha_client)) listener_task = asyncio.create_task(_ha_listener_loop(ha_client))
autopilot_task = asyncio.create_task(_autopilot_loop())
try: try:
yield yield
finally: finally:
if listener_task is not None: for task in (listener_task, autopilot_task):
listener_task.cancel() if task is not None:
with suppress(asyncio.CancelledError): task.cancel()
await listener_task with suppress(asyncio.CancelledError):
await task
app = FastAPI( app = FastAPI(
title="SillyHome Future API", title="SillyHome Future API",
description="SillyHome v2 event-core side project.", description="SillyHome v2 event-core side project.",
version="2.0.0-alpha.9", version="2.0.0-alpha.14",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -143,12 +208,275 @@ def dashboard_data() -> dict[str, object]:
"global_enabled": control.global_enabled, "global_enabled": control.global_enabled,
"learning_profiles": len(learning.profiles), "learning_profiles": len(learning.profiles),
"control_profiles": len(control.profiles), "control_profiles": len(control.profiles),
"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()), "rooms": list(learning.rooms.values()),
"scenes": list(learning.scenes.values()), "scenes": list(learning.scenes.values()),
"audit": latest_audit, "audit": latest_audit,
} }
@app.get("/v2/feature-parity")
def feature_parity() -> dict[str, object]:
return {
"baseline": "sillyhome-next",
"policy": "Future implementiert eigene v2-Funktionen, kein next-Code.",
"implemented": [
"HA REST/WebSocket",
"Service-Ausfuehrung",
"Dashboard",
"Aktor-Control",
"Lernmuster",
"Dry-run",
"Safety-Gates",
"Feedback",
"Backup/Restore",
"Raeume/Szenen",
"Not-Aus",
"Simulation",
"Audit",
"Health",
"Automation-Proposals",
"YAML-Export",
"History-Analyse",
"lokales Modelltraining",
"Sensor-Gewichte",
"Job-Queue",
"Autopilot light",
"Kandidaten-Vorschlaege",
"periodisches Training/Bewertung",
],
"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()
proposal_id = f"proposal-{len(state.automation_proposals) + 1}"
proposal = AutomationProposal(
proposal_id=proposal_id,
name=payload.name,
trigger_entity_id=payload.trigger_entity_id,
trigger_state=payload.trigger_state,
actuator_entity_id=payload.actuator_entity_id,
target_state=payload.target_state,
)
state.automation_proposals[proposal_id] = proposal
stores.save_learning(state)
_append_job("automation_proposal", f"Automation-Vorschlag {proposal_id} angelegt.")
return proposal
@app.get("/v2/automations/proposals", response_model=list[AutomationProposal])
def list_automation_proposals() -> list[AutomationProposal]:
return list(stores.learning().automation_proposals.values())
@app.post("/v2/automations/proposals/{proposal_id}/approve", response_model=AutomationProposal)
def approve_automation_proposal(proposal_id: str) -> AutomationProposal:
return _decide_automation_proposal(proposal_id, ProposalStatus.APPROVED)
@app.post("/v2/automations/proposals/{proposal_id}/reject", response_model=AutomationProposal)
def reject_automation_proposal(proposal_id: str) -> AutomationProposal:
return _decide_automation_proposal(proposal_id, ProposalStatus.REJECTED)
@app.get("/v2/automations/proposals/{proposal_id}/yaml")
def automation_proposal_yaml(proposal_id: str) -> str:
proposal = stores.learning().automation_proposals[proposal_id]
return "\n".join(
[
f"alias: {proposal.name}",
"trigger:",
" - platform: state",
f" entity_id: {proposal.trigger_entity_id}",
f" to: {proposal.trigger_state or ''}",
"action:",
" - service: homeassistant.turn_on",
" target:",
f" entity_id: {proposal.actuator_entity_id}",
" data:",
f" target_state: {proposal.target_state}",
"mode: single",
"",
]
)
@app.post("/v2/history/analyze", response_model=list[HistoryAnalysis])
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(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"}
else "Als Aktor oder Kontext pruefen."
),
)
)
_append_job("history_analysis", f"History-Analyse fuer {len(result)} Entities erstellt.")
return result
@app.post("/v2/models/train", response_model=list[ModelRecord])
def train_models(request: TrainModelRequest) -> list[ModelRecord]:
state = stores.learning()
actuator_ids = [request.actuator_entity_id] if request.actuator_entity_id else list(state.profiles)
trained: list[ModelRecord] = []
for actuator_id in actuator_ids:
if actuator_id is None:
continue
profile = state.profiles.get(actuator_id)
pattern_count = len(profile.patterns) if profile else 0
confidence = (
sum(pattern.confidence for pattern in profile.patterns) / pattern_count
if profile and pattern_count
else 0.0
)
model = ModelRecord(
model_id=f"model-{actuator_id}-{len(state.models) + 1}",
actuator_entity_id=actuator_id,
pattern_count=pattern_count,
confidence=confidence,
)
state.models[model.model_id] = model
trained.append(model)
stores.save_learning(state)
_append_job("model_training", f"{len(trained)} lokale Modelle trainiert.")
return trained
@app.get("/v2/models", response_model=list[ModelRecord])
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:]
@app.post("/v2/events/state", response_model=list[AuditEvent]) @app.post("/v2/events/state", response_model=list[AuditEvent])
def ingest_state_event(event: StateEvent) -> list[AuditEvent]: def ingest_state_event(event: StateEvent) -> list[AuditEvent]:
return event_core.process_state_event(event, execute=_execute_ha_decision) return event_core.process_state_event(event, execute=_execute_ha_decision)
@@ -203,6 +531,45 @@ def get_control() -> ControlState:
return stores.control() return stores.control()
@app.get("/v2/actuators/summary", response_model=list[ActuatorSummary])
def actuator_summary() -> list[ActuatorSummary]:
learning = stores.learning()
control = stores.control()
ids = sorted(set(learning.profiles) | set(control.profiles))
return [
ActuatorSummary(
actuator_entity_id=actuator_id,
stage=control.profiles.get(
actuator_id,
ControlProfile(actuator_entity_id=actuator_id),
).stage,
handoff_mode=control.profiles.get(
actuator_id,
ControlProfile(actuator_entity_id=actuator_id),
).handoff_mode,
active_ready=control.profiles.get(
actuator_id,
ControlProfile(actuator_entity_id=actuator_id),
).active_ready,
pattern_count=len(
learning.profiles.get(
actuator_id,
LearningProfile(actuator_entity_id=actuator_id),
).patterns
),
feedback_positive=learning.profiles.get(
actuator_id,
LearningProfile(actuator_entity_id=actuator_id),
).feedback_positive,
feedback_negative=learning.profiles.get(
actuator_id,
LearningProfile(actuator_entity_id=actuator_id),
).feedback_negative,
)
for actuator_id in ids
]
@app.post("/v2/control/global", response_model=ControlState) @app.post("/v2/control/global", response_model=ControlState)
def set_global_control(update: GlobalControlUpdate) -> ControlState: def set_global_control(update: GlobalControlUpdate) -> ControlState:
state = stores.control().model_copy(update={"global_enabled": update.enabled}) state = stores.control().model_copy(update={"global_enabled": update.enabled})
@@ -305,6 +672,118 @@ def create_learning_pattern(
return updated return updated
@app.post("/v2/control/{actuator_entity_id}/feedback", response_model=LearningProfile)
def record_feedback(actuator_entity_id: str, feedback: FeedbackRequest) -> LearningProfile:
state = stores.learning()
profile = state.profiles.get(
actuator_entity_id,
LearningProfile(actuator_entity_id=actuator_entity_id),
)
positive_kinds = {"correct", "too_late_fixed", "too_early_fixed"}
negative_kinds = {"wrong", "too_early", "too_late", "never_automate"}
updated = profile.model_copy(
update={
"feedback_positive": profile.feedback_positive
+ int(feedback.kind in positive_kinds),
"feedback_negative": profile.feedback_negative
+ int(feedback.kind in negative_kinds),
}
)
state.profiles[actuator_entity_id] = updated
stores.save_learning(state)
if feedback.kind == "never_automate":
control = stores.control()
control.profiles[actuator_entity_id] = control.profiles.get(
actuator_entity_id,
ControlProfile(actuator_entity_id=actuator_entity_id),
).model_copy(update={"manual_block": True, "stage": SafetyStage.BLOCKED})
stores.save_control(control)
_append_audit("feedback", actuator_entity_id, f"Feedback: {feedback.kind}")
return updated
@app.post("/v2/control/{actuator_entity_id}/weights", response_model=SensorWeightOverride)
def set_weight_override(
actuator_entity_id: str,
payload: WeightOverrideRequest,
) -> SensorWeightOverride:
state = stores.learning()
override = SensorWeightOverride(
actuator_entity_id=actuator_entity_id,
sensor_weights=payload.sensor_weights,
note=payload.note,
)
state.weight_overrides[actuator_entity_id] = override
stores.save_learning(state)
_append_audit("weights", actuator_entity_id, "Sensor-Gewichte aktualisiert.")
return override
@app.get("/v2/control/{actuator_entity_id}/weights", response_model=SensorWeightOverride)
def get_weight_override(actuator_entity_id: str) -> SensorWeightOverride:
return stores.learning().weight_overrides.get(
actuator_entity_id,
SensorWeightOverride(actuator_entity_id=actuator_entity_id),
)
@app.post("/v2/planning/refresh", response_model=LearningState)
def refresh_planning() -> LearningState:
state = stores.learning()
runtime = stores.runtime()
rooms = dict(state.rooms)
scenes = dict(state.scenes)
for entity in runtime.entities.values():
room_name = entity.area_name
if not room_name:
continue
room_id = room_name.lower().replace(" ", "_")
room = rooms.get(room_id)
actuator_ids = []
context_ids = []
if entity.domain in {"light", "switch", "fan", "cover", "humidifier"}:
actuator_ids.append(entity.entity_id)
else:
context_ids.append(entity.entity_id)
if room is None:
from app.core.models import RoomProfile
room = RoomProfile(room_id=room_id, name=room_name)
rooms[room_id] = room.model_copy(
update={
"actuator_entity_ids": sorted(
set(room.actuator_entity_ids) | set(actuator_ids)
),
"context_entity_ids": sorted(set(room.context_entity_ids) | set(context_ids)),
}
)
state = state.model_copy(update={"rooms": rooms, "scenes": scenes})
stores.save_learning(state)
_append_audit("planning", None, "Planung aktualisiert.")
return state
@app.get("/v2/anomalies")
def anomalies() -> list[dict[str, object]]:
runtime = stores.runtime()
result: list[dict[str, object]] = []
unavailable = [
entity.entity_id
for entity in runtime.entities.values()
if entity.state in {"unavailable", "unknown"}
][:100]
if unavailable:
result.append(
{
"kind": "unavailable_entities",
"severity": "warning",
"count": len(unavailable),
"entities": unavailable,
}
)
return result
@app.post("/v2/simulate") @app.post("/v2/simulate")
def simulate_decision(request: SimulationRequest) -> dict[str, object]: def simulate_decision(request: SimulationRequest) -> dict[str, object]:
runtime = stores.runtime() runtime = stores.runtime()
@@ -457,6 +936,131 @@ async def _ha_listener_loop(client: FutureHaClient) -> None:
await asyncio.sleep(2) 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_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 entity.domain in {"light", "switch", "fan", "cover", "humidifier"}
][: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 _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: def _set_websocket_status(status: str) -> None:
runtime = stores.runtime() runtime = stores.runtime()
if runtime.websocket_status == status: if runtime.websocket_status == status:
@@ -503,6 +1107,97 @@ def _append_audit(kind: str, entity_id: str | None, message: str) -> None:
stores.save_runtime(runtime) stores.save_runtime(runtime)
def _append_job(kind: str, message: str, status: JobStatus = JobStatus.SUCCEEDED) -> JobQueueItem:
state = stores.learning()
job = JobQueueItem(
job_id=f"job-{len(state.jobs) + 1}",
kind=kind,
status=status,
message=message,
finished_at=datetime.now(timezone.utc) if status is JobStatus.SUCCEEDED else None,
)
state.jobs[job.job_id] = job
stores.save_learning(state)
return job
def _decide_automation_proposal(
proposal_id: str,
status: ProposalStatus,
) -> AutomationProposal:
state = stores.learning()
proposal = state.automation_proposals[proposal_id]
updated = proposal.model_copy(
update={
"status": status,
"revision": proposal.revision + 1,
"decided_at": datetime.now(timezone.utc),
}
)
state.automation_proposals[proposal_id] = updated
stores.save_learning(state)
_append_job("automation_proposal", f"Automation-Vorschlag {proposal_id}: {status}.")
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: def _execute_ha_decision(decision: object) -> bool:
if ha_client is None or not hasattr(decision, "actuator_entity_id"): if ha_client is None or not hasattr(decision, "actuator_entity_id"):
return False return False
@@ -554,37 +1249,37 @@ def _dashboard_html() -> str:
<section class="grid" id="metrics"></section> <section class="grid" id="metrics"></section>
<section class="split"> <section class="split">
<div class="card"> <div class="card">
<h2>Control</h2> <h2>Steuerung</h2>
<label for="entitySearch">Suche</label> <label for="entitySearch">Suche</label>
<input id="entitySearch" placeholder="light., switch., sensor..." autocomplete="off"> <input id="entitySearch" placeholder="light., switch., sensor..." autocomplete="off">
<label for="actuator">Aktor</label> <label for="actuator">Aktor</label>
<select id="actuator"></select> <select id="actuator"></select>
<div class="row"> <div class="row">
<div> <div>
<label for="minConfidence">Min. Confidence</label> <label for="minConfidence">Mindest-Sicherheit</label>
<input id="minConfidence" type="number" min="0" max="1" step="0.01" value="0.82"> <input id="minConfidence" type="number" min="0" max="1" step="0.01" value="0.82">
</div> </div>
<div> <div>
<label for="cooldown">Cooldown Sekunden</label> <label for="cooldown">Sperrzeit in Sekunden</label>
<input id="cooldown" type="number" min="0" step="30" value="900"> <input id="cooldown" type="number" min="0" step="30" value="900">
</div> </div>
</div> </div>
<label><input id="manualBlock" type="checkbox" style="width:auto;margin-right:6px"> Manuell blockieren</label> <label><input id="manualBlock" type="checkbox" style="width:auto;margin-right:6px"> Manuell blockieren</label>
<div class="stages" id="stages"></div> <div class="stages" id="stages"></div>
<button class="primary" id="saveControl">Control speichern</button> <button class="primary" id="saveControl">Steuerung speichern</button>
<button id="globalToggle">Globaler Not-Aus</button> <button id="globalToggle">Globaler Not-Aus</button>
<div class="status" id="readiness">Ready-Status wird geladen...</div> <div class="status" id="readiness">Ready-Status wird geladen...</div>
<div class="status" id="controlStatus"></div> <div class="status" id="controlStatus"></div>
</div> </div>
<div class="card"> <div class="card">
<h2>Learning Pattern</h2> <h2>Lernmuster</h2>
<div class="row"> <div class="row">
<div> <div>
<label for="trigger">Trigger</label> <label for="trigger">Trigger</label>
<select id="trigger"></select> <select id="trigger"></select>
</div> </div>
<div> <div>
<label for="triggerState">Trigger State</label> <label for="triggerState">Trigger-Zustand</label>
<input id="triggerState" placeholder="on, off, open..."> <input id="triggerState" placeholder="on, off, open...">
</div> </div>
</div> </div>
@@ -594,22 +1289,28 @@ def _dashboard_html() -> str:
<input id="targetState" value="on"> <input id="targetState" value="on">
</div> </div>
<div> <div>
<label for="patternConfidence">Confidence</label> <label for="patternConfidence">Sicherheit</label>
<input id="patternConfidence" type="number" min="0" max="1" step="0.01" value="0.9"> <input id="patternConfidence" type="number" min="0" max="1" step="0.01" value="0.9">
</div> </div>
</div> </div>
<button class="primary" id="addPattern">Pattern anlegen</button> <button class="primary" id="addPattern">Lernmuster anlegen</button>
<button id="simulatePattern">Pattern simulieren</button> <button id="simulatePattern">Lernmuster simulieren</button>
<div class="status" id="patternStatus"></div> <div class="status" id="patternStatus"></div>
<h2>Aktuelles Profil</h2> <h2>Aktuelles Profil</h2>
<pre id="profile">Lade...</pre> <pre id="profile">Lade...</pre>
</div> </div>
</section> </section>
<h2>Audit</h2> <h2>Prüfprotokoll</h2>
<pre id="audit">Lade...</pre> <pre id="audit">Lade...</pre>
</main> </main>
<script> <script>
const stages = ['observe', 'dry_run', 'active', 'blocked']; const stages = ['observe', 'dry_run', 'active', 'blocked'];
const stageLabels = {
observe: 'Beobachten',
dry_run: 'Testlauf',
active: 'Aktiv',
blocked: 'Gesperrt',
};
const state = { entities: [], control: {}, learning: {}, selectedStage: 'observe' }; const state = { entities: [], control: {}, learning: {}, selectedStage: 'observe' };
function optionText(entity) { function optionText(entity) {
@@ -625,7 +1326,7 @@ def _dashboard_html() -> str:
function renderStages() { function renderStages() {
document.getElementById('stages').innerHTML = stages.map(stage => document.getElementById('stages').innerHTML = stages.map(stage =>
`<button type="button" data-stage="${stage}" class="${stage === state.selectedStage ? 'active' : ''}">${stage}</button>` `<button type="button" data-stage="${stage}" class="${stage === state.selectedStage ? 'active' : ''}">${stageLabels[stage]}</button>`
).join(''); ).join('');
document.querySelectorAll('[data-stage]').forEach(button => { document.querySelectorAll('[data-stage]').forEach(button => {
button.onclick = () => { state.selectedStage = button.dataset.stage; renderStages(); }; button.onclick = () => { state.selectedStage = button.dataset.stage; renderStages(); };
@@ -674,13 +1375,18 @@ def _dashboard_html() -> str:
state.learning = await learningResponse.json(); state.learning = await learningResponse.json();
const metrics = [ const metrics = [
['WebSocket', data.websocket_status], ['WebSocket', data.websocket_status],
['Entities', data.entity_count], ['Entitäten', data.entity_count],
['Actuators', data.actuator_count], ['Aktoren', data.actuator_count],
['Global', data.global_enabled ? 'on' : 'off'], ['Not-Aus', data.global_enabled ? 'frei' : 'aktiv'],
['Learning', data.learning_profiles], ['Lernen', data.learning_profiles],
['Control', data.control_profiles], ['Steuerung', data.control_profiles],
['Rooms', data.rooms.length], ['Vorschlaege', data.automation_proposals],
['Scenes', data.scenes.length], ['Modelle', data.models],
['Jobs', data.jobs],
['Kandidaten', data.candidates],
['Autopilot', data.autopilot_enabled ? 'aktiv' : 'aus'],
['Räume', data.rooms.length],
['Szenen', data.scenes.length],
]; ];
document.getElementById('metrics').innerHTML = metrics.map(([label, value]) => document.getElementById('metrics').innerHTML = metrics.map(([label, value]) =>
`<div class="card"><div class="label">${label}</div><div class="value">${value}</div></div>` `<div class="card"><div class="label">${label}</div><div class="value">${value}</div></div>`
@@ -696,7 +1402,7 @@ def _dashboard_html() -> str:
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/readiness`); const response = await fetch(`/v2/control/${encodeURIComponent(id)}/readiness`);
const data = await response.json(); const data = await response.json();
document.getElementById('readiness').textContent = document.getElementById('readiness').textContent =
`${data.ready ? 'ready for active' : 'nicht active-ready'} - ${data.reason}`; `${data.ready ? 'bereit fuer Aktiv' : 'nicht bereit fuer Aktiv'} - ${data.reason}`;
} }
document.getElementById('entitySearch').oninput = renderEntities; document.getElementById('entitySearch').oninput = renderEntities;

View File

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

View File

@@ -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")) config = yaml.safe_load(Path("addon/config.yaml").read_text(encoding="utf-8"))
assert config["slug"] == "sillyhome_future" assert config["slug"] == "sillyhome_future"
assert config["version"] == "2.0.0-alpha.9" assert config["version"] == "2.0.0-alpha.14"
assert config["ingress"] is True assert config["ingress"] is True
assert config["ingress_port"] == 8099 assert config["ingress_port"] == 8099
assert config["homeassistant_api"] is True 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: def test_repository_points_to_gitea_repo() -> None:

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()) restore = client.post("/v2/backup/restore", json=backup.json())
assert health.status_code == 200 assert health.status_code == 200
assert health.json()["version"] == "2.0.0-alpha.9" assert health.json()["version"] == "2.0.0-alpha.14"
assert backup.status_code == 200 assert backup.status_code == 200
assert restore.status_code == 200 assert restore.status_code == 200
assert restore.json() == {"status": "restored"} assert restore.json() == {"status": "restored"}
@@ -39,6 +39,13 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
entity_id="binary_sensor.storage_door", entity_id="binary_sensor.storage_door",
domain="binary_sensor", domain="binary_sensor",
state="off", state="off",
area_name="Storage",
),
"light.storage_door": EntityState(
entity_id="light.storage_door",
domain="light",
state="off",
area_name="Storage",
), ),
} }
) )
@@ -77,12 +84,46 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
readiness = client.get("/v2/control/light.storage/readiness") readiness = client.get("/v2/control/light.storage/readiness")
global_control = client.post("/v2/control/global", json={"enabled": False}) global_control = client.post("/v2/control/global", json={"enabled": False})
detailed_health = client.get("/v2/health") 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") dashboard = client.get("/v2/dashboard")
assert entities.status_code == 200 assert entities.status_code == 200
assert [item["entity_id"] for item in entities.json()] == [ assert [item["entity_id"] for item in entities.json()] == [
"binary_sensor.storage_door", "binary_sensor.storage_door",
"light.storage", "light.storage",
"light.storage_door",
] ]
assert control.status_code == 200 assert control.status_code == 200
assert control.json()["stage"] == "dry_run" assert control.json()["stage"] == "dry_run"
@@ -96,5 +137,35 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
assert global_control.json()["global_enabled"] is False assert global_control.json()["global_enabled"] is False
assert detailed_health.status_code == 200 assert detailed_health.status_code == 200
assert detailed_health.json()["global_enabled"] is False 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 candidates.status_code == 200
assert accepted.status_code == 200
assert jobs.status_code == 200
assert jobs.json()
assert dashboard.status_code == 200 assert dashboard.status_code == 200
assert dashboard.json()["actuator_count"] == 1 assert dashboard.json()["actuator_count"] == 2
assert dashboard.json()["automation_proposals"] == 1