Add Future parity feature blocks

This commit is contained in:
2026-06-18 16:20:30 +02:00
parent e4b860571a
commit 07e3e96c30
7 changed files with 324 additions and 14 deletions

View File

@@ -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.11
RUN python -m pip install --no-cache-dir \ RUN python -m pip install --no-cache-dir \
"http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz" "http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz"

View File

@@ -1,5 +1,5 @@
name: SillyHome Future name: SillyHome Future
version: "2.0.0-alpha.10" version: "2.0.0-alpha.11"
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

View File

@@ -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,51 @@ 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 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): class Decision(BaseModel):
actuator_entity_id: str actuator_entity_id: str
target_state: str | None = None target_state: str | None = None
@@ -121,6 +179,10 @@ 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)
jobs: dict[str, JobQueueItem] = Field(default_factory=dict)
weight_overrides: dict[str, SensorWeightOverride] = Field(default_factory=dict)
class ControlState(BaseModel): class ControlState(BaseModel):

View File

@@ -4,6 +4,7 @@ import asyncio
import os import os
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from datetime import datetime, timezone
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
@@ -15,16 +16,23 @@ 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,
ModelRecord,
ProposalStatus,
RuntimeState, RuntimeState,
SafetyStage, SafetyStage,
SensorWeightOverride,
StateEvent, StateEvent,
) )
from app.core.stores import FutureStores from app.core.stores import FutureStores
@@ -86,6 +94,27 @@ 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)
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 @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]: async def lifespan(app: FastAPI) -> AsyncIterator[None]:
global ha_client global ha_client
@@ -106,7 +135,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.11",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -159,6 +188,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 +217,135 @@ 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": ["echte Langzeit-History aus HA", "fortgeschrittene Modellbewertung"],
"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]
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]) @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 +571,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 +851,39 @@ 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 _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 +1066,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],
]; ];

View File

@@ -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.11"
description = "SillyHome v2 event-core prototype" description = "SillyHome v2 event-core prototype"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -9,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.11"
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

View File

@@ -19,7 +19,7 @@ def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ig
restore = client.post("/v2/backup/restore", json=backup.json()) restore = client.post("/v2/backup/restore", json=backup.json())
assert health.status_code == 200 assert health.status_code == 200
assert health.json()["version"] == "2.0.0-alpha.10" assert health.json()["version"] == "2.0.0-alpha.11"
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,28 @@ 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"})
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 +127,22 @@ 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 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