Compare commits
6 Commits
v2.0.0-alp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bf832b49f4 | |||
| 8057751c11 | |||
| 2f750e3e41 | |||
| 6e6031cfda | |||
| 07e3e96c30 | |||
| e4b860571a |
@@ -2,7 +2,7 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.9
|
||||
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.9"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -89,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
|
||||
@@ -121,11 +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):
|
||||
|
||||
783
app/main.py
783
app/main.py
@@ -4,6 +4,7 @@ import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI
|
||||
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.models import (
|
||||
AuditEvent,
|
||||
AutopilotSettings,
|
||||
AutomationProposal,
|
||||
BackupBundle,
|
||||
BehaviorPatternV2,
|
||||
CandidateRecommendation,
|
||||
CandidateStatus,
|
||||
ControlProfile,
|
||||
ControlState,
|
||||
EntityState,
|
||||
HistoryAnalysis,
|
||||
HandoffMode,
|
||||
JobQueueItem,
|
||||
JobStatus,
|
||||
LearningProfile,
|
||||
LearningState,
|
||||
ModelEvaluation,
|
||||
ModelRecord,
|
||||
ProposalStatus,
|
||||
RuntimeState,
|
||||
SafetyStage,
|
||||
SensorWeightOverride,
|
||||
StateEvent,
|
||||
)
|
||||
from app.core.stores import FutureStores
|
||||
@@ -70,27 +82,80 @@ class ActiveReadiness(BaseModel):
|
||||
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
|
||||
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.9",
|
||||
version="2.0.0-alpha.15",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -134,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,
|
||||
@@ -143,12 +208,275 @@ def dashboard_data() -> dict[str, object]:
|
||||
"global_enabled": control.global_enabled,
|
||||
"learning_profiles": len(learning.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()),
|
||||
"scenes": list(learning.scenes.values()),
|
||||
"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])
|
||||
def ingest_state_event(event: StateEvent) -> list[AuditEvent]:
|
||||
return event_core.process_state_event(event, execute=_execute_ha_decision)
|
||||
@@ -203,6 +531,45 @@ def get_control() -> ControlState:
|
||||
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)
|
||||
def set_global_control(update: GlobalControlUpdate) -> ControlState:
|
||||
state = stores.control().model_copy(update={"global_enabled": update.enabled})
|
||||
@@ -305,6 +672,118 @@ def create_learning_pattern(
|
||||
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")
|
||||
def simulate_decision(request: SimulationRequest) -> dict[str, object]:
|
||||
runtime = stores.runtime()
|
||||
@@ -457,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:
|
||||
@@ -503,6 +1134,97 @@ def _append_audit(kind: str, entity_id: str | None, message: str) -> None:
|
||||
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:
|
||||
if ha_client is None or not hasattr(decision, "actuator_entity_id"):
|
||||
return False
|
||||
@@ -554,37 +1276,37 @@ def _dashboard_html() -> str:
|
||||
<section class="grid" id="metrics"></section>
|
||||
<section class="split">
|
||||
<div class="card">
|
||||
<h2>Control</h2>
|
||||
<h2>Steuerung</h2>
|
||||
<label for="entitySearch">Suche</label>
|
||||
<input id="entitySearch" placeholder="light., switch., sensor..." autocomplete="off">
|
||||
<label for="actuator">Aktor</label>
|
||||
<select id="actuator"></select>
|
||||
<div class="row">
|
||||
<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">
|
||||
</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">
|
||||
</div>
|
||||
</div>
|
||||
<label><input id="manualBlock" type="checkbox" style="width:auto;margin-right:6px"> Manuell blockieren</label>
|
||||
<div class="stages" id="stages"></div>
|
||||
<button class="primary" id="saveControl">Control speichern</button>
|
||||
<button class="primary" id="saveControl">Steuerung speichern</button>
|
||||
<button id="globalToggle">Globaler Not-Aus</button>
|
||||
<div class="status" id="readiness">Ready-Status wird geladen...</div>
|
||||
<div class="status" id="controlStatus"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Learning Pattern</h2>
|
||||
<h2>Lernmuster</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="trigger">Trigger</label>
|
||||
<select id="trigger"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="triggerState">Trigger State</label>
|
||||
<label for="triggerState">Trigger-Zustand</label>
|
||||
<input id="triggerState" placeholder="on, off, open...">
|
||||
</div>
|
||||
</div>
|
||||
@@ -594,22 +1316,28 @@ def _dashboard_html() -> str:
|
||||
<input id="targetState" value="on">
|
||||
</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">
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary" id="addPattern">Pattern anlegen</button>
|
||||
<button id="simulatePattern">Pattern simulieren</button>
|
||||
<button class="primary" id="addPattern">Lernmuster anlegen</button>
|
||||
<button id="simulatePattern">Lernmuster simulieren</button>
|
||||
<div class="status" id="patternStatus"></div>
|
||||
<h2>Aktuelles Profil</h2>
|
||||
<pre id="profile">Lade...</pre>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Audit</h2>
|
||||
<h2>Prüfprotokoll</h2>
|
||||
<pre id="audit">Lade...</pre>
|
||||
</main>
|
||||
<script>
|
||||
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' };
|
||||
|
||||
function optionText(entity) {
|
||||
@@ -625,7 +1353,7 @@ def _dashboard_html() -> str:
|
||||
|
||||
function renderStages() {
|
||||
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('');
|
||||
document.querySelectorAll('[data-stage]').forEach(button => {
|
||||
button.onclick = () => { state.selectedStage = button.dataset.stage; renderStages(); };
|
||||
@@ -674,13 +1402,18 @@ def _dashboard_html() -> str:
|
||||
state.learning = await learningResponse.json();
|
||||
const metrics = [
|
||||
['WebSocket', data.websocket_status],
|
||||
['Entities', data.entity_count],
|
||||
['Actuators', data.actuator_count],
|
||||
['Global', data.global_enabled ? 'on' : 'off'],
|
||||
['Learning', data.learning_profiles],
|
||||
['Control', data.control_profiles],
|
||||
['Rooms', data.rooms.length],
|
||||
['Scenes', data.scenes.length],
|
||||
['Entitäten', data.entity_count],
|
||||
['Aktoren', data.actuator_count],
|
||||
['Not-Aus', data.global_enabled ? 'frei' : 'aktiv'],
|
||||
['Lernen', data.learning_profiles],
|
||||
['Steuerung', data.control_profiles],
|
||||
['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],
|
||||
];
|
||||
document.getElementById('metrics').innerHTML = metrics.map(([label, value]) =>
|
||||
`<div class="card"><div class="label">${label}</div><div class="value">${value}</div></div>`
|
||||
@@ -696,7 +1429,7 @@ def _dashboard_html() -> str:
|
||||
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/readiness`);
|
||||
const data = await response.json();
|
||||
document.getElementById('readiness').textContent =
|
||||
`${data.ready ? 'ready for active' : 'nicht active-ready'} - ${data.reason}`;
|
||||
`${data.ready ? 'bereit fuer Aktiv' : 'nicht bereit fuer Aktiv'} - ${data.reason}`;
|
||||
}
|
||||
|
||||
document.getElementById('entitySearch').oninput = renderEntities;
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-future"
|
||||
version = "2.0.0-alpha.9"
|
||||
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.9"
|
||||
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:
|
||||
|
||||
@@ -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.9"
|
||||
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",
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -77,12 +90,46 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
|
||||
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"
|
||||
@@ -96,5 +143,39 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
|
||||
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"] == 1
|
||||
assert dashboard.json()["actuator_count"] == 2
|
||||
assert dashboard.json()["automation_proposals"] == 1
|
||||
|
||||
Reference in New Issue
Block a user