From 2f750e3e41c0d7590ee517354490ebe12260861f Mon Sep 17 00:00:00 2001 From: Otto Date: Thu, 18 Jun 2026 17:27:25 +0200 Subject: [PATCH] Add autopilot light workflow --- addon/Dockerfile | 2 +- addon/config.yaml | 2 +- app/core/models.py | 30 ++++++ app/main.py | 197 +++++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- tests/test_addon_config.py | 2 +- tests/test_future_api.py | 21 +++- 7 files changed, 244 insertions(+), 12 deletions(-) diff --git a/addon/Dockerfile b/addon/Dockerfile index 738bcc7..1374aa7 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.12 +ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.13 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 dea8988..8df5289 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Future -version: "2.0.0-alpha.12" +version: "2.0.0-alpha.13" 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 afc7077..6ec377e 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -34,6 +34,12 @@ class JobStatus(StrEnum): FAILED = "failed" +class CandidateStatus(StrEnum): + PROPOSED = "proposed" + ACCEPTED = "accepted" + DISMISSED = "dismissed" + + class EntityState(BaseModel): entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$") domain: str @@ -152,6 +158,28 @@ class SensorWeightOverride(BaseModel): updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) +class AutopilotSettings(BaseModel): + enabled: bool = True + interval_seconds: int = Field(default=3600, ge=300) + min_candidate_confidence: float = Field(default=0.65, ge=0.0, le=1.0) + auto_train: bool = True + auto_evaluate: bool = True + auto_activate: bool = False + last_run_at: datetime | None = None + + +class CandidateRecommendation(BaseModel): + candidate_id: str + actuator_entity_id: str + trigger_entity_id: str + trigger_state: str | None = None + target_state: str + confidence: float = Field(default=0.0, ge=0.0, le=1.0) + reason: str + status: CandidateStatus = CandidateStatus.PROPOSED + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + class HistoryAnalysis(BaseModel): entity_id: str samples: int @@ -199,11 +227,13 @@ class LearningState(BaseModel): model_evaluations: dict[str, ModelEvaluation] = Field(default_factory=dict) jobs: dict[str, JobQueueItem] = Field(default_factory=dict) weight_overrides: dict[str, SensorWeightOverride] = Field(default_factory=dict) + candidates: dict[str, CandidateRecommendation] = Field(default_factory=dict) class ControlState(BaseModel): profiles: dict[str, ControlProfile] = Field(default_factory=dict) global_enabled: bool = True + autopilot: AutopilotSettings = Field(default_factory=AutopilotSettings) class BackupBundle(BaseModel): diff --git a/app/main.py b/app/main.py index 2aa7377..848ceda 100644 --- a/app/main.py +++ b/app/main.py @@ -16,9 +16,12 @@ from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state from app.core.handoff import HandoffMatrix from app.core.models import ( AuditEvent, + AutopilotSettings, AutomationProposal, BackupBundle, BehaviorPatternV2, + CandidateRecommendation, + CandidateStatus, ControlProfile, ControlState, EntityState, @@ -123,27 +126,36 @@ class WeightOverrideRequest(BaseModel): note: str | None = Field(default=None, max_length=500) +class AutopilotRunResult(BaseModel): + candidates: list[CandidateRecommendation] + trained_models: list[ModelRecord] + evaluations: list[ModelEvaluation] + + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: global ha_client listener_task: asyncio.Task[None] | None = None + autopilot_task: asyncio.Task[None] | None = None ha_client = _ha_client_from_env() if ha_client is not None: _load_initial_ha_states(ha_client) listener_task = asyncio.create_task(_ha_listener_loop(ha_client)) + autopilot_task = asyncio.create_task(_autopilot_loop()) try: yield finally: - if listener_task is not None: - listener_task.cancel() - with suppress(asyncio.CancelledError): - await listener_task + for task in (listener_task, autopilot_task): + if task is not None: + task.cancel() + with suppress(asyncio.CancelledError): + await task app = FastAPI( title="SillyHome Future API", description="SillyHome v2 event-core side project.", - version="2.0.0-alpha.12", + version="2.0.0-alpha.13", lifespan=lifespan, ) @@ -199,6 +211,9 @@ def dashboard_data() -> dict[str, object]: "automation_proposals": len(learning.automation_proposals), "models": len(learning.models), "jobs": len(learning.jobs), + "candidates": len(learning.candidates), + "autopilot_enabled": control.autopilot.enabled, + "autopilot_last_run_at": control.autopilot.last_run_at, "rooms": list(learning.rooms.values()), "scenes": list(learning.scenes.values()), "audit": latest_audit, @@ -231,11 +246,87 @@ def feature_parity() -> dict[str, object]: "lokales Modelltraining", "Sensor-Gewichte", "Job-Queue", + "Autopilot light", + "Kandidaten-Vorschlaege", + "periodisches Training/Bewertung", ], - "next_to_expand": ["Dashboard-Flaechen fuer History/Modelle"], + "next_to_expand": ["Dashboard-Flaechen fuer Autopilot/History/Modelle"], } +@app.get("/v2/autopilot/settings", response_model=AutopilotSettings) +def get_autopilot_settings() -> AutopilotSettings: + return stores.control().autopilot + + +@app.put("/v2/autopilot/settings", response_model=AutopilotSettings) +def put_autopilot_settings(settings: AutopilotSettings) -> AutopilotSettings: + control = stores.control().model_copy(update={"autopilot": settings}) + stores.save_control(control) + _append_audit( + "autopilot", + None, + "Autopilot light aktiviert." if settings.enabled else "Autopilot light deaktiviert.", + ) + return settings + + +@app.post("/v2/autopilot/run", response_model=AutopilotRunResult) +def run_autopilot() -> AutopilotRunResult: + return _run_autopilot_once() + + +@app.get("/v2/autopilot/candidates", response_model=list[CandidateRecommendation]) +def list_candidates() -> list[CandidateRecommendation]: + return list(stores.learning().candidates.values()) + + +@app.post("/v2/autopilot/candidates/{candidate_id}/accept", response_model=LearningProfile) +def accept_candidate(candidate_id: str) -> LearningProfile: + state = stores.learning() + candidate = state.candidates[candidate_id].model_copy( + update={"status": CandidateStatus.ACCEPTED} + ) + state.candidates[candidate_id] = candidate + profile = state.profiles.get( + candidate.actuator_entity_id, + LearningProfile(actuator_entity_id=candidate.actuator_entity_id), + ) + profile = profile.model_copy( + update={ + "patterns": [ + *profile.patterns, + BehaviorPatternV2( + actuator_entity_id=candidate.actuator_entity_id, + target_state=candidate.target_state, + trigger_entity_id=candidate.trigger_entity_id, + trigger_state=candidate.trigger_state, + support=3, + confidence=candidate.confidence, + source="autopilot", + ), + ], + "model_version": "autopilot-light", + } + ) + state.profiles[candidate.actuator_entity_id] = profile + stores.save_learning(state) + _append_job("autopilot_candidate", f"Kandidat {candidate_id} akzeptiert.") + return profile + + +@app.post("/v2/autopilot/candidates/{candidate_id}/dismiss", response_model=CandidateRecommendation) +def dismiss_candidate(candidate_id: str) -> CandidateRecommendation: + state = stores.learning() + candidate = state.candidates[candidate_id].model_copy( + update={"status": CandidateStatus.DISMISSED} + ) + state.candidates[candidate_id] = candidate + stores.save_learning(state) + _append_job("autopilot_candidate", f"Kandidat {candidate_id} verworfen.") + return candidate + + @app.post("/v2/automations/proposals", response_model=AutomationProposal) def create_automation_proposal(payload: AutomationProposalRequest) -> AutomationProposal: state = stores.learning() @@ -845,6 +936,98 @@ async def _ha_listener_loop(client: FutureHaClient) -> None: await asyncio.sleep(2) +async def _autopilot_loop() -> None: + while True: + control = stores.control() + settings = control.autopilot + if settings.enabled: + await asyncio.to_thread(_run_autopilot_once) + await asyncio.sleep(stores.control().autopilot.interval_seconds) + + +def _run_autopilot_once() -> AutopilotRunResult: + control = stores.control() + settings = control.autopilot + if not settings.enabled: + return AutopilotRunResult(candidates=[], trained_models=[], evaluations=[]) + learning = stores.learning() + candidates = _generate_candidates(settings) + for candidate in candidates: + learning.candidates[candidate.candidate_id] = candidate + stores.save_learning(learning) + updated_settings = settings.model_copy(update={"last_run_at": datetime.now(timezone.utc)}) + stores.save_control(control.model_copy(update={"autopilot": updated_settings})) + trained = train_models(TrainModelRequest()) if settings.auto_train else [] + evaluations = evaluate_models(EvaluateModelRequest()) if settings.auto_evaluate else [] + _append_job("autopilot", f"Autopilot light: {len(candidates)} Kandidaten erzeugt.") + return AutopilotRunResult(candidates=candidates, trained_models=trained, evaluations=evaluations) + + +def _generate_candidates(settings: AutopilotSettings) -> list[CandidateRecommendation]: + runtime = stores.runtime() + existing = stores.learning().candidates + actuators = [ + entity + for entity in runtime.entities.values() + if entity.domain in {"light", "switch", "fan", "cover", "humidifier"} + ][:150] + triggers = [ + entity + for entity in runtime.entities.values() + if entity.domain in {"binary_sensor", "sensor"} + ][:500] + result: list[CandidateRecommendation] = [] + for actuator in actuators: + best_trigger: EntityState | None = None + best_score = 0.0 + best_reason = "" + for trigger in triggers: + score, reason = _candidate_score(actuator, trigger) + if score > best_score: + best_score = score + best_reason = reason + best_trigger = trigger + if best_trigger is None or best_score < settings.min_candidate_confidence: + continue + candidate_id = f"{actuator.entity_id}:{best_trigger.entity_id}:on" + if candidate_id in existing: + continue + result.append( + CandidateRecommendation( + candidate_id=candidate_id, + actuator_entity_id=actuator.entity_id, + trigger_entity_id=best_trigger.entity_id, + trigger_state="on" if best_trigger.domain == "binary_sensor" else None, + target_state="open" if actuator.domain == "cover" else "on", + confidence=round(best_score, 2), + reason=best_reason, + ) + ) + if len(result) >= 20: + break + return result + + +def _candidate_score(actuator: EntityState, trigger: EntityState) -> tuple[float, str]: + if actuator.area_name and actuator.area_name == trigger.area_name: + return 0.82, f"Gleicher Raum: {actuator.area_name}" + actuator_tokens = _entity_tokens(actuator) + trigger_tokens = _entity_tokens(trigger) + overlap = actuator_tokens & trigger_tokens + if overlap: + return min(0.78, 0.55 + (0.08 * len(overlap))), ( + "Aehnliche Namen: " + ", ".join(sorted(overlap)[:4]) + ) + if trigger.domain == "binary_sensor" and actuator.domain in {"light", "switch"}: + return 0.62, "Binary-Sensor passt grundsaetzlich zu Licht/Schalter." + return 0.0, "" + + +def _entity_tokens(entity: EntityState) -> set[str]: + raw = f"{entity.entity_id} {entity.friendly_name or ''}".lower() + return {part for part in raw.replace(".", "_").split("_") if len(part) >= 4} + + def _set_websocket_status(status: str) -> None: runtime = stores.runtime() if runtime.websocket_status == status: @@ -1167,6 +1350,8 @@ def _dashboard_html() -> str: ['Vorschlaege', data.automation_proposals], ['Modelle', data.models], ['Jobs', data.jobs], + ['Kandidaten', data.candidates], + ['Autopilot', data.autopilot_enabled ? 'aktiv' : 'aus'], ['Räume', data.rooms.length], ['Szenen', data.scenes.length], ]; diff --git a/pyproject.toml b/pyproject.toml index fc8e86f..5016438 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.12" +version = "2.0.0-alpha.13" 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 06fba45..9839152 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.12" + assert config["version"] == "2.0.0-alpha.13" 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 bca0203..f94d75e 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.12" + assert health.json()["version"] == "2.0.0-alpha.13" assert backup.status_code == 200 assert restore.status_code == 200 assert restore.json() == {"status": "restored"} @@ -39,6 +39,13 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None entity_id="binary_sensor.storage_door", domain="binary_sensor", state="off", + area_name="Storage", + ), + "light.storage_door": EntityState( + entity_id="light.storage_door", + domain="light", + state="off", + area_name="Storage", ), } ) @@ -104,6 +111,11 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None models = client.post("/v2/models/train", json={"actuator_entity_id": "light.storage"}) evaluation = client.post("/v2/models/evaluate", json={"actuator_entity_id": "light.storage"}) evaluations = client.get("/v2/models/evaluations") + autopilot = client.post("/v2/autopilot/run") + candidates = client.get("/v2/autopilot/candidates") + accepted = client.post( + f"/v2/autopilot/candidates/{autopilot.json()['candidates'][0]['candidate_id']}/accept" + ) jobs = client.get("/v2/jobs") dashboard = client.get("/v2/dashboard") @@ -111,6 +123,7 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None assert [item["entity_id"] for item in entities.json()] == [ "binary_sensor.storage_door", "light.storage", + "light.storage_door", ] assert control.status_code == 200 assert control.json()["stage"] == "dry_run" @@ -147,8 +160,12 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None assert evaluation.json()[0]["verdict"] in {"bereit", "weiter testen"} assert evaluations.status_code == 200 assert evaluations.json() + assert autopilot.status_code == 200 + assert autopilot.json()["candidates"] + assert candidates.status_code == 200 + assert accepted.status_code == 200 assert jobs.status_code == 200 assert jobs.json() assert dashboard.status_code == 200 - assert dashboard.json()["actuator_count"] == 1 + assert dashboard.json()["actuator_count"] == 2 assert dashboard.json()["automation_proposals"] == 1