diff --git a/addon/Dockerfile b/addon/Dockerfile index c5289d7..b719650 100644 --- a/addon/Dockerfile +++ b/addon/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.13-slim 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 \ "http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz" diff --git a/addon/config.yaml b/addon/config.yaml index 2567bf8..7b76ca1 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Future -version: "2.0.0-alpha.10" +version: "2.0.0-alpha.11" slug: sillyhome_future description: Event-first SillyHome v2 test controller url: http://192.168.6.31:3000/Otto/sillyhome-future diff --git a/app/core/models.py b/app/core/models.py index c5ab32f..5759d31 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -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): diff --git a/app/main.py b/app/main.py index 864f2c8..c8964a9 100644 --- a/app/main.py +++ b/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], ]; diff --git a/pyproject.toml b/pyproject.toml index f786529..1098f9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sillyhome-future" -version = "2.0.0-alpha.10" +version = "2.0.0-alpha.11" description = "SillyHome v2 event-core prototype" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_addon_config.py b/tests/test_addon_config.py index ac9421e..4e3f7d8 100644 --- a/tests/test_addon_config.py +++ b/tests/test_addon_config.py @@ -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")) 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_port"] == 8099 assert config["homeassistant_api"] is True diff --git a/tests/test_future_api.py b/tests/test_future_api.py index 9b69ce1..d9506a8 100644 --- a/tests/test_future_api.py +++ b/tests/test_future_api.py @@ -19,7 +19,7 @@ def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ig restore = client.post("/v2/backup/restore", json=backup.json()) assert health.status_code == 200 - assert health.json()["version"] == "2.0.0-alpha.10" + assert health.json()["version"] == "2.0.0-alpha.11" assert backup.status_code == 200 assert restore.status_code == 200 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") parity = client.get("/v2/feature-parity") anomalies = client.get("/v2/anomalies") + proposal = client.post( + "/v2/automations/proposals", + json={ + "name": "Storage light", + "trigger_entity_id": "binary_sensor.storage_door", + "trigger_state": "on", + "actuator_entity_id": "light.storage", + "target_state": "on", + }, + ) + proposal_yaml = client.get("/v2/automations/proposals/proposal-1/yaml") + approved = client.post("/v2/automations/proposals/proposal-1/approve") + weights = client.post( + "/v2/control/light.storage/weights", + json={"sensor_weights": {"binary_sensor.storage_door": 1.0}, "note": "door"}, + ) + history = client.post( + "/v2/history/analyze", + json={"entity_ids": ["binary_sensor.storage_door"]}, + ) + models = client.post("/v2/models/train", json={"actuator_entity_id": "light.storage"}) + jobs = client.get("/v2/jobs") dashboard = client.get("/v2/dashboard") 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.json()[0]["actuator_entity_id"] == "light.storage" assert parity.status_code == 200 - assert "Feedback" in parity.json()["implemented"] + assert "Job-Queue" in parity.json()["implemented"] assert anomalies.status_code == 200 + assert proposal.status_code == 200 + assert proposal.json()["status"] == "draft" + assert proposal_yaml.status_code == 200 + assert "alias: Storage light" in proposal_yaml.text + assert approved.status_code == 200 + assert approved.json()["status"] == "approved" + assert weights.status_code == 200 + assert weights.json()["sensor_weights"]["binary_sensor.storage_door"] == 1.0 + assert history.status_code == 200 + assert history.json()[0]["entity_id"] == "binary_sensor.storage_door" + assert models.status_code == 200 + assert models.json()[0]["actuator_entity_id"] == "light.storage" + assert jobs.status_code == 200 + assert jobs.json() assert dashboard.status_code == 200 assert dashboard.json()["actuator_count"] == 1 + assert dashboard.json()["automation_proposals"] == 1