diff --git a/addon/Dockerfile b/addon/Dockerfile index e49bf11..18a1a7a 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.7 +ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.8 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 6c1d305..fa3ae02 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Future -version: "2.0.0-alpha.7" +version: "2.0.0-alpha.8" slug: sillyhome_future description: Event-first SillyHome v2 test controller url: http://192.168.6.31:3000/Otto/sillyhome-future diff --git a/app/main.py b/app/main.py index 2728eee..2bcb290 100644 --- a/app/main.py +++ b/app/main.py @@ -7,6 +7,7 @@ from contextlib import asynccontextmanager, suppress from fastapi import FastAPI from fastapi.responses import HTMLResponse +from pydantic import BaseModel, Field from app.core.event_core import EventCore from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state @@ -14,12 +15,15 @@ from app.core.handoff import HandoffMatrix from app.core.models import ( AuditEvent, BackupBundle, + BehaviorPatternV2, ControlProfile, ControlState, EntityState, HandoffMode, + LearningProfile, LearningState, RuntimeState, + SafetyStage, StateEvent, ) from app.core.stores import FutureStores @@ -30,6 +34,22 @@ 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) + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: global ha_client @@ -50,7 +70,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.7", + version="2.0.0-alpha.8", lifespan=lifespan, ) @@ -76,9 +96,15 @@ def dashboard_data() -> dict[str, object]: 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), "learning_profiles": len(learning.profiles), "control_profiles": len(control.profiles), "rooms": list(learning.rooms.values()), @@ -97,6 +123,23 @@ 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/learning", response_model=LearningState) def get_learning() -> LearningState: return stores.learning() @@ -120,6 +163,62 @@ def put_control(actuator_entity_id: str, profile: ControlProfile) -> ControlProf 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), + ) + updated = profile.model_copy( + update={ + "stage": update.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) + return updated + + +@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/handoff/{actuator_entity_id}/assume", response_model=ControlProfile) def assume_control(actuator_entity_id: str) -> ControlProfile: state = stores.control() @@ -280,30 +379,155 @@ def _dashboard_html() -> str: SillyHome Future

SillyHome Future

+
+
+

Control

+ + + + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+

Learning Pattern

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+

Aktuelles Profil

+
Lade...
+
+

Audit

Lade...
diff --git a/pyproject.toml b/pyproject.toml index c97d191..1b5bb77 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.7" +version = "2.0.0-alpha.8" 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 ec16b47..064979f 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.7" + assert config["version"] == "2.0.0-alpha.8" 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 81fcc02..cfd3c89 100644 --- a/tests/test_future_api.py +++ b/tests/test_future_api.py @@ -2,20 +2,80 @@ from __future__ import annotations from fastapi.testclient import TestClient -from app.main import app, stores +import app.main as main +from app.core.event_core import EventCore +from app.core.models import EntityState, RuntimeState +from app.core.stores import FutureStores def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] - monkeypatch.setenv("SILLYHOME_FUTURE_STORE", str(tmp_path)) - client = TestClient(app) + test_stores = FutureStores(tmp_path) + monkeypatch.setattr(main, "stores", test_stores) + monkeypatch.setattr(main, "event_core", EventCore(test_stores)) + client = TestClient(main.app) health = client.get("/health") backup = client.get("/v2/backup/export") restore = client.post("/v2/backup/restore", json=backup.json()) - assert stores is not None assert health.status_code == 200 - assert health.json()["version"] == "2.0.0-alpha.7" + assert health.json()["version"] == "2.0.0-alpha.8" assert backup.status_code == 200 assert restore.status_code == 200 assert restore.json() == {"status": "restored"} + + +def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] + test_stores = FutureStores(tmp_path) + test_stores.save_runtime( + RuntimeState( + entities={ + "light.storage": EntityState( + entity_id="light.storage", + domain="light", + state="off", + ), + "binary_sensor.storage_door": EntityState( + entity_id="binary_sensor.storage_door", + domain="binary_sensor", + state="off", + ), + } + ) + ) + monkeypatch.setattr(main, "stores", test_stores) + monkeypatch.setattr(main, "event_core", EventCore(test_stores)) + client = TestClient(main.app) + + entities = client.get("/v2/entities?domain=light,binary_sensor") + control = client.post( + "/v2/control/light.storage/stage", + json={ + "stage": "dry_run", + "min_confidence": 0.75, + "manual_block": False, + "cooldown_seconds": 120, + }, + ) + learning = client.post( + "/v2/learning/light.storage/patterns", + json={ + "trigger_entity_id": "binary_sensor.storage_door", + "trigger_state": "on", + "target_state": "on", + "confidence": 0.91, + }, + ) + dashboard = client.get("/v2/dashboard") + + assert entities.status_code == 200 + assert [item["entity_id"] for item in entities.json()] == [ + "binary_sensor.storage_door", + "light.storage", + ] + assert control.status_code == 200 + assert control.json()["stage"] == "dry_run" + assert learning.status_code == 200 + assert learning.json()["patterns"][0]["trigger_entity_id"] == "binary_sensor.storage_door" + assert dashboard.status_code == 200 + assert dashboard.json()["actuator_count"] == 1