Add Future parity feature blocks
This commit is contained in:
@@ -21,6 +21,19 @@ 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 EntityState(BaseModel):
|
||||
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
domain: str
|
||||
@@ -89,6 +102,51 @@ 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 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 HistoryAnalysis(BaseModel):
|
||||
entity_id: str
|
||||
samples: int
|
||||
last_state: str | None = None
|
||||
changed_at: datetime | None = None
|
||||
recommendation: str
|
||||
|
||||
|
||||
class Decision(BaseModel):
|
||||
actuator_entity_id: str
|
||||
target_state: str | None = None
|
||||
@@ -121,6 +179,10 @@ 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)
|
||||
jobs: dict[str, JobQueueItem] = Field(default_factory=dict)
|
||||
weight_overrides: dict[str, SensorWeightOverride] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ControlState(BaseModel):
|
||||
|
||||
227
app/main.py
227
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,23 @@ from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
|
||||
from app.core.handoff import HandoffMatrix
|
||||
from app.core.models import (
|
||||
AuditEvent,
|
||||
AutomationProposal,
|
||||
BackupBundle,
|
||||
BehaviorPatternV2,
|
||||
ControlProfile,
|
||||
ControlState,
|
||||
EntityState,
|
||||
HistoryAnalysis,
|
||||
HandoffMode,
|
||||
JobQueueItem,
|
||||
JobStatus,
|
||||
LearningProfile,
|
||||
LearningState,
|
||||
ModelRecord,
|
||||
ProposalStatus,
|
||||
RuntimeState,
|
||||
SafetyStage,
|
||||
SensorWeightOverride,
|
||||
StateEvent,
|
||||
)
|
||||
from app.core.stores import FutureStores
|
||||
@@ -86,6 +94,27 @@ class ActuatorSummary(BaseModel):
|
||||
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)
|
||||
|
||||
|
||||
class TrainModelRequest(BaseModel):
|
||||
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)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
global ha_client
|
||||
@@ -106,7 +135,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
app = FastAPI(
|
||||
title="SillyHome Future API",
|
||||
description="SillyHome v2 event-core side project.",
|
||||
version="2.0.0-alpha.10",
|
||||
version="2.0.0-alpha.11",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -159,6 +188,9 @@ 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),
|
||||
"rooms": list(learning.rooms.values()),
|
||||
"scenes": list(learning.scenes.values()),
|
||||
"audit": latest_audit,
|
||||
@@ -185,17 +217,135 @@ def feature_parity() -> dict[str, object]:
|
||||
"Simulation",
|
||||
"Audit",
|
||||
"Health",
|
||||
"Automation-Proposals",
|
||||
"YAML-Export",
|
||||
"History-Analyse",
|
||||
"lokales Modelltraining",
|
||||
"Sensor-Gewichte",
|
||||
"Job-Queue",
|
||||
],
|
||||
"next_to_expand": [
|
||||
"automations-proposals",
|
||||
"history-analysis",
|
||||
"model-training",
|
||||
"weight-overrides",
|
||||
"job-queue",
|
||||
],
|
||||
"next_to_expand": ["echte Langzeit-History aus HA", "fortgeschrittene Modellbewertung"],
|
||||
}
|
||||
|
||||
|
||||
@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]
|
||||
result: list[HistoryAnalysis] = []
|
||||
for entity_id in ids:
|
||||
entity = runtime.entities.get(entity_id)
|
||||
matching_audit = [item for item in runtime.audit if item.entity_id == entity_id]
|
||||
result.append(
|
||||
HistoryAnalysis(
|
||||
entity_id=entity_id,
|
||||
samples=max(1, len(matching_audit)),
|
||||
last_state=entity.state if entity else None,
|
||||
changed_at=entity.changed_at if entity else None,
|
||||
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.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)
|
||||
@@ -421,6 +571,31 @@ def record_feedback(actuator_entity_id: str, feedback: FeedbackRequest) -> Learn
|
||||
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()
|
||||
@@ -676,6 +851,39 @@ 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 _execute_ha_decision(decision: object) -> bool:
|
||||
if ha_client is None or not hasattr(decision, "actuator_entity_id"):
|
||||
return False
|
||||
@@ -858,6 +1066,9 @@ def _dashboard_html() -> str:
|
||||
['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],
|
||||
['Räume', data.rooms.length],
|
||||
['Szenen', data.scenes.length],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user