diff --git a/addon/Dockerfile b/addon/Dockerfile
index 76a1106..c5289d7 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.9
+ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.10
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 ff7bd19..2567bf8 100644
--- a/addon/config.yaml
+++ b/addon/config.yaml
@@ -1,12 +1,12 @@
name: SillyHome Future
-version: "2.0.0-alpha.9"
+version: "2.0.0-alpha.10"
slug: sillyhome_future
description: Event-first SillyHome v2 test controller
url: http://192.168.6.31:3000/Otto/sillyhome-future
arch:
- amd64
startup: application
-boot: manual
+boot: auto
watchdog: http://[HOST]:[PORT:8099]/health
init: false
ingress: true
diff --git a/app/main.py b/app/main.py
index a348244..864f2c8 100644
--- a/app/main.py
+++ b/app/main.py
@@ -70,6 +70,22 @@ class ActiveReadiness(BaseModel):
dry_run_failures: int
+class FeedbackRequest(BaseModel):
+ kind: str = Field(default="correct", max_length=40)
+ expected_state: str | None = Field(default=None, max_length=100)
+ note: str | None = Field(default=None, max_length=500)
+
+
+class ActuatorSummary(BaseModel):
+ actuator_entity_id: str
+ stage: SafetyStage
+ handoff_mode: HandoffMode
+ active_ready: bool
+ pattern_count: int
+ feedback_positive: int
+ feedback_negative: int
+
+
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
global ha_client
@@ -90,7 +106,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.9",
+ version="2.0.0-alpha.10",
lifespan=lifespan,
)
@@ -149,6 +165,37 @@ def dashboard_data() -> dict[str, object]:
}
+@app.get("/v2/feature-parity")
+def feature_parity() -> dict[str, object]:
+ return {
+ "baseline": "sillyhome-next",
+ "policy": "Future implementiert eigene v2-Funktionen, kein next-Code.",
+ "implemented": [
+ "HA REST/WebSocket",
+ "Service-Ausfuehrung",
+ "Dashboard",
+ "Aktor-Control",
+ "Lernmuster",
+ "Dry-run",
+ "Safety-Gates",
+ "Feedback",
+ "Backup/Restore",
+ "Raeume/Szenen",
+ "Not-Aus",
+ "Simulation",
+ "Audit",
+ "Health",
+ ],
+ "next_to_expand": [
+ "automations-proposals",
+ "history-analysis",
+ "model-training",
+ "weight-overrides",
+ "job-queue",
+ ],
+ }
+
+
@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)
@@ -203,6 +250,45 @@ def get_control() -> ControlState:
return stores.control()
+@app.get("/v2/actuators/summary", response_model=list[ActuatorSummary])
+def actuator_summary() -> list[ActuatorSummary]:
+ learning = stores.learning()
+ control = stores.control()
+ ids = sorted(set(learning.profiles) | set(control.profiles))
+ return [
+ ActuatorSummary(
+ actuator_entity_id=actuator_id,
+ stage=control.profiles.get(
+ actuator_id,
+ ControlProfile(actuator_entity_id=actuator_id),
+ ).stage,
+ handoff_mode=control.profiles.get(
+ actuator_id,
+ ControlProfile(actuator_entity_id=actuator_id),
+ ).handoff_mode,
+ active_ready=control.profiles.get(
+ actuator_id,
+ ControlProfile(actuator_entity_id=actuator_id),
+ ).active_ready,
+ pattern_count=len(
+ learning.profiles.get(
+ actuator_id,
+ LearningProfile(actuator_entity_id=actuator_id),
+ ).patterns
+ ),
+ feedback_positive=learning.profiles.get(
+ actuator_id,
+ LearningProfile(actuator_entity_id=actuator_id),
+ ).feedback_positive,
+ feedback_negative=learning.profiles.get(
+ actuator_id,
+ LearningProfile(actuator_entity_id=actuator_id),
+ ).feedback_negative,
+ )
+ for actuator_id in ids
+ ]
+
+
@app.post("/v2/control/global", response_model=ControlState)
def set_global_control(update: GlobalControlUpdate) -> ControlState:
state = stores.control().model_copy(update={"global_enabled": update.enabled})
@@ -305,6 +391,93 @@ def create_learning_pattern(
return updated
+@app.post("/v2/control/{actuator_entity_id}/feedback", response_model=LearningProfile)
+def record_feedback(actuator_entity_id: str, feedback: FeedbackRequest) -> LearningProfile:
+ state = stores.learning()
+ profile = state.profiles.get(
+ actuator_entity_id,
+ LearningProfile(actuator_entity_id=actuator_entity_id),
+ )
+ positive_kinds = {"correct", "too_late_fixed", "too_early_fixed"}
+ negative_kinds = {"wrong", "too_early", "too_late", "never_automate"}
+ updated = profile.model_copy(
+ update={
+ "feedback_positive": profile.feedback_positive
+ + int(feedback.kind in positive_kinds),
+ "feedback_negative": profile.feedback_negative
+ + int(feedback.kind in negative_kinds),
+ }
+ )
+ state.profiles[actuator_entity_id] = updated
+ stores.save_learning(state)
+ if feedback.kind == "never_automate":
+ control = stores.control()
+ control.profiles[actuator_entity_id] = control.profiles.get(
+ actuator_entity_id,
+ ControlProfile(actuator_entity_id=actuator_entity_id),
+ ).model_copy(update={"manual_block": True, "stage": SafetyStage.BLOCKED})
+ stores.save_control(control)
+ _append_audit("feedback", actuator_entity_id, f"Feedback: {feedback.kind}")
+ return updated
+
+
+@app.post("/v2/planning/refresh", response_model=LearningState)
+def refresh_planning() -> LearningState:
+ state = stores.learning()
+ runtime = stores.runtime()
+ rooms = dict(state.rooms)
+ scenes = dict(state.scenes)
+ for entity in runtime.entities.values():
+ room_name = entity.area_name
+ if not room_name:
+ continue
+ room_id = room_name.lower().replace(" ", "_")
+ room = rooms.get(room_id)
+ actuator_ids = []
+ context_ids = []
+ if entity.domain in {"light", "switch", "fan", "cover", "humidifier"}:
+ actuator_ids.append(entity.entity_id)
+ else:
+ context_ids.append(entity.entity_id)
+ if room is None:
+ from app.core.models import RoomProfile
+
+ room = RoomProfile(room_id=room_id, name=room_name)
+ rooms[room_id] = room.model_copy(
+ update={
+ "actuator_entity_ids": sorted(
+ set(room.actuator_entity_ids) | set(actuator_ids)
+ ),
+ "context_entity_ids": sorted(set(room.context_entity_ids) | set(context_ids)),
+ }
+ )
+ state = state.model_copy(update={"rooms": rooms, "scenes": scenes})
+ stores.save_learning(state)
+ _append_audit("planning", None, "Planung aktualisiert.")
+ return state
+
+
+@app.get("/v2/anomalies")
+def anomalies() -> list[dict[str, object]]:
+ runtime = stores.runtime()
+ result: list[dict[str, object]] = []
+ unavailable = [
+ entity.entity_id
+ for entity in runtime.entities.values()
+ if entity.state in {"unavailable", "unknown"}
+ ][:100]
+ if unavailable:
+ result.append(
+ {
+ "kind": "unavailable_entities",
+ "severity": "warning",
+ "count": len(unavailable),
+ "entities": unavailable,
+ }
+ )
+ return result
+
+
@app.post("/v2/simulate")
def simulate_decision(request: SimulationRequest) -> dict[str, object]:
runtime = stores.runtime()
@@ -554,37 +727,37 @@ def _dashboard_html() -> str:
Control
+ Steuerung
Learning Pattern
+ Lernmuster
Aktuelles Profil
Lade...
Lade...