1438 lines
52 KiB
Python
1438 lines
52 KiB
Python
from __future__ import annotations
|
|
|
|
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
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.core.decision import DecisionEngineV2
|
|
from app.core.event_core import EventCore
|
|
from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
|
|
from app.core.handoff import HandoffMatrix
|
|
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
|
|
|
|
stores = FutureStores(os.getenv("SILLYHOME_FUTURE_STORE", ".future_store"))
|
|
event_core = EventCore(stores)
|
|
handoff = HandoffMatrix()
|
|
ha_client: FutureHaClient | None = None
|
|
|
|
|
|
class ControlStageUpdate(BaseModel):
|
|
stage: SafetyStage
|
|
min_confidence: float = Field(default=0.82, ge=0.0, le=1.0)
|
|
manual_block: bool = False
|
|
cooldown_seconds: int = Field(default=900, ge=0)
|
|
|
|
|
|
class PatternCreateRequest(BaseModel):
|
|
trigger_entity_id: str
|
|
trigger_state: str | None = None
|
|
target_state: str
|
|
confidence: float = Field(default=0.9, ge=0.0, le=1.0)
|
|
support: int = Field(default=3, ge=1)
|
|
source: str = Field(default="dashboard", max_length=40)
|
|
|
|
|
|
class GlobalControlUpdate(BaseModel):
|
|
enabled: bool
|
|
|
|
|
|
class SimulationRequest(BaseModel):
|
|
actuator_entity_id: str
|
|
trigger_entity_id: str
|
|
trigger_state: str | None = None
|
|
|
|
|
|
class ActiveReadiness(BaseModel):
|
|
actuator_entity_id: str
|
|
ready: bool
|
|
reason: str
|
|
dry_run_events: int
|
|
dry_run_successes: int
|
|
dry_run_failures: int
|
|
|
|
|
|
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:
|
|
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.13",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict[str, str]:
|
|
runtime = stores.runtime()
|
|
return {
|
|
"status": "ok",
|
|
"version": app.version,
|
|
"ha": runtime.websocket_status,
|
|
}
|
|
|
|
|
|
@app.get("/v2/health")
|
|
def detailed_health() -> dict[str, object]:
|
|
runtime = stores.runtime()
|
|
control = stores.control()
|
|
return {
|
|
"api": "ok",
|
|
"version": app.version,
|
|
"websocket": runtime.websocket_status,
|
|
"store": "ok",
|
|
"global_enabled": control.global_enabled,
|
|
"entities": len(runtime.entities),
|
|
"audit_events": len(runtime.audit),
|
|
}
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def dashboard() -> str:
|
|
return _dashboard_html()
|
|
|
|
|
|
@app.get("/v2/dashboard")
|
|
def dashboard_data() -> dict[str, object]:
|
|
runtime = stores.runtime()
|
|
learning = stores.learning()
|
|
control = stores.control()
|
|
latest_audit = runtime.audit[-20:]
|
|
actuator_entities = [
|
|
entity
|
|
for entity in runtime.entities.values()
|
|
if entity.domain in {"light", "switch", "fan", "cover", "humidifier"}
|
|
]
|
|
return {
|
|
"websocket_status": runtime.websocket_status,
|
|
"entity_count": len(runtime.entities),
|
|
"actuator_count": len(actuator_entities),
|
|
"global_enabled": control.global_enabled,
|
|
"learning_profiles": len(learning.profiles),
|
|
"control_profiles": len(control.profiles),
|
|
"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)
|
|
|
|
|
|
@app.get("/v2/runtime", response_model=RuntimeState)
|
|
def get_runtime() -> RuntimeState:
|
|
return stores.runtime()
|
|
|
|
|
|
@app.get("/v2/entities", response_model=list[EntityState])
|
|
def list_entities(domain: str | None = None, q: str | None = None) -> list[EntityState]:
|
|
entities = list(stores.runtime().entities.values())
|
|
if domain:
|
|
wanted = {item.strip() for item in domain.split(",") if item.strip()}
|
|
entities = [entity for entity in entities if entity.domain in wanted]
|
|
if q:
|
|
needle = q.casefold()
|
|
entities = [
|
|
entity
|
|
for entity in entities
|
|
if needle in entity.entity_id.casefold()
|
|
or (entity.friendly_name is not None and needle in entity.friendly_name.casefold())
|
|
]
|
|
return sorted(entities, key=lambda entity: entity.entity_id)[:500]
|
|
|
|
|
|
@app.get("/v2/entities/groups")
|
|
def entity_groups() -> dict[str, list[EntityState]]:
|
|
grouped: dict[str, list[EntityState]] = {}
|
|
for entity in stores.runtime().entities.values():
|
|
key = entity.area_name or entity.domain
|
|
grouped.setdefault(key, []).append(entity)
|
|
return {
|
|
key: sorted(values, key=lambda entity: entity.entity_id)[:250]
|
|
for key, values in sorted(grouped.items())
|
|
}
|
|
|
|
|
|
@app.get("/v2/learning", response_model=LearningState)
|
|
def get_learning() -> LearningState:
|
|
return stores.learning()
|
|
|
|
|
|
@app.put("/v2/learning", response_model=LearningState)
|
|
def put_learning(state: LearningState) -> LearningState:
|
|
return stores.save_learning(state)
|
|
|
|
|
|
@app.get("/v2/control", response_model=ControlState)
|
|
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})
|
|
stores.save_control(state)
|
|
_append_audit(
|
|
"safety",
|
|
None,
|
|
"Globaler Not-Aus deaktiviert." if update.enabled else "Globaler Not-Aus aktiviert.",
|
|
)
|
|
return state
|
|
|
|
|
|
@app.put("/v2/control/{actuator_entity_id}", response_model=ControlProfile)
|
|
def put_control(actuator_entity_id: str, profile: ControlProfile) -> ControlProfile:
|
|
state = stores.control()
|
|
state.profiles[actuator_entity_id] = profile
|
|
stores.save_control(state)
|
|
return profile
|
|
|
|
|
|
@app.post("/v2/control/{actuator_entity_id}/stage", response_model=ControlProfile)
|
|
def update_control_stage(
|
|
actuator_entity_id: str,
|
|
update: ControlStageUpdate,
|
|
) -> ControlProfile:
|
|
state = stores.control()
|
|
profile = state.profiles.get(
|
|
actuator_entity_id,
|
|
ControlProfile(actuator_entity_id=actuator_entity_id),
|
|
)
|
|
requested_stage = update.stage
|
|
if requested_stage is SafetyStage.ACTIVE and not profile.active_ready:
|
|
requested_stage = SafetyStage.DRY_RUN
|
|
updated = profile.model_copy(
|
|
update={
|
|
"stage": requested_stage,
|
|
"min_confidence": update.min_confidence,
|
|
"manual_block": update.manual_block,
|
|
"cooldown_seconds": update.cooldown_seconds,
|
|
"handoff_mode": handoff.classify(profile),
|
|
}
|
|
)
|
|
state.profiles[actuator_entity_id] = updated
|
|
stores.save_control(state)
|
|
_append_audit(
|
|
"safety",
|
|
actuator_entity_id,
|
|
f"Stage gesetzt auf {updated.stage}."
|
|
if updated.stage == update.stage
|
|
else f"Active blockiert: {updated.active_readiness_reason}",
|
|
)
|
|
return updated
|
|
|
|
|
|
@app.get("/v2/control/{actuator_entity_id}/readiness", response_model=ActiveReadiness)
|
|
def active_readiness(actuator_entity_id: str) -> ActiveReadiness:
|
|
profile = stores.control().profiles.get(
|
|
actuator_entity_id,
|
|
ControlProfile(actuator_entity_id=actuator_entity_id),
|
|
)
|
|
return ActiveReadiness(
|
|
actuator_entity_id=actuator_entity_id,
|
|
ready=profile.active_ready,
|
|
reason=profile.active_readiness_reason,
|
|
dry_run_events=profile.dry_run_events,
|
|
dry_run_successes=profile.dry_run_successes,
|
|
dry_run_failures=profile.dry_run_failures,
|
|
)
|
|
|
|
|
|
@app.post("/v2/learning/{actuator_entity_id}/patterns", response_model=LearningProfile)
|
|
def create_learning_pattern(
|
|
actuator_entity_id: str,
|
|
pattern: PatternCreateRequest,
|
|
) -> LearningProfile:
|
|
state = stores.learning()
|
|
profile = state.profiles.get(
|
|
actuator_entity_id,
|
|
LearningProfile(actuator_entity_id=actuator_entity_id),
|
|
)
|
|
updated = profile.model_copy(
|
|
update={
|
|
"patterns": [
|
|
*profile.patterns,
|
|
BehaviorPatternV2(
|
|
actuator_entity_id=actuator_entity_id,
|
|
target_state=pattern.target_state,
|
|
trigger_entity_id=pattern.trigger_entity_id,
|
|
trigger_state=pattern.trigger_state,
|
|
support=pattern.support,
|
|
confidence=pattern.confidence,
|
|
source=pattern.source,
|
|
),
|
|
],
|
|
"model_version": "dashboard-v1",
|
|
}
|
|
)
|
|
state.profiles[actuator_entity_id] = updated
|
|
stores.save_learning(state)
|
|
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()
|
|
learning = stores.learning()
|
|
control = stores.control()
|
|
runtime.entities[request.trigger_entity_id] = EntityState(
|
|
entity_id=request.trigger_entity_id,
|
|
domain=request.trigger_entity_id.split(".", 1)[0],
|
|
state=request.trigger_state,
|
|
)
|
|
profile = learning.profiles.get(
|
|
request.actuator_entity_id,
|
|
LearningProfile(actuator_entity_id=request.actuator_entity_id),
|
|
)
|
|
control_profile = control.profiles.get(
|
|
request.actuator_entity_id,
|
|
ControlProfile(actuator_entity_id=request.actuator_entity_id),
|
|
)
|
|
decision = DecisionEngineV2().decide(
|
|
actuator_entity_id=request.actuator_entity_id,
|
|
trigger_entity_id=request.trigger_entity_id,
|
|
runtime=runtime,
|
|
learning=profile,
|
|
control=control_profile,
|
|
)
|
|
service = service_for_state(
|
|
request.actuator_entity_id.split(".", 1)[0],
|
|
decision.target_state,
|
|
)
|
|
return {
|
|
"decision": decision,
|
|
"would_call_service": service is not None and decision.allowed and not decision.dry_run,
|
|
"service": service,
|
|
}
|
|
|
|
|
|
@app.get("/v2/audit/{actuator_entity_id}", response_model=list[AuditEvent])
|
|
def actuator_audit(actuator_entity_id: str) -> list[AuditEvent]:
|
|
return [
|
|
item
|
|
for item in stores.runtime().audit
|
|
if item.entity_id == actuator_entity_id or (
|
|
item.decision is not None and item.decision.actuator_entity_id == actuator_entity_id
|
|
)
|
|
][-50:]
|
|
|
|
|
|
@app.post("/v2/handoff/{actuator_entity_id}/assume", response_model=ControlProfile)
|
|
def assume_control(actuator_entity_id: str) -> ControlProfile:
|
|
state = stores.control()
|
|
profile = state.profiles.get(
|
|
actuator_entity_id,
|
|
ControlProfile(actuator_entity_id=actuator_entity_id),
|
|
)
|
|
updated = handoff.assume_control(profile)
|
|
state.profiles[actuator_entity_id] = updated
|
|
stores.save_control(state)
|
|
return updated
|
|
|
|
|
|
@app.post("/v2/handoff/{actuator_entity_id}/rollback", response_model=ControlProfile)
|
|
def rollback_control(actuator_entity_id: str) -> ControlProfile:
|
|
state = stores.control()
|
|
profile = state.profiles.get(
|
|
actuator_entity_id,
|
|
ControlProfile(actuator_entity_id=actuator_entity_id),
|
|
)
|
|
updated = handoff.rollback(profile)
|
|
state.profiles[actuator_entity_id] = updated
|
|
stores.save_control(state)
|
|
return updated
|
|
|
|
|
|
@app.get("/v2/handoff/{actuator_entity_id}", response_model=HandoffMode)
|
|
def classify_handoff(actuator_entity_id: str) -> HandoffMode:
|
|
profile = stores.control().profiles.get(
|
|
actuator_entity_id,
|
|
ControlProfile(actuator_entity_id=actuator_entity_id),
|
|
)
|
|
return handoff.classify(profile)
|
|
|
|
|
|
@app.get("/v2/backup/export", response_model=BackupBundle)
|
|
def export_backup() -> BackupBundle:
|
|
return stores.export_backup()
|
|
|
|
|
|
@app.post("/v2/backup/restore")
|
|
def restore_backup(bundle: BackupBundle) -> dict[str, str]:
|
|
stores.restore_backup(bundle)
|
|
return {"status": "restored"}
|
|
|
|
|
|
def _ha_client_from_env() -> FutureHaClient | None:
|
|
token = os.getenv("SUPERVISOR_TOKEN") or os.getenv("SILLYHOME_FUTURE_HA_TOKEN")
|
|
if not token:
|
|
return None
|
|
core_url = os.getenv("SILLYHOME_FUTURE_HA_CORE_URL", "http://supervisor/core")
|
|
websocket_url = os.getenv("SILLYHOME_FUTURE_HA_WS_URL")
|
|
return FutureHaClient(
|
|
HaClientConfig(core_url=core_url, token=token, websocket_url=websocket_url)
|
|
)
|
|
|
|
|
|
def _load_initial_ha_states(client: FutureHaClient) -> None:
|
|
runtime = stores.runtime()
|
|
try:
|
|
for entity in client.read_states():
|
|
runtime.entities[entity.entity_id] = entity
|
|
runtime.websocket_status = "initial_state_loaded"
|
|
except Exception as exc:
|
|
runtime.websocket_status = f"initial_state_error: {exc}"
|
|
stores.save_runtime(runtime)
|
|
|
|
|
|
async def _ha_listener_loop(client: FutureHaClient) -> None:
|
|
routed_triggers: set[str] = set()
|
|
next_route_refresh = 0.0
|
|
pending_unrouted: list[StateEvent] = []
|
|
last_unrouted_flush = 0.0
|
|
while True:
|
|
await asyncio.to_thread(_set_websocket_status, "connecting")
|
|
try:
|
|
async for event in client.listen_state_events():
|
|
loop_time = asyncio.get_running_loop().time()
|
|
if loop_time >= next_route_refresh:
|
|
routed_triggers = await asyncio.to_thread(_routed_trigger_ids)
|
|
next_route_refresh = loop_time + 5
|
|
await asyncio.to_thread(_set_websocket_status, "connected")
|
|
if event.entity_id in routed_triggers:
|
|
if pending_unrouted:
|
|
await asyncio.to_thread(_merge_unrouted_events, pending_unrouted)
|
|
pending_unrouted = []
|
|
await asyncio.to_thread(
|
|
event_core.process_state_event,
|
|
event,
|
|
execute=_execute_ha_decision,
|
|
)
|
|
else:
|
|
pending_unrouted.append(event)
|
|
if len(pending_unrouted) >= 100 or loop_time - last_unrouted_flush >= 2:
|
|
await asyncio.to_thread(_merge_unrouted_events, pending_unrouted)
|
|
pending_unrouted = []
|
|
last_unrouted_flush = loop_time
|
|
await asyncio.sleep(0)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
await asyncio.to_thread(_set_websocket_status, f"reconnecting: {exc}")
|
|
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()
|
|
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 in {"binary_sensor", "sensor"}
|
|
][: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 _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:
|
|
return
|
|
runtime.websocket_status = status
|
|
stores.save_runtime(runtime)
|
|
|
|
|
|
def _routed_trigger_ids() -> set[str]:
|
|
result: set[str] = set()
|
|
for profile in stores.learning().profiles.values():
|
|
for pattern in profile.patterns:
|
|
if pattern.trigger_entity_id is not None:
|
|
result.add(pattern.trigger_entity_id)
|
|
return result
|
|
|
|
|
|
def _merge_unrouted_events(events: list[StateEvent]) -> None:
|
|
if not events:
|
|
return
|
|
runtime = stores.runtime()
|
|
for event in events:
|
|
runtime.entities[event.entity_id] = EntityState(
|
|
entity_id=event.entity_id,
|
|
domain=event.entity_id.split(".", 1)[0],
|
|
state=event.new_state,
|
|
changed_at=event.changed_at,
|
|
area_name=event.attributes.get("area_name"),
|
|
device_id=event.attributes.get("device_id"),
|
|
friendly_name=event.attributes.get("friendly_name"),
|
|
)
|
|
stores.save_runtime(runtime)
|
|
|
|
|
|
def _append_audit(kind: str, entity_id: str | None, message: str) -> None:
|
|
runtime = stores.runtime()
|
|
event = AuditEvent(
|
|
event_id=f"{kind}-{len(runtime.audit) + 1}",
|
|
kind=kind,
|
|
entity_id=entity_id,
|
|
message=message,
|
|
)
|
|
runtime.audit = [*runtime.audit, event][-200:]
|
|
stores.save_runtime(runtime)
|
|
|
|
|
|
def _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
|
|
actuator_entity_id = str(decision.actuator_entity_id)
|
|
target_state = getattr(decision, "target_state", None)
|
|
service = service_for_state(actuator_entity_id.split(".", 1)[0], target_state)
|
|
if service is None:
|
|
return False
|
|
domain, service_name = service
|
|
ha_client.call_service(domain, service_name, {"entity_id": actuator_entity_id})
|
|
return True
|
|
|
|
|
|
def _dashboard_html() -> str:
|
|
return """<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>SillyHome Future</title>
|
|
<style>
|
|
:root { color-scheme: dark; --bg: #101418; --panel: #171d23; --line: #2c3640; --text: #eef3f6; --muted: #9fb0bd; --accent: #42d392; --warn: #f3c969; }
|
|
* { box-sizing: border-box; }
|
|
body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--text); }
|
|
main { max-width: 1180px; margin: 0 auto; padding: 20px; }
|
|
h1 { font-size: 28px; margin: 0 0 18px; }
|
|
h2 { font-size: 18px; margin: 24px 0 10px; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
|
|
.split { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 1.2fr); gap: 14px; align-items: start; }
|
|
.card { border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: var(--panel); }
|
|
.label { color: #9fb0bd; font-size: 13px; }
|
|
.value { font-size: 24px; margin-top: 4px; }
|
|
label { display: block; color: var(--muted); font-size: 13px; margin: 10px 0 5px; }
|
|
input, select, button { width: 100%; border: 1px solid var(--line); border-radius: 7px; background: #0c1014; color: var(--text); font: inherit; padding: 10px; }
|
|
button { cursor: pointer; background: #20303a; }
|
|
button:hover { border-color: var(--accent); }
|
|
.row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
.stages { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 8px; }
|
|
.stages button.active { border-color: var(--accent); color: var(--accent); }
|
|
.primary { background: #17402c; border-color: #256f4a; }
|
|
.status { min-height: 24px; color: var(--warn); margin-top: 10px; }
|
|
pre { max-height: 360px; overflow: auto; white-space: pre-wrap; word-break: break-word; background: #0c1014; padding: 14px; border-radius: 8px; }
|
|
@media (max-width: 800px) { .split, .row, .stages { grid-template-columns: 1fr; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>SillyHome Future</h1>
|
|
<section class="grid" id="metrics"></section>
|
|
<section class="split">
|
|
<div class="card">
|
|
<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">Mindest-Sicherheit</label>
|
|
<input id="minConfidence" type="number" min="0" max="1" step="0.01" value="0.82">
|
|
</div>
|
|
<div>
|
|
<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">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>Lernmuster</h2>
|
|
<div class="row">
|
|
<div>
|
|
<label for="trigger">Trigger</label>
|
|
<select id="trigger"></select>
|
|
</div>
|
|
<div>
|
|
<label for="triggerState">Trigger-Zustand</label>
|
|
<input id="triggerState" placeholder="on, off, open...">
|
|
</div>
|
|
</div>
|
|
<div class="row">
|
|
<div>
|
|
<label for="targetState">Zielzustand</label>
|
|
<input id="targetState" value="on">
|
|
</div>
|
|
<div>
|
|
<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">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>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) {
|
|
const name = entity.friendly_name ? ` - ${entity.friendly_name}` : '';
|
|
const current = entity.state == null ? '' : ` (${entity.state})`;
|
|
return `${entity.entity_id}${current}${name}`;
|
|
}
|
|
|
|
function setStatus(id, text) {
|
|
document.getElementById(id).textContent = text;
|
|
if (text) setTimeout(() => document.getElementById(id).textContent = '', 4000);
|
|
}
|
|
|
|
function renderStages() {
|
|
document.getElementById('stages').innerHTML = stages.map(stage =>
|
|
`<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(); };
|
|
});
|
|
}
|
|
|
|
function renderEntities() {
|
|
const actuator = document.getElementById('actuator');
|
|
const trigger = document.getElementById('trigger');
|
|
const query = document.getElementById('entitySearch').value.toLowerCase();
|
|
const shown = state.entities.filter(entity =>
|
|
!query || optionText(entity).toLowerCase().includes(query)
|
|
);
|
|
const actuators = shown.filter(entity => ['light', 'switch', 'fan', 'cover', 'humidifier'].includes(entity.domain));
|
|
actuator.innerHTML = actuators.map(entity => `<option value="${entity.entity_id}">${optionText(entity)}</option>`).join('');
|
|
trigger.innerHTML = shown.map(entity => `<option value="${entity.entity_id}">${optionText(entity)}</option>`).join('');
|
|
renderProfile();
|
|
}
|
|
|
|
function renderProfile() {
|
|
const id = document.getElementById('actuator').value;
|
|
const profile = {
|
|
control: state.control.profiles?.[id] || null,
|
|
learning: state.learning.profiles?.[id] || null,
|
|
};
|
|
if (profile.control) {
|
|
state.selectedStage = profile.control.stage;
|
|
document.getElementById('minConfidence').value = profile.control.min_confidence;
|
|
document.getElementById('cooldown').value = profile.control.cooldown_seconds;
|
|
document.getElementById('manualBlock').checked = profile.control.manual_block;
|
|
renderStages();
|
|
}
|
|
document.getElementById('profile').textContent = JSON.stringify(profile, null, 2);
|
|
}
|
|
|
|
async function loadDashboard() {
|
|
const [dashResponse, entitiesResponse, controlResponse, learningResponse] = await Promise.all([
|
|
fetch('/v2/dashboard'),
|
|
fetch('/v2/entities?domain=light,switch,fan,cover,humidifier,binary_sensor,sensor'),
|
|
fetch('/v2/control'),
|
|
fetch('/v2/learning'),
|
|
]);
|
|
const data = await dashResponse.json();
|
|
state.entities = await entitiesResponse.json();
|
|
state.control = await controlResponse.json();
|
|
state.learning = await learningResponse.json();
|
|
const metrics = [
|
|
['WebSocket', data.websocket_status],
|
|
['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>`
|
|
).join('');
|
|
renderEntities();
|
|
await loadReadiness();
|
|
document.getElementById('audit').textContent = JSON.stringify(data.audit, null, 2);
|
|
}
|
|
|
|
async function loadReadiness() {
|
|
const id = document.getElementById('actuator').value;
|
|
if (!id) return;
|
|
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/readiness`);
|
|
const data = await response.json();
|
|
document.getElementById('readiness').textContent =
|
|
`${data.ready ? 'bereit fuer Aktiv' : 'nicht bereit fuer Aktiv'} - ${data.reason}`;
|
|
}
|
|
|
|
document.getElementById('entitySearch').oninput = renderEntities;
|
|
document.getElementById('actuator').onchange = () => { renderProfile(); loadReadiness(); };
|
|
document.getElementById('saveControl').onclick = async () => {
|
|
const id = document.getElementById('actuator').value;
|
|
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/stage`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
stage: state.selectedStage,
|
|
min_confidence: Number(document.getElementById('minConfidence').value),
|
|
manual_block: document.getElementById('manualBlock').checked,
|
|
cooldown_seconds: Number(document.getElementById('cooldown').value),
|
|
}),
|
|
});
|
|
if (!response.ok) throw new Error(await response.text());
|
|
setStatus('controlStatus', 'Gespeichert');
|
|
await loadDashboard();
|
|
await loadReadiness();
|
|
};
|
|
document.getElementById('addPattern').onclick = async () => {
|
|
const id = document.getElementById('actuator').value;
|
|
const response = await fetch(`/v2/learning/${encodeURIComponent(id)}/patterns`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
trigger_entity_id: document.getElementById('trigger').value,
|
|
trigger_state: document.getElementById('triggerState').value || null,
|
|
target_state: document.getElementById('targetState').value,
|
|
confidence: Number(document.getElementById('patternConfidence').value),
|
|
}),
|
|
});
|
|
if (!response.ok) throw new Error(await response.text());
|
|
setStatus('patternStatus', 'Pattern angelegt');
|
|
await loadDashboard();
|
|
};
|
|
document.getElementById('simulatePattern').onclick = async () => {
|
|
const id = document.getElementById('actuator').value;
|
|
const response = await fetch('/v2/simulate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
actuator_entity_id: id,
|
|
trigger_entity_id: document.getElementById('trigger').value,
|
|
trigger_state: document.getElementById('triggerState').value || null,
|
|
}),
|
|
});
|
|
document.getElementById('profile').textContent = JSON.stringify(await response.json(), null, 2);
|
|
};
|
|
document.getElementById('globalToggle').onclick = async () => {
|
|
const dash = await (await fetch('/v2/dashboard')).json();
|
|
await fetch('/v2/control/global', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: !dash.global_enabled }),
|
|
});
|
|
await loadDashboard();
|
|
};
|
|
renderStages();
|
|
loadDashboard();
|
|
setInterval(loadDashboard, 5000);
|
|
</script>
|
|
</body>
|
|
</html>"""
|