From e4b860571a95a05a9b110e86028b7d9dfde7c865 Mon Sep 17 00:00:00 2001 From: Otto Date: Thu, 18 Jun 2026 14:26:26 +0200 Subject: [PATCH] Enable watchdog and localize dashboard --- addon/Dockerfile | 2 +- addon/config.yaml | 4 +- app/main.py | 219 +++++++++++++++++++++++++++++++++---- pyproject.toml | 2 +- tests/test_addon_config.py | 4 +- tests/test_future_api.py | 13 ++- 6 files changed, 218 insertions(+), 26 deletions(-) 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

- +
- +
- +
Ready-Status wird geladen...
-

Learning Pattern

+

Lernmuster

- +
@@ -594,22 +767,28 @@ def _dashboard_html() -> str:
- +
- - + +

Aktuelles Profil

Lade...
-

Audit

+

Prüfprotokoll

Lade...