SillyHome Future
+Control
+ + + + +Learning Pattern
+Aktuelles Profil
+Lade...+
Audit
Lade...
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:
Lade...+
Lade...