Compare commits
2 Commits
v2.0.0-alp
...
v2.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e6031cfda | |||
| 07e3e96c30 |
@@ -2,7 +2,7 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.10
|
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.12
|
||||||
RUN python -m pip install --no-cache-dir \
|
RUN python -m pip install --no-cache-dir \
|
||||||
"http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz"
|
"http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Future
|
name: SillyHome Future
|
||||||
version: "2.0.0-alpha.10"
|
version: "2.0.0-alpha.12"
|
||||||
slug: sillyhome_future
|
slug: sillyhome_future
|
||||||
description: Event-first SillyHome v2 test controller
|
description: Event-first SillyHome v2 test controller
|
||||||
url: http://192.168.6.31:3000/Otto/sillyhome-future
|
url: http://192.168.6.31:3000/Otto/sillyhome-future
|
||||||
|
|||||||
@@ -72,6 +72,46 @@ class FutureHaClient:
|
|||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
def read_history(
|
||||||
|
self,
|
||||||
|
entity_ids: list[str],
|
||||||
|
start_time: datetime,
|
||||||
|
end_time: datetime,
|
||||||
|
) -> dict[str, list[StateEvent]]:
|
||||||
|
params = {
|
||||||
|
"filter_entity_id": ",".join(entity_ids),
|
||||||
|
"end_time": end_time.isoformat(),
|
||||||
|
"minimal_response": "1",
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=self._config.timeout_seconds) as client:
|
||||||
|
response = client.get(
|
||||||
|
f"{self._core_url}/api/history/period/{start_time.isoformat()}",
|
||||||
|
headers=self._headers,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
result: dict[str, list[StateEvent]] = {entity_id: [] for entity_id in entity_ids}
|
||||||
|
for series in payload if isinstance(payload, list) else []:
|
||||||
|
if not isinstance(series, list):
|
||||||
|
continue
|
||||||
|
for item in series:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
entity_id = item.get("entity_id")
|
||||||
|
if not isinstance(entity_id, str):
|
||||||
|
continue
|
||||||
|
result.setdefault(entity_id, []).append(
|
||||||
|
StateEvent(
|
||||||
|
entity_id=entity_id,
|
||||||
|
new_state=item.get("state") if isinstance(item.get("state"), str) else None,
|
||||||
|
changed_at=_parse_datetime(
|
||||||
|
item.get("last_changed") or item.get("last_updated")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
async def listen_state_events(self) -> AsyncIterator[StateEvent]:
|
async def listen_state_events(self) -> AsyncIterator[StateEvent]:
|
||||||
websocket_url = self._config.websocket_url or _default_websocket_url(self._core_url)
|
websocket_url = self._config.websocket_url or _default_websocket_url(self._core_url)
|
||||||
async with websockets.connect(websocket_url, ping_interval=None) as websocket:
|
async with websockets.connect(websocket_url, ping_interval=None) as websocket:
|
||||||
@@ -160,4 +200,3 @@ def _parse_datetime(value: object) -> datetime:
|
|||||||
if parsed.tzinfo is None:
|
if parsed.tzinfo is None:
|
||||||
return parsed.replace(tzinfo=timezone.utc)
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ class SafetyStage(StrEnum):
|
|||||||
BLOCKED = "blocked"
|
BLOCKED = "blocked"
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalStatus(StrEnum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(StrEnum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
RUNNING = "running"
|
||||||
|
SUCCEEDED = "succeeded"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
class EntityState(BaseModel):
|
class EntityState(BaseModel):
|
||||||
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||||
domain: str
|
domain: str
|
||||||
@@ -89,6 +102,66 @@ class SceneProfile(BaseModel):
|
|||||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationProposal(BaseModel):
|
||||||
|
proposal_id: str
|
||||||
|
name: str
|
||||||
|
trigger_entity_id: str
|
||||||
|
trigger_state: str | None = None
|
||||||
|
actuator_entity_id: str
|
||||||
|
target_state: str
|
||||||
|
status: ProposalStatus = ProposalStatus.DRAFT
|
||||||
|
revision: int = 1
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
decided_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRecord(BaseModel):
|
||||||
|
model_id: str
|
||||||
|
actuator_entity_id: str
|
||||||
|
pattern_count: int
|
||||||
|
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
trained_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class ModelEvaluation(BaseModel):
|
||||||
|
evaluation_id: str
|
||||||
|
model_id: str
|
||||||
|
actuator_entity_id: str
|
||||||
|
score: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
coverage: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
dry_run_success_rate: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
feedback_score: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
verdict: str
|
||||||
|
reasons: list[str] = Field(default_factory=list)
|
||||||
|
evaluated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class JobQueueItem(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
kind: str
|
||||||
|
status: JobStatus = JobStatus.QUEUED
|
||||||
|
message: str = ""
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
finished_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SensorWeightOverride(BaseModel):
|
||||||
|
actuator_entity_id: str
|
||||||
|
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryAnalysis(BaseModel):
|
||||||
|
entity_id: str
|
||||||
|
samples: int
|
||||||
|
last_state: str | None = None
|
||||||
|
changed_at: datetime | None = None
|
||||||
|
unique_states: int = 0
|
||||||
|
transitions: int = 0
|
||||||
|
recommendation: str
|
||||||
|
|
||||||
|
|
||||||
class Decision(BaseModel):
|
class Decision(BaseModel):
|
||||||
actuator_entity_id: str
|
actuator_entity_id: str
|
||||||
target_state: str | None = None
|
target_state: str | None = None
|
||||||
@@ -121,6 +194,11 @@ class LearningState(BaseModel):
|
|||||||
profiles: dict[str, LearningProfile] = Field(default_factory=dict)
|
profiles: dict[str, LearningProfile] = Field(default_factory=dict)
|
||||||
rooms: dict[str, RoomProfile] = Field(default_factory=dict)
|
rooms: dict[str, RoomProfile] = Field(default_factory=dict)
|
||||||
scenes: dict[str, SceneProfile] = Field(default_factory=dict)
|
scenes: dict[str, SceneProfile] = Field(default_factory=dict)
|
||||||
|
automation_proposals: dict[str, AutomationProposal] = Field(default_factory=dict)
|
||||||
|
models: dict[str, ModelRecord] = Field(default_factory=dict)
|
||||||
|
model_evaluations: dict[str, ModelEvaluation] = Field(default_factory=dict)
|
||||||
|
jobs: dict[str, JobQueueItem] = Field(default_factory=dict)
|
||||||
|
weight_overrides: dict[str, SensorWeightOverride] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class ControlState(BaseModel):
|
class ControlState(BaseModel):
|
||||||
|
|||||||
325
app/main.py
325
app/main.py
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
@@ -15,16 +16,24 @@ from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
|
|||||||
from app.core.handoff import HandoffMatrix
|
from app.core.handoff import HandoffMatrix
|
||||||
from app.core.models import (
|
from app.core.models import (
|
||||||
AuditEvent,
|
AuditEvent,
|
||||||
|
AutomationProposal,
|
||||||
BackupBundle,
|
BackupBundle,
|
||||||
BehaviorPatternV2,
|
BehaviorPatternV2,
|
||||||
ControlProfile,
|
ControlProfile,
|
||||||
ControlState,
|
ControlState,
|
||||||
EntityState,
|
EntityState,
|
||||||
|
HistoryAnalysis,
|
||||||
HandoffMode,
|
HandoffMode,
|
||||||
|
JobQueueItem,
|
||||||
|
JobStatus,
|
||||||
LearningProfile,
|
LearningProfile,
|
||||||
LearningState,
|
LearningState,
|
||||||
|
ModelEvaluation,
|
||||||
|
ModelRecord,
|
||||||
|
ProposalStatus,
|
||||||
RuntimeState,
|
RuntimeState,
|
||||||
SafetyStage,
|
SafetyStage,
|
||||||
|
SensorWeightOverride,
|
||||||
StateEvent,
|
StateEvent,
|
||||||
)
|
)
|
||||||
from app.core.stores import FutureStores
|
from app.core.stores import FutureStores
|
||||||
@@ -86,6 +95,34 @@ class ActuatorSummary(BaseModel):
|
|||||||
feedback_negative: 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)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
global ha_client
|
global ha_client
|
||||||
@@ -106,7 +143,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="SillyHome Future API",
|
title="SillyHome Future API",
|
||||||
description="SillyHome v2 event-core side project.",
|
description="SillyHome v2 event-core side project.",
|
||||||
version="2.0.0-alpha.10",
|
version="2.0.0-alpha.12",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -159,6 +196,9 @@ def dashboard_data() -> dict[str, object]:
|
|||||||
"global_enabled": control.global_enabled,
|
"global_enabled": control.global_enabled,
|
||||||
"learning_profiles": len(learning.profiles),
|
"learning_profiles": len(learning.profiles),
|
||||||
"control_profiles": len(control.profiles),
|
"control_profiles": len(control.profiles),
|
||||||
|
"automation_proposals": len(learning.automation_proposals),
|
||||||
|
"models": len(learning.models),
|
||||||
|
"jobs": len(learning.jobs),
|
||||||
"rooms": list(learning.rooms.values()),
|
"rooms": list(learning.rooms.values()),
|
||||||
"scenes": list(learning.scenes.values()),
|
"scenes": list(learning.scenes.values()),
|
||||||
"audit": latest_audit,
|
"audit": latest_audit,
|
||||||
@@ -185,17 +225,167 @@ def feature_parity() -> dict[str, object]:
|
|||||||
"Simulation",
|
"Simulation",
|
||||||
"Audit",
|
"Audit",
|
||||||
"Health",
|
"Health",
|
||||||
|
"Automation-Proposals",
|
||||||
|
"YAML-Export",
|
||||||
|
"History-Analyse",
|
||||||
|
"lokales Modelltraining",
|
||||||
|
"Sensor-Gewichte",
|
||||||
|
"Job-Queue",
|
||||||
],
|
],
|
||||||
"next_to_expand": [
|
"next_to_expand": ["Dashboard-Flaechen fuer History/Modelle"],
|
||||||
"automations-proposals",
|
|
||||||
"history-analysis",
|
|
||||||
"model-training",
|
|
||||||
"weight-overrides",
|
|
||||||
"job-queue",
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v2/automations/proposals", response_model=AutomationProposal)
|
||||||
|
def create_automation_proposal(payload: AutomationProposalRequest) -> AutomationProposal:
|
||||||
|
state = stores.learning()
|
||||||
|
proposal_id = f"proposal-{len(state.automation_proposals) + 1}"
|
||||||
|
proposal = AutomationProposal(
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
name=payload.name,
|
||||||
|
trigger_entity_id=payload.trigger_entity_id,
|
||||||
|
trigger_state=payload.trigger_state,
|
||||||
|
actuator_entity_id=payload.actuator_entity_id,
|
||||||
|
target_state=payload.target_state,
|
||||||
|
)
|
||||||
|
state.automation_proposals[proposal_id] = proposal
|
||||||
|
stores.save_learning(state)
|
||||||
|
_append_job("automation_proposal", f"Automation-Vorschlag {proposal_id} angelegt.")
|
||||||
|
return proposal
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v2/automations/proposals", response_model=list[AutomationProposal])
|
||||||
|
def list_automation_proposals() -> list[AutomationProposal]:
|
||||||
|
return list(stores.learning().automation_proposals.values())
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v2/automations/proposals/{proposal_id}/approve", response_model=AutomationProposal)
|
||||||
|
def approve_automation_proposal(proposal_id: str) -> AutomationProposal:
|
||||||
|
return _decide_automation_proposal(proposal_id, ProposalStatus.APPROVED)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v2/automations/proposals/{proposal_id}/reject", response_model=AutomationProposal)
|
||||||
|
def reject_automation_proposal(proposal_id: str) -> AutomationProposal:
|
||||||
|
return _decide_automation_proposal(proposal_id, ProposalStatus.REJECTED)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v2/automations/proposals/{proposal_id}/yaml")
|
||||||
|
def automation_proposal_yaml(proposal_id: str) -> str:
|
||||||
|
proposal = stores.learning().automation_proposals[proposal_id]
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
f"alias: {proposal.name}",
|
||||||
|
"trigger:",
|
||||||
|
" - platform: state",
|
||||||
|
f" entity_id: {proposal.trigger_entity_id}",
|
||||||
|
f" to: {proposal.trigger_state or ''}",
|
||||||
|
"action:",
|
||||||
|
" - service: homeassistant.turn_on",
|
||||||
|
" target:",
|
||||||
|
f" entity_id: {proposal.actuator_entity_id}",
|
||||||
|
" data:",
|
||||||
|
f" target_state: {proposal.target_state}",
|
||||||
|
"mode: single",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v2/history/analyze", response_model=list[HistoryAnalysis])
|
||||||
|
def analyze_history(request: HistoryAnalysisRequest) -> list[HistoryAnalysis]:
|
||||||
|
runtime = stores.runtime()
|
||||||
|
ids = request.entity_ids or list(runtime.entities)[:50]
|
||||||
|
history: dict[str, list[StateEvent]] = {}
|
||||||
|
if ha_client is not None and request.start_time is not None and request.end_time is not None:
|
||||||
|
history = ha_client.read_history(ids, request.start_time, request.end_time)
|
||||||
|
result: list[HistoryAnalysis] = []
|
||||||
|
for entity_id in ids:
|
||||||
|
entity = runtime.entities.get(entity_id)
|
||||||
|
samples = history.get(entity_id, [])
|
||||||
|
matching_audit = [item for item in runtime.audit if item.entity_id == entity_id]
|
||||||
|
states = [sample.new_state for sample in samples if sample.new_state is not None]
|
||||||
|
result.append(
|
||||||
|
HistoryAnalysis(
|
||||||
|
entity_id=entity_id,
|
||||||
|
samples=max(1, len(samples) or len(matching_audit)),
|
||||||
|
last_state=states[-1] if states else (entity.state if entity else None),
|
||||||
|
changed_at=samples[-1].changed_at if samples else (entity.changed_at if entity else None),
|
||||||
|
unique_states=len(set(states)) if states else int(entity is not None),
|
||||||
|
transitions=_count_transitions(states),
|
||||||
|
recommendation=(
|
||||||
|
"Als Trigger geeignet."
|
||||||
|
if entity is not None and entity.domain in {"binary_sensor", "sensor"}
|
||||||
|
else "Als Aktor oder Kontext pruefen."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_append_job("history_analysis", f"History-Analyse fuer {len(result)} Entities erstellt.")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v2/models/train", response_model=list[ModelRecord])
|
||||||
|
def train_models(request: TrainModelRequest) -> list[ModelRecord]:
|
||||||
|
state = stores.learning()
|
||||||
|
actuator_ids = [request.actuator_entity_id] if request.actuator_entity_id else list(state.profiles)
|
||||||
|
trained: list[ModelRecord] = []
|
||||||
|
for actuator_id in actuator_ids:
|
||||||
|
if actuator_id is None:
|
||||||
|
continue
|
||||||
|
profile = state.profiles.get(actuator_id)
|
||||||
|
pattern_count = len(profile.patterns) if profile else 0
|
||||||
|
confidence = (
|
||||||
|
sum(pattern.confidence for pattern in profile.patterns) / pattern_count
|
||||||
|
if profile and pattern_count
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
model = ModelRecord(
|
||||||
|
model_id=f"model-{actuator_id}-{len(state.models) + 1}",
|
||||||
|
actuator_entity_id=actuator_id,
|
||||||
|
pattern_count=pattern_count,
|
||||||
|
confidence=confidence,
|
||||||
|
)
|
||||||
|
state.models[model.model_id] = model
|
||||||
|
trained.append(model)
|
||||||
|
stores.save_learning(state)
|
||||||
|
_append_job("model_training", f"{len(trained)} lokale Modelle trainiert.")
|
||||||
|
return trained
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v2/models", response_model=list[ModelRecord])
|
||||||
|
def list_models() -> list[ModelRecord]:
|
||||||
|
return list(stores.learning().models.values())
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v2/models/evaluate", response_model=list[ModelEvaluation])
|
||||||
|
def evaluate_models(request: EvaluateModelRequest) -> list[ModelEvaluation]:
|
||||||
|
state = stores.learning()
|
||||||
|
control = stores.control()
|
||||||
|
models = list(state.models.values())
|
||||||
|
if request.model_id:
|
||||||
|
models = [model for model in models if model.model_id == request.model_id]
|
||||||
|
if request.actuator_entity_id:
|
||||||
|
models = [model for model in models if model.actuator_entity_id == request.actuator_entity_id]
|
||||||
|
evaluations = [
|
||||||
|
_evaluate_model(model, state, control)
|
||||||
|
for model in models
|
||||||
|
]
|
||||||
|
for evaluation in evaluations:
|
||||||
|
state.model_evaluations[evaluation.evaluation_id] = evaluation
|
||||||
|
stores.save_learning(state)
|
||||||
|
_append_job("model_evaluation", f"{len(evaluations)} Modelle bewertet.")
|
||||||
|
return evaluations
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v2/models/evaluations", response_model=list[ModelEvaluation])
|
||||||
|
def list_model_evaluations() -> list[ModelEvaluation]:
|
||||||
|
return list(stores.learning().model_evaluations.values())[-100:]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v2/jobs", response_model=list[JobQueueItem])
|
||||||
|
def list_jobs() -> list[JobQueueItem]:
|
||||||
|
return list(stores.learning().jobs.values())[-100:]
|
||||||
|
|
||||||
|
|
||||||
@app.post("/v2/events/state", response_model=list[AuditEvent])
|
@app.post("/v2/events/state", response_model=list[AuditEvent])
|
||||||
def ingest_state_event(event: StateEvent) -> list[AuditEvent]:
|
def ingest_state_event(event: StateEvent) -> list[AuditEvent]:
|
||||||
return event_core.process_state_event(event, execute=_execute_ha_decision)
|
return event_core.process_state_event(event, execute=_execute_ha_decision)
|
||||||
@@ -421,6 +611,31 @@ def record_feedback(actuator_entity_id: str, feedback: FeedbackRequest) -> Learn
|
|||||||
return updated
|
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)
|
@app.post("/v2/planning/refresh", response_model=LearningState)
|
||||||
def refresh_planning() -> LearningState:
|
def refresh_planning() -> LearningState:
|
||||||
state = stores.learning()
|
state = stores.learning()
|
||||||
@@ -676,6 +891,97 @@ def _append_audit(kind: str, entity_id: str | None, message: str) -> None:
|
|||||||
stores.save_runtime(runtime)
|
stores.save_runtime(runtime)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_job(kind: str, message: str, status: JobStatus = JobStatus.SUCCEEDED) -> JobQueueItem:
|
||||||
|
state = stores.learning()
|
||||||
|
job = JobQueueItem(
|
||||||
|
job_id=f"job-{len(state.jobs) + 1}",
|
||||||
|
kind=kind,
|
||||||
|
status=status,
|
||||||
|
message=message,
|
||||||
|
finished_at=datetime.now(timezone.utc) if status is JobStatus.SUCCEEDED else None,
|
||||||
|
)
|
||||||
|
state.jobs[job.job_id] = job
|
||||||
|
stores.save_learning(state)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _decide_automation_proposal(
|
||||||
|
proposal_id: str,
|
||||||
|
status: ProposalStatus,
|
||||||
|
) -> AutomationProposal:
|
||||||
|
state = stores.learning()
|
||||||
|
proposal = state.automation_proposals[proposal_id]
|
||||||
|
updated = proposal.model_copy(
|
||||||
|
update={
|
||||||
|
"status": status,
|
||||||
|
"revision": proposal.revision + 1,
|
||||||
|
"decided_at": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
state.automation_proposals[proposal_id] = updated
|
||||||
|
stores.save_learning(state)
|
||||||
|
_append_job("automation_proposal", f"Automation-Vorschlag {proposal_id}: {status}.")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
def _count_transitions(states: list[str]) -> int:
|
||||||
|
if not states:
|
||||||
|
return 0
|
||||||
|
transitions = 0
|
||||||
|
previous = states[0]
|
||||||
|
for state in states[1:]:
|
||||||
|
transitions += int(state != previous)
|
||||||
|
previous = state
|
||||||
|
return transitions
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_model(
|
||||||
|
model: ModelRecord,
|
||||||
|
learning: LearningState,
|
||||||
|
control: ControlState,
|
||||||
|
) -> ModelEvaluation:
|
||||||
|
profile = learning.profiles.get(
|
||||||
|
model.actuator_entity_id,
|
||||||
|
LearningProfile(actuator_entity_id=model.actuator_entity_id),
|
||||||
|
)
|
||||||
|
control_profile = control.profiles.get(
|
||||||
|
model.actuator_entity_id,
|
||||||
|
ControlProfile(actuator_entity_id=model.actuator_entity_id),
|
||||||
|
)
|
||||||
|
coverage = min(1.0, model.pattern_count / 5)
|
||||||
|
dry_run_events = max(1, control_profile.dry_run_events)
|
||||||
|
dry_run_success_rate = control_profile.dry_run_successes / dry_run_events
|
||||||
|
feedback_total = profile.feedback_positive + profile.feedback_negative
|
||||||
|
feedback_score = (
|
||||||
|
profile.feedback_positive / feedback_total if feedback_total else 0.5
|
||||||
|
)
|
||||||
|
score = round(
|
||||||
|
(model.confidence * 0.35)
|
||||||
|
+ (coverage * 0.2)
|
||||||
|
+ (dry_run_success_rate * 0.3)
|
||||||
|
+ (feedback_score * 0.15),
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
reasons = [
|
||||||
|
f"Modell-Confidence {model.confidence:.0%}",
|
||||||
|
f"Pattern-Abdeckung {coverage:.0%}",
|
||||||
|
f"Dry-run-Erfolg {dry_run_success_rate:.0%}",
|
||||||
|
f"Feedback-Score {feedback_score:.0%}",
|
||||||
|
]
|
||||||
|
verdict = "bereit" if score >= 0.82 and control_profile.active_ready else "weiter testen"
|
||||||
|
return ModelEvaluation(
|
||||||
|
evaluation_id=f"eval-{model.model_id}-{len(learning.model_evaluations) + 1}",
|
||||||
|
model_id=model.model_id,
|
||||||
|
actuator_entity_id=model.actuator_entity_id,
|
||||||
|
score=score,
|
||||||
|
coverage=coverage,
|
||||||
|
dry_run_success_rate=dry_run_success_rate,
|
||||||
|
feedback_score=feedback_score,
|
||||||
|
verdict=verdict,
|
||||||
|
reasons=reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _execute_ha_decision(decision: object) -> bool:
|
def _execute_ha_decision(decision: object) -> bool:
|
||||||
if ha_client is None or not hasattr(decision, "actuator_entity_id"):
|
if ha_client is None or not hasattr(decision, "actuator_entity_id"):
|
||||||
return False
|
return False
|
||||||
@@ -858,6 +1164,9 @@ def _dashboard_html() -> str:
|
|||||||
['Not-Aus', data.global_enabled ? 'frei' : 'aktiv'],
|
['Not-Aus', data.global_enabled ? 'frei' : 'aktiv'],
|
||||||
['Lernen', data.learning_profiles],
|
['Lernen', data.learning_profiles],
|
||||||
['Steuerung', data.control_profiles],
|
['Steuerung', data.control_profiles],
|
||||||
|
['Vorschlaege', data.automation_proposals],
|
||||||
|
['Modelle', data.models],
|
||||||
|
['Jobs', data.jobs],
|
||||||
['Räume', data.rooms.length],
|
['Räume', data.rooms.length],
|
||||||
['Szenen', data.scenes.length],
|
['Szenen', data.scenes.length],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-future"
|
name = "sillyhome-future"
|
||||||
version = "2.0.0-alpha.10"
|
version = "2.0.0-alpha.12"
|
||||||
description = "SillyHome v2 event-core prototype"
|
description = "SillyHome v2 event-core prototype"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ def test_addon_config_declares_future_addon() -> None:
|
|||||||
config = yaml.safe_load(Path("addon/config.yaml").read_text(encoding="utf-8"))
|
config = yaml.safe_load(Path("addon/config.yaml").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
assert config["slug"] == "sillyhome_future"
|
assert config["slug"] == "sillyhome_future"
|
||||||
assert config["version"] == "2.0.0-alpha.10"
|
assert config["version"] == "2.0.0-alpha.12"
|
||||||
assert config["ingress"] is True
|
assert config["ingress"] is True
|
||||||
assert config["ingress_port"] == 8099
|
assert config["ingress_port"] == 8099
|
||||||
assert config["homeassistant_api"] is True
|
assert config["homeassistant_api"] is True
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ig
|
|||||||
restore = client.post("/v2/backup/restore", json=backup.json())
|
restore = client.post("/v2/backup/restore", json=backup.json())
|
||||||
|
|
||||||
assert health.status_code == 200
|
assert health.status_code == 200
|
||||||
assert health.json()["version"] == "2.0.0-alpha.10"
|
assert health.json()["version"] == "2.0.0-alpha.12"
|
||||||
assert backup.status_code == 200
|
assert backup.status_code == 200
|
||||||
assert restore.status_code == 200
|
assert restore.status_code == 200
|
||||||
assert restore.json() == {"status": "restored"}
|
assert restore.json() == {"status": "restored"}
|
||||||
@@ -81,6 +81,30 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
|
|||||||
summary = client.get("/v2/actuators/summary")
|
summary = client.get("/v2/actuators/summary")
|
||||||
parity = client.get("/v2/feature-parity")
|
parity = client.get("/v2/feature-parity")
|
||||||
anomalies = client.get("/v2/anomalies")
|
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")
|
||||||
|
jobs = client.get("/v2/jobs")
|
||||||
dashboard = client.get("/v2/dashboard")
|
dashboard = client.get("/v2/dashboard")
|
||||||
|
|
||||||
assert entities.status_code == 200
|
assert entities.status_code == 200
|
||||||
@@ -105,7 +129,26 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
|
|||||||
assert summary.status_code == 200
|
assert summary.status_code == 200
|
||||||
assert summary.json()[0]["actuator_entity_id"] == "light.storage"
|
assert summary.json()[0]["actuator_entity_id"] == "light.storage"
|
||||||
assert parity.status_code == 200
|
assert parity.status_code == 200
|
||||||
assert "Feedback" in parity.json()["implemented"]
|
assert "Job-Queue" in parity.json()["implemented"]
|
||||||
assert anomalies.status_code == 200
|
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 jobs.status_code == 200
|
||||||
|
assert jobs.json()
|
||||||
assert dashboard.status_code == 200
|
assert dashboard.status_code == 200
|
||||||
assert dashboard.json()["actuator_count"] == 1
|
assert dashboard.json()["actuator_count"] == 1
|
||||||
|
assert dashboard.json()["automation_proposals"] == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user