Compare commits
9 Commits
v2.0.0-alp
...
v2.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| e4b860571a | |||
| 3c6fe24b03 | |||
| 01e5464ec1 | |||
| 937c79a19b | |||
| 149f11a18e | |||
| 38a85fce74 | |||
| b77992606a | |||
| 9d01af98b2 | |||
| 1336bc36fa |
13
README.md
13
README.md
@@ -22,6 +22,18 @@ pip install -e ".[dev]"
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
## Home Assistant Add-on
|
||||
|
||||
Add this repository in Home Assistant:
|
||||
|
||||
```text
|
||||
http://192.168.6.31:3000/Otto/sillyhome-future
|
||||
```
|
||||
|
||||
Then install **SillyHome Future**. The add-on is intentionally independent from
|
||||
`sillyhome-next` and starts its own v2 event-core API on port `8099` behind
|
||||
Ingress.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
@@ -29,4 +41,3 @@ pytest
|
||||
ruff check .
|
||||
mypy app tests
|
||||
```
|
||||
|
||||
|
||||
13
addon/Dockerfile
Normal file
13
addon/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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"
|
||||
|
||||
COPY run.sh /run.sh
|
||||
RUN chmod 0755 /run.sh \
|
||||
&& useradd --create-home --uid 10001 sillyhome
|
||||
|
||||
ENTRYPOINT ["/run.sh"]
|
||||
28
addon/config.yaml
Normal file
28
addon/config.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
name: SillyHome Future
|
||||
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: auto
|
||||
watchdog: http://[HOST]:[PORT:8099]/health
|
||||
init: false
|
||||
ingress: true
|
||||
ingress_port: 8099
|
||||
panel_title: SillyHome Future
|
||||
panel_icon: mdi:home-lightning-bolt
|
||||
panel_admin: true
|
||||
homeassistant_api: true
|
||||
hassio_api: false
|
||||
auth_api: false
|
||||
map:
|
||||
- type: addon_config
|
||||
read_only: false
|
||||
options:
|
||||
store_path: /data/future_store
|
||||
log_level: info
|
||||
schema:
|
||||
store_path: str
|
||||
log_level: list(debug|info|warning|error)
|
||||
31
addon/run.sh
Normal file
31
addon/run.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
export PYTHONUNBUFFERED=1
|
||||
|
||||
STORE_PATH="/data/future_store"
|
||||
LOG_LEVEL="info"
|
||||
if [ -f /data/options.json ]; then
|
||||
eval "$(python - <<'PY'
|
||||
import json
|
||||
import shlex
|
||||
|
||||
with open("/data/options.json", encoding="utf-8") as stream:
|
||||
options = json.load(stream)
|
||||
for name, env_name in {
|
||||
"store_path": "STORE_PATH",
|
||||
"log_level": "LOG_LEVEL",
|
||||
}.items():
|
||||
if name in options:
|
||||
print(f"{env_name}={shlex.quote(str(options[name]))}")
|
||||
PY
|
||||
)"
|
||||
fi
|
||||
|
||||
export SILLYHOME_FUTURE_STORE="$STORE_PATH"
|
||||
mkdir -p "$SILLYHOME_FUTURE_STORE"
|
||||
chown -R sillyhome:sillyhome "$SILLYHOME_FUTURE_STORE"
|
||||
|
||||
exec su -s /bin/sh sillyhome -c \
|
||||
"uvicorn app.main:app --host 0.0.0.0 --port 8099 --log-level ${LOG_LEVEL}"
|
||||
|
||||
@@ -27,6 +27,8 @@ class DecisionEngineV2:
|
||||
blockers.append(f"Domain {domain} ist nicht fuer Active-Control freigegeben.")
|
||||
if control.manual_block:
|
||||
blockers.append("Manuelle Sicherheitssperre ist aktiv.")
|
||||
if control.stage is SafetyStage.ACTIVE and not control.active_ready:
|
||||
blockers.append("Active ist noch nicht freigegeben. Erst Dry-run-Reife erreichen.")
|
||||
best = None
|
||||
for pattern in learning.patterns:
|
||||
if pattern.trigger_entity_id != trigger_entity_id:
|
||||
@@ -63,4 +65,3 @@ class DecisionEngineV2:
|
||||
blockers=blockers,
|
||||
trigger_entity_id=trigger_entity_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.core.decision import DecisionEngineV2
|
||||
from app.core.handoff import HandoffMatrix
|
||||
from app.core.models import (
|
||||
AuditEvent,
|
||||
ControlProfile,
|
||||
Decision,
|
||||
EntityState,
|
||||
LearningProfile,
|
||||
StateEvent,
|
||||
@@ -31,7 +33,12 @@ class EventCore:
|
||||
self._decision = DecisionEngineV2()
|
||||
self._handoff = HandoffMatrix()
|
||||
|
||||
def process_state_event(self, event: StateEvent) -> list[AuditEvent]:
|
||||
def process_state_event(
|
||||
self,
|
||||
event: StateEvent,
|
||||
*,
|
||||
execute: Callable[[Decision], bool] | None = None,
|
||||
) -> list[AuditEvent]:
|
||||
runtime = self._stores.runtime()
|
||||
learning = self._stores.learning()
|
||||
control = self._stores.control()
|
||||
@@ -69,6 +76,19 @@ class EventCore:
|
||||
learning=learning_profile,
|
||||
control=control_profile,
|
||||
)
|
||||
if not control.global_enabled and decision.allowed:
|
||||
decision = decision.model_copy(
|
||||
update={
|
||||
"allowed": False,
|
||||
"reason": "Globaler Not-Aus ist aktiv.",
|
||||
"blockers": [*decision.blockers, "Globaler Not-Aus ist aktiv."],
|
||||
}
|
||||
)
|
||||
if decision.dry_run:
|
||||
control_profile = _record_dry_run(control_profile, decision)
|
||||
control.profiles[actuator_id] = control_profile
|
||||
if callable(execute) and decision.allowed and not decision.dry_run:
|
||||
decision = _execute_decision(decision, execute)
|
||||
audit.append(
|
||||
AuditEvent(
|
||||
event_id=_event_id("decision"),
|
||||
@@ -87,3 +107,39 @@ class EventCore:
|
||||
def _event_id(prefix: str) -> str:
|
||||
return f"{prefix}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S%f')}"
|
||||
|
||||
|
||||
def _execute_decision(decision: Decision, execute: Callable[[Decision], bool]) -> Decision:
|
||||
try:
|
||||
result = execute(decision)
|
||||
except Exception as exc:
|
||||
return decision.model_copy(
|
||||
update={
|
||||
"executed": False,
|
||||
"allowed": False,
|
||||
"reason": f"Ausfuehrung fehlgeschlagen: {exc}",
|
||||
"blockers": [*decision.blockers, str(exc)],
|
||||
}
|
||||
)
|
||||
return decision.model_copy(update={"executed": bool(result)})
|
||||
|
||||
|
||||
def _record_dry_run(profile: ControlProfile, decision: Decision) -> ControlProfile:
|
||||
events = profile.dry_run_events + 1
|
||||
successes = profile.dry_run_successes + int(decision.allowed and not decision.blockers)
|
||||
failures = profile.dry_run_failures + int(bool(decision.blockers))
|
||||
success_rate = successes / events if events else 0.0
|
||||
ready = events >= 5 and success_rate >= 0.8 and failures <= 1
|
||||
reason = (
|
||||
f"Dry-run {successes}/{events} erfolgreich."
|
||||
if ready
|
||||
else f"Dry-run braucht mindestens 5 Events und 80% Treffer; aktuell {successes}/{events}."
|
||||
)
|
||||
return profile.model_copy(
|
||||
update={
|
||||
"dry_run_events": events,
|
||||
"dry_run_successes": successes,
|
||||
"dry_run_failures": failures,
|
||||
"active_ready": ready,
|
||||
"active_readiness_reason": reason,
|
||||
}
|
||||
)
|
||||
|
||||
163
app/core/ha_client.py
Normal file
163
app/core/ha_client.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
|
||||
from app.core.models import EntityState, StateEvent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HaClientConfig:
|
||||
core_url: str
|
||||
token: str
|
||||
websocket_url: str | None = None
|
||||
timeout_seconds: float = 15.0
|
||||
|
||||
|
||||
class FutureHaClient:
|
||||
def __init__(self, config: HaClientConfig) -> None:
|
||||
self._config = config
|
||||
self._core_url = config.core_url.rstrip("/")
|
||||
|
||||
def read_states(self) -> list[EntityState]:
|
||||
with httpx.Client(timeout=self._config.timeout_seconds) as client:
|
||||
response = client.get(
|
||||
f"{self._core_url}/api/states",
|
||||
headers=self._headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
result: list[EntityState] = []
|
||||
for item in payload if isinstance(payload, list) else []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
entity_id = item.get("entity_id")
|
||||
if not isinstance(entity_id, str) or "." not in entity_id:
|
||||
continue
|
||||
attributes = item.get("attributes")
|
||||
attr = attributes if isinstance(attributes, dict) else {}
|
||||
result.append(
|
||||
EntityState(
|
||||
entity_id=entity_id,
|
||||
domain=entity_id.split(".", 1)[0],
|
||||
state=item.get("state") if isinstance(item.get("state"), str) else None,
|
||||
changed_at=_parse_datetime(item.get("last_changed")),
|
||||
area_name=attr.get("area_name") if isinstance(attr.get("area_name"), str) else None,
|
||||
device_id=attr.get("device_id") if isinstance(attr.get("device_id"), str) else None,
|
||||
friendly_name=(
|
||||
attr.get("friendly_name")
|
||||
if isinstance(attr.get("friendly_name"), str)
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def call_service(
|
||||
self,
|
||||
domain: str,
|
||||
service: str,
|
||||
service_data: dict[str, object],
|
||||
) -> None:
|
||||
with httpx.Client(timeout=self._config.timeout_seconds) as client:
|
||||
response = client.post(
|
||||
f"{self._core_url}/api/services/{domain}/{service}",
|
||||
headers=self._headers,
|
||||
json=service_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
async def listen_state_events(self) -> AsyncIterator[StateEvent]:
|
||||
websocket_url = self._config.websocket_url or _default_websocket_url(self._core_url)
|
||||
async with websockets.connect(websocket_url, ping_interval=None) as websocket:
|
||||
auth_required = json.loads(await websocket.recv())
|
||||
if auth_required.get("type") != "auth_required":
|
||||
raise RuntimeError("Home Assistant websocket did not request authentication")
|
||||
await websocket.send(json.dumps({"type": "auth", "access_token": self._config.token}))
|
||||
auth_result = json.loads(await websocket.recv())
|
||||
if auth_result.get("type") != "auth_ok":
|
||||
raise RuntimeError("Home Assistant websocket authentication failed")
|
||||
await websocket.send(
|
||||
json.dumps({"id": 1, "type": "subscribe_events", "event_type": "state_changed"})
|
||||
)
|
||||
async for raw_message in websocket:
|
||||
data = json.loads(raw_message)
|
||||
if data.get("type") != "event":
|
||||
continue
|
||||
event = data.get("event")
|
||||
if not isinstance(event, dict) or event.get("event_type") != "state_changed":
|
||||
continue
|
||||
event_data = event.get("data")
|
||||
if not isinstance(event_data, dict):
|
||||
continue
|
||||
entity_id = event_data.get("entity_id")
|
||||
new_state = event_data.get("new_state")
|
||||
if not isinstance(entity_id, str) or not isinstance(new_state, dict):
|
||||
continue
|
||||
state = new_state.get("state")
|
||||
attributes = new_state.get("attributes")
|
||||
attr = attributes if isinstance(attributes, dict) else {}
|
||||
yield StateEvent(
|
||||
entity_id=entity_id,
|
||||
new_state=state if isinstance(state, str) else None,
|
||||
changed_at=_parse_datetime(
|
||||
new_state.get("last_changed") or new_state.get("last_updated")
|
||||
),
|
||||
attributes={
|
||||
key: value
|
||||
for key, value in {
|
||||
"area_name": attr.get("area_name"),
|
||||
"device_id": attr.get("device_id"),
|
||||
"friendly_name": attr.get("friendly_name"),
|
||||
}.items()
|
||||
if isinstance(value, str)
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._config.token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def service_for_state(domain: str, target_state: str | None) -> tuple[str, str] | None:
|
||||
if target_state is None:
|
||||
return None
|
||||
if domain in {"light", "switch", "fan", "humidifier"}:
|
||||
if target_state == "on":
|
||||
return domain, "turn_on"
|
||||
if target_state == "off":
|
||||
return domain, "turn_off"
|
||||
if domain == "cover":
|
||||
if target_state in {"open", "opening"}:
|
||||
return domain, "open_cover"
|
||||
if target_state in {"closed", "closing"}:
|
||||
return domain, "close_cover"
|
||||
return None
|
||||
|
||||
|
||||
def _default_websocket_url(core_url: str) -> str:
|
||||
stripped = core_url.rstrip("/")
|
||||
if stripped.endswith("/core"):
|
||||
return stripped.replace("http://", "ws://").replace("https://", "wss://") + "/websocket"
|
||||
return stripped.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
|
||||
|
||||
|
||||
def _parse_datetime(value: object) -> datetime:
|
||||
if not isinstance(value, str):
|
||||
return datetime.now(timezone.utc)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return datetime.now(timezone.utc)
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
@@ -67,6 +67,11 @@ class ControlProfile(BaseModel):
|
||||
handoff_mode: HandoffMode = HandoffMode.SHADOW
|
||||
related_automation_ids: list[str] = Field(default_factory=list)
|
||||
paused_automation_ids: list[str] = Field(default_factory=list)
|
||||
dry_run_events: int = Field(default=0, ge=0)
|
||||
dry_run_successes: int = Field(default=0, ge=0)
|
||||
dry_run_failures: int = Field(default=0, ge=0)
|
||||
active_ready: bool = False
|
||||
active_readiness_reason: str = "Noch nicht bewertet."
|
||||
|
||||
|
||||
class RoomProfile(BaseModel):
|
||||
@@ -120,6 +125,7 @@ class LearningState(BaseModel):
|
||||
|
||||
class ControlState(BaseModel):
|
||||
profiles: dict[str, ControlProfile] = Field(default_factory=dict)
|
||||
global_enabled: bool = True
|
||||
|
||||
|
||||
class BackupBundle(BaseModel):
|
||||
@@ -127,4 +133,3 @@ class BackupBundle(BaseModel):
|
||||
runtime: RuntimeState
|
||||
learning: LearningState
|
||||
control: ControlState
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import TypeVar
|
||||
from typing import cast, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -18,13 +18,20 @@ class JsonDocumentStore:
|
||||
self.root = Path(root).resolve()
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = RLock()
|
||||
self._cache: dict[str, BaseModel] = {}
|
||||
|
||||
def load(self, name: str, model: type[T], default: T) -> T:
|
||||
path = self.root / name
|
||||
with self._lock:
|
||||
cached = self._cache.get(name)
|
||||
if cached is not None:
|
||||
return cast(T, cached)
|
||||
if not path.exists():
|
||||
self._cache[name] = default
|
||||
return default
|
||||
return model.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
value = model.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
self._cache[name] = value
|
||||
return value
|
||||
|
||||
def save(self, name: str, value: T) -> T:
|
||||
path = self.root / name
|
||||
@@ -35,6 +42,7 @@ class JsonDocumentStore:
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
self._cache[name] = value
|
||||
return value
|
||||
|
||||
|
||||
@@ -71,4 +79,3 @@ class FutureStores:
|
||||
self.save_runtime(bundle.runtime)
|
||||
self.save_learning(bundle.learning)
|
||||
self.save_control(bundle.control)
|
||||
|
||||
|
||||
844
app/main.py
844
app/main.py
@@ -1,41 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.decision import DecisionEngineV2
|
||||
from app.core.event_core import EventCore
|
||||
from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
|
||||
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
|
||||
|
||||
app = FastAPI(
|
||||
title="SillyHome Future API",
|
||||
description="SillyHome v2 event-core side project.",
|
||||
version="2.0.0-alpha.1",
|
||||
)
|
||||
stores = FutureStores(os.getenv("SILLYHOME_FUTURE_STORE", ".future_store"))
|
||||
event_core = EventCore(stores)
|
||||
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)
|
||||
|
||||
|
||||
class GlobalControlUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class SimulationRequest(BaseModel):
|
||||
actuator_entity_id: str
|
||||
trigger_entity_id: str
|
||||
trigger_state: str | None = None
|
||||
|
||||
|
||||
class ActiveReadiness(BaseModel):
|
||||
actuator_entity_id: str
|
||||
ready: bool
|
||||
reason: str
|
||||
dry_run_events: int
|
||||
dry_run_successes: int
|
||||
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
|
||||
listener_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))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if listener_task is not None:
|
||||
listener_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await listener_task
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="SillyHome Future API",
|
||||
description="SillyHome v2 event-core side project.",
|
||||
version="2.0.0-alpha.10",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok", "version": app.version}
|
||||
runtime = stores.runtime()
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": app.version,
|
||||
"ha": runtime.websocket_status,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v2/health")
|
||||
def detailed_health() -> dict[str, object]:
|
||||
runtime = stores.runtime()
|
||||
control = stores.control()
|
||||
return {
|
||||
"api": "ok",
|
||||
"version": app.version,
|
||||
"websocket": runtime.websocket_status,
|
||||
"store": "ok",
|
||||
"global_enabled": control.global_enabled,
|
||||
"entities": len(runtime.entities),
|
||||
"audit_events": len(runtime.audit),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def dashboard() -> str:
|
||||
return _dashboard_html()
|
||||
|
||||
|
||||
@app.get("/v2/dashboard")
|
||||
def dashboard_data() -> dict[str, object]:
|
||||
runtime = stores.runtime()
|
||||
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),
|
||||
"global_enabled": control.global_enabled,
|
||||
"learning_profiles": len(learning.profiles),
|
||||
"control_profiles": len(control.profiles),
|
||||
"rooms": list(learning.rooms.values()),
|
||||
"scenes": list(learning.scenes.values()),
|
||||
"audit": latest_audit,
|
||||
}
|
||||
|
||||
|
||||
@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)
|
||||
return event_core.process_state_event(event, execute=_execute_ha_decision)
|
||||
|
||||
|
||||
@app.get("/v2/runtime", response_model=RuntimeState)
|
||||
@@ -43,6 +206,35 @@ 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/entities/groups")
|
||||
def entity_groups() -> dict[str, list[EntityState]]:
|
||||
grouped: dict[str, list[EntityState]] = {}
|
||||
for entity in stores.runtime().entities.values():
|
||||
key = entity.area_name or entity.domain
|
||||
grouped.setdefault(key, []).append(entity)
|
||||
return {
|
||||
key: sorted(values, key=lambda entity: entity.entity_id)[:250]
|
||||
for key, values in sorted(grouped.items())
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v2/learning", response_model=LearningState)
|
||||
def get_learning() -> LearningState:
|
||||
return stores.learning()
|
||||
@@ -58,6 +250,57 @@ 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})
|
||||
stores.save_control(state)
|
||||
_append_audit(
|
||||
"safety",
|
||||
None,
|
||||
"Globaler Not-Aus deaktiviert." if update.enabled else "Globaler Not-Aus aktiviert.",
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
@app.put("/v2/control/{actuator_entity_id}", response_model=ControlProfile)
|
||||
def put_control(actuator_entity_id: str, profile: ControlProfile) -> ControlProfile:
|
||||
state = stores.control()
|
||||
@@ -66,6 +309,222 @@ 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),
|
||||
)
|
||||
requested_stage = update.stage
|
||||
if requested_stage is SafetyStage.ACTIVE and not profile.active_ready:
|
||||
requested_stage = SafetyStage.DRY_RUN
|
||||
updated = profile.model_copy(
|
||||
update={
|
||||
"stage": requested_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)
|
||||
_append_audit(
|
||||
"safety",
|
||||
actuator_entity_id,
|
||||
f"Stage gesetzt auf {updated.stage}."
|
||||
if updated.stage == update.stage
|
||||
else f"Active blockiert: {updated.active_readiness_reason}",
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
@app.get("/v2/control/{actuator_entity_id}/readiness", response_model=ActiveReadiness)
|
||||
def active_readiness(actuator_entity_id: str) -> ActiveReadiness:
|
||||
profile = stores.control().profiles.get(
|
||||
actuator_entity_id,
|
||||
ControlProfile(actuator_entity_id=actuator_entity_id),
|
||||
)
|
||||
return ActiveReadiness(
|
||||
actuator_entity_id=actuator_entity_id,
|
||||
ready=profile.active_ready,
|
||||
reason=profile.active_readiness_reason,
|
||||
dry_run_events=profile.dry_run_events,
|
||||
dry_run_successes=profile.dry_run_successes,
|
||||
dry_run_failures=profile.dry_run_failures,
|
||||
)
|
||||
|
||||
|
||||
@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/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()
|
||||
learning = stores.learning()
|
||||
control = stores.control()
|
||||
runtime.entities[request.trigger_entity_id] = EntityState(
|
||||
entity_id=request.trigger_entity_id,
|
||||
domain=request.trigger_entity_id.split(".", 1)[0],
|
||||
state=request.trigger_state,
|
||||
)
|
||||
profile = learning.profiles.get(
|
||||
request.actuator_entity_id,
|
||||
LearningProfile(actuator_entity_id=request.actuator_entity_id),
|
||||
)
|
||||
control_profile = control.profiles.get(
|
||||
request.actuator_entity_id,
|
||||
ControlProfile(actuator_entity_id=request.actuator_entity_id),
|
||||
)
|
||||
decision = DecisionEngineV2().decide(
|
||||
actuator_entity_id=request.actuator_entity_id,
|
||||
trigger_entity_id=request.trigger_entity_id,
|
||||
runtime=runtime,
|
||||
learning=profile,
|
||||
control=control_profile,
|
||||
)
|
||||
service = service_for_state(
|
||||
request.actuator_entity_id.split(".", 1)[0],
|
||||
decision.target_state,
|
||||
)
|
||||
return {
|
||||
"decision": decision,
|
||||
"would_call_service": service is not None and decision.allowed and not decision.dry_run,
|
||||
"service": service,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v2/audit/{actuator_entity_id}", response_model=list[AuditEvent])
|
||||
def actuator_audit(actuator_entity_id: str) -> list[AuditEvent]:
|
||||
return [
|
||||
item
|
||||
for item in stores.runtime().audit
|
||||
if item.entity_id == actuator_entity_id or (
|
||||
item.decision is not None and item.decision.actuator_entity_id == actuator_entity_id
|
||||
)
|
||||
][-50:]
|
||||
|
||||
|
||||
@app.post("/v2/handoff/{actuator_entity_id}/assume", response_model=ControlProfile)
|
||||
def assume_control(actuator_entity_id: str) -> ControlProfile:
|
||||
state = stores.control()
|
||||
@@ -111,3 +570,374 @@ def restore_backup(bundle: BackupBundle) -> dict[str, str]:
|
||||
stores.restore_backup(bundle)
|
||||
return {"status": "restored"}
|
||||
|
||||
|
||||
def _ha_client_from_env() -> FutureHaClient | None:
|
||||
token = os.getenv("SUPERVISOR_TOKEN") or os.getenv("SILLYHOME_FUTURE_HA_TOKEN")
|
||||
if not token:
|
||||
return None
|
||||
core_url = os.getenv("SILLYHOME_FUTURE_HA_CORE_URL", "http://supervisor/core")
|
||||
websocket_url = os.getenv("SILLYHOME_FUTURE_HA_WS_URL")
|
||||
return FutureHaClient(
|
||||
HaClientConfig(core_url=core_url, token=token, websocket_url=websocket_url)
|
||||
)
|
||||
|
||||
|
||||
def _load_initial_ha_states(client: FutureHaClient) -> None:
|
||||
runtime = stores.runtime()
|
||||
try:
|
||||
for entity in client.read_states():
|
||||
runtime.entities[entity.entity_id] = entity
|
||||
runtime.websocket_status = "initial_state_loaded"
|
||||
except Exception as exc:
|
||||
runtime.websocket_status = f"initial_state_error: {exc}"
|
||||
stores.save_runtime(runtime)
|
||||
|
||||
|
||||
async def _ha_listener_loop(client: FutureHaClient) -> None:
|
||||
routed_triggers: set[str] = set()
|
||||
next_route_refresh = 0.0
|
||||
pending_unrouted: list[StateEvent] = []
|
||||
last_unrouted_flush = 0.0
|
||||
while True:
|
||||
await asyncio.to_thread(_set_websocket_status, "connecting")
|
||||
try:
|
||||
async for event in client.listen_state_events():
|
||||
loop_time = asyncio.get_running_loop().time()
|
||||
if loop_time >= next_route_refresh:
|
||||
routed_triggers = await asyncio.to_thread(_routed_trigger_ids)
|
||||
next_route_refresh = loop_time + 5
|
||||
await asyncio.to_thread(_set_websocket_status, "connected")
|
||||
if event.entity_id in routed_triggers:
|
||||
if pending_unrouted:
|
||||
await asyncio.to_thread(_merge_unrouted_events, pending_unrouted)
|
||||
pending_unrouted = []
|
||||
await asyncio.to_thread(
|
||||
event_core.process_state_event,
|
||||
event,
|
||||
execute=_execute_ha_decision,
|
||||
)
|
||||
else:
|
||||
pending_unrouted.append(event)
|
||||
if len(pending_unrouted) >= 100 or loop_time - last_unrouted_flush >= 2:
|
||||
await asyncio.to_thread(_merge_unrouted_events, pending_unrouted)
|
||||
pending_unrouted = []
|
||||
last_unrouted_flush = loop_time
|
||||
await asyncio.sleep(0)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
await asyncio.to_thread(_set_websocket_status, f"reconnecting: {exc}")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
|
||||
def _set_websocket_status(status: str) -> None:
|
||||
runtime = stores.runtime()
|
||||
if runtime.websocket_status == status:
|
||||
return
|
||||
runtime.websocket_status = status
|
||||
stores.save_runtime(runtime)
|
||||
|
||||
|
||||
def _routed_trigger_ids() -> set[str]:
|
||||
result: set[str] = set()
|
||||
for profile in stores.learning().profiles.values():
|
||||
for pattern in profile.patterns:
|
||||
if pattern.trigger_entity_id is not None:
|
||||
result.add(pattern.trigger_entity_id)
|
||||
return result
|
||||
|
||||
|
||||
def _merge_unrouted_events(events: list[StateEvent]) -> None:
|
||||
if not events:
|
||||
return
|
||||
runtime = stores.runtime()
|
||||
for event in events:
|
||||
runtime.entities[event.entity_id] = EntityState(
|
||||
entity_id=event.entity_id,
|
||||
domain=event.entity_id.split(".", 1)[0],
|
||||
state=event.new_state,
|
||||
changed_at=event.changed_at,
|
||||
area_name=event.attributes.get("area_name"),
|
||||
device_id=event.attributes.get("device_id"),
|
||||
friendly_name=event.attributes.get("friendly_name"),
|
||||
)
|
||||
stores.save_runtime(runtime)
|
||||
|
||||
|
||||
def _append_audit(kind: str, entity_id: str | None, message: str) -> None:
|
||||
runtime = stores.runtime()
|
||||
event = AuditEvent(
|
||||
event_id=f"{kind}-{len(runtime.audit) + 1}",
|
||||
kind=kind,
|
||||
entity_id=entity_id,
|
||||
message=message,
|
||||
)
|
||||
runtime.audit = [*runtime.audit, event][-200:]
|
||||
stores.save_runtime(runtime)
|
||||
|
||||
|
||||
def _execute_ha_decision(decision: object) -> bool:
|
||||
if ha_client is None or not hasattr(decision, "actuator_entity_id"):
|
||||
return False
|
||||
actuator_entity_id = str(decision.actuator_entity_id)
|
||||
target_state = getattr(decision, "target_state", None)
|
||||
service = service_for_state(actuator_entity_id.split(".", 1)[0], target_state)
|
||||
if service is None:
|
||||
return False
|
||||
domain, service_name = service
|
||||
ha_client.call_service(domain, service_name, {"entity_id": actuator_entity_id})
|
||||
return True
|
||||
|
||||
|
||||
def _dashboard_html() -> str:
|
||||
return """<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>SillyHome Future</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg: #101418; --panel: #171d23; --line: #2c3640; --text: #eef3f6; --muted: #9fb0bd; --accent: #42d392; --warn: #f3c969; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--text); }
|
||||
main { max-width: 1180px; margin: 0 auto; padding: 20px; }
|
||||
h1 { font-size: 28px; margin: 0 0 18px; }
|
||||
h2 { font-size: 18px; margin: 24px 0 10px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
|
||||
.split { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 1.2fr); gap: 14px; align-items: start; }
|
||||
.card { border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: var(--panel); }
|
||||
.label { color: #9fb0bd; font-size: 13px; }
|
||||
.value { font-size: 24px; margin-top: 4px; }
|
||||
label { display: block; color: var(--muted); font-size: 13px; margin: 10px 0 5px; }
|
||||
input, select, button { width: 100%; border: 1px solid var(--line); border-radius: 7px; background: #0c1014; color: var(--text); font: inherit; padding: 10px; }
|
||||
button { cursor: pointer; background: #20303a; }
|
||||
button:hover { border-color: var(--accent); }
|
||||
.row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.stages { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 8px; }
|
||||
.stages button.active { border-color: var(--accent); color: var(--accent); }
|
||||
.primary { background: #17402c; border-color: #256f4a; }
|
||||
.status { min-height: 24px; color: var(--warn); margin-top: 10px; }
|
||||
pre { max-height: 360px; overflow: auto; white-space: pre-wrap; word-break: break-word; background: #0c1014; padding: 14px; border-radius: 8px; }
|
||||
@media (max-width: 800px) { .split, .row, .stages { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>SillyHome Future</h1>
|
||||
<section class="grid" id="metrics"></section>
|
||||
<section class="split">
|
||||
<div class="card">
|
||||
<h2>Steuerung</h2>
|
||||
<label for="entitySearch">Suche</label>
|
||||
<input id="entitySearch" placeholder="light., switch., sensor..." autocomplete="off">
|
||||
<label for="actuator">Aktor</label>
|
||||
<select id="actuator"></select>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="minConfidence">Mindest-Sicherheit</label>
|
||||
<input id="minConfidence" type="number" min="0" max="1" step="0.01" value="0.82">
|
||||
</div>
|
||||
<div>
|
||||
<label for="cooldown">Sperrzeit in Sekunden</label>
|
||||
<input id="cooldown" type="number" min="0" step="30" value="900">
|
||||
</div>
|
||||
</div>
|
||||
<label><input id="manualBlock" type="checkbox" style="width:auto;margin-right:6px"> Manuell blockieren</label>
|
||||
<div class="stages" id="stages"></div>
|
||||
<button class="primary" id="saveControl">Steuerung speichern</button>
|
||||
<button id="globalToggle">Globaler Not-Aus</button>
|
||||
<div class="status" id="readiness">Ready-Status wird geladen...</div>
|
||||
<div class="status" id="controlStatus"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Lernmuster</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="trigger">Trigger</label>
|
||||
<select id="trigger"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="triggerState">Trigger-Zustand</label>
|
||||
<input id="triggerState" placeholder="on, off, open...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="targetState">Zielzustand</label>
|
||||
<input id="targetState" value="on">
|
||||
</div>
|
||||
<div>
|
||||
<label for="patternConfidence">Sicherheit</label>
|
||||
<input id="patternConfidence" type="number" min="0" max="1" step="0.01" value="0.9">
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary" id="addPattern">Lernmuster anlegen</button>
|
||||
<button id="simulatePattern">Lernmuster simulieren</button>
|
||||
<div class="status" id="patternStatus"></div>
|
||||
<h2>Aktuelles Profil</h2>
|
||||
<pre id="profile">Lade...</pre>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Prüfprotokoll</h2>
|
||||
<pre id="audit">Lade...</pre>
|
||||
</main>
|
||||
<script>
|
||||
const stages = ['observe', 'dry_run', 'active', 'blocked'];
|
||||
const stageLabels = {
|
||||
observe: 'Beobachten',
|
||||
dry_run: 'Testlauf',
|
||||
active: 'Aktiv',
|
||||
blocked: 'Gesperrt',
|
||||
};
|
||||
const state = { entities: [], control: {}, learning: {}, selectedStage: 'observe' };
|
||||
|
||||
function optionText(entity) {
|
||||
const name = entity.friendly_name ? ` - ${entity.friendly_name}` : '';
|
||||
const current = entity.state == null ? '' : ` (${entity.state})`;
|
||||
return `${entity.entity_id}${current}${name}`;
|
||||
}
|
||||
|
||||
function setStatus(id, text) {
|
||||
document.getElementById(id).textContent = text;
|
||||
if (text) setTimeout(() => document.getElementById(id).textContent = '', 4000);
|
||||
}
|
||||
|
||||
function renderStages() {
|
||||
document.getElementById('stages').innerHTML = stages.map(stage =>
|
||||
`<button type="button" data-stage="${stage}" class="${stage === state.selectedStage ? 'active' : ''}">${stageLabels[stage]}</button>`
|
||||
).join('');
|
||||
document.querySelectorAll('[data-stage]').forEach(button => {
|
||||
button.onclick = () => { state.selectedStage = button.dataset.stage; renderStages(); };
|
||||
});
|
||||
}
|
||||
|
||||
function renderEntities() {
|
||||
const actuator = document.getElementById('actuator');
|
||||
const trigger = document.getElementById('trigger');
|
||||
const query = document.getElementById('entitySearch').value.toLowerCase();
|
||||
const shown = state.entities.filter(entity =>
|
||||
!query || optionText(entity).toLowerCase().includes(query)
|
||||
);
|
||||
const actuators = shown.filter(entity => ['light', 'switch', 'fan', 'cover', 'humidifier'].includes(entity.domain));
|
||||
actuator.innerHTML = actuators.map(entity => `<option value="${entity.entity_id}">${optionText(entity)}</option>`).join('');
|
||||
trigger.innerHTML = shown.map(entity => `<option value="${entity.entity_id}">${optionText(entity)}</option>`).join('');
|
||||
renderProfile();
|
||||
}
|
||||
|
||||
function renderProfile() {
|
||||
const id = document.getElementById('actuator').value;
|
||||
const profile = {
|
||||
control: state.control.profiles?.[id] || null,
|
||||
learning: state.learning.profiles?.[id] || null,
|
||||
};
|
||||
if (profile.control) {
|
||||
state.selectedStage = profile.control.stage;
|
||||
document.getElementById('minConfidence').value = profile.control.min_confidence;
|
||||
document.getElementById('cooldown').value = profile.control.cooldown_seconds;
|
||||
document.getElementById('manualBlock').checked = profile.control.manual_block;
|
||||
renderStages();
|
||||
}
|
||||
document.getElementById('profile').textContent = JSON.stringify(profile, null, 2);
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const [dashResponse, entitiesResponse, controlResponse, learningResponse] = await Promise.all([
|
||||
fetch('/v2/dashboard'),
|
||||
fetch('/v2/entities?domain=light,switch,fan,cover,humidifier,binary_sensor,sensor'),
|
||||
fetch('/v2/control'),
|
||||
fetch('/v2/learning'),
|
||||
]);
|
||||
const data = await dashResponse.json();
|
||||
state.entities = await entitiesResponse.json();
|
||||
state.control = await controlResponse.json();
|
||||
state.learning = await learningResponse.json();
|
||||
const metrics = [
|
||||
['WebSocket', data.websocket_status],
|
||||
['Entitäten', data.entity_count],
|
||||
['Aktoren', data.actuator_count],
|
||||
['Not-Aus', data.global_enabled ? 'frei' : 'aktiv'],
|
||||
['Lernen', data.learning_profiles],
|
||||
['Steuerung', data.control_profiles],
|
||||
['Räume', data.rooms.length],
|
||||
['Szenen', data.scenes.length],
|
||||
];
|
||||
document.getElementById('metrics').innerHTML = metrics.map(([label, value]) =>
|
||||
`<div class="card"><div class="label">${label}</div><div class="value">${value}</div></div>`
|
||||
).join('');
|
||||
renderEntities();
|
||||
await loadReadiness();
|
||||
document.getElementById('audit').textContent = JSON.stringify(data.audit, null, 2);
|
||||
}
|
||||
|
||||
async function loadReadiness() {
|
||||
const id = document.getElementById('actuator').value;
|
||||
if (!id) return;
|
||||
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/readiness`);
|
||||
const data = await response.json();
|
||||
document.getElementById('readiness').textContent =
|
||||
`${data.ready ? 'bereit fuer Aktiv' : 'nicht bereit fuer Aktiv'} - ${data.reason}`;
|
||||
}
|
||||
|
||||
document.getElementById('entitySearch').oninput = renderEntities;
|
||||
document.getElementById('actuator').onchange = () => { renderProfile(); loadReadiness(); };
|
||||
document.getElementById('saveControl').onclick = async () => {
|
||||
const id = document.getElementById('actuator').value;
|
||||
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/stage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
stage: state.selectedStage,
|
||||
min_confidence: Number(document.getElementById('minConfidence').value),
|
||||
manual_block: document.getElementById('manualBlock').checked,
|
||||
cooldown_seconds: Number(document.getElementById('cooldown').value),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
setStatus('controlStatus', 'Gespeichert');
|
||||
await loadDashboard();
|
||||
await loadReadiness();
|
||||
};
|
||||
document.getElementById('addPattern').onclick = async () => {
|
||||
const id = document.getElementById('actuator').value;
|
||||
const response = await fetch(`/v2/learning/${encodeURIComponent(id)}/patterns`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
trigger_entity_id: document.getElementById('trigger').value,
|
||||
trigger_state: document.getElementById('triggerState').value || null,
|
||||
target_state: document.getElementById('targetState').value,
|
||||
confidence: Number(document.getElementById('patternConfidence').value),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
setStatus('patternStatus', 'Pattern angelegt');
|
||||
await loadDashboard();
|
||||
};
|
||||
document.getElementById('simulatePattern').onclick = async () => {
|
||||
const id = document.getElementById('actuator').value;
|
||||
const response = await fetch('/v2/simulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
actuator_entity_id: id,
|
||||
trigger_entity_id: document.getElementById('trigger').value,
|
||||
trigger_state: document.getElementById('triggerState').value || null,
|
||||
}),
|
||||
});
|
||||
document.getElementById('profile').textContent = JSON.stringify(await response.json(), null, 2);
|
||||
};
|
||||
document.getElementById('globalToggle').onclick = async () => {
|
||||
const dash = await (await fetch('/v2/dashboard')).json();
|
||||
await fetch('/v2/control/global', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !dash.global_enabled }),
|
||||
});
|
||||
await loadDashboard();
|
||||
};
|
||||
renderStages();
|
||||
loadDashboard();
|
||||
setInterval(loadDashboard, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@@ -27,3 +27,9 @@ state, but switching decisions must remain inspectable and testable.
|
||||
6. Dashboard v2
|
||||
7. Add-on hardening
|
||||
|
||||
## Add-on Contract
|
||||
|
||||
The Home Assistant add-on is owned by this repository and does not copy code
|
||||
from `sillyhome-next`. It starts the v2 API with `SILLYHOME_FUTURE_STORE`
|
||||
pointing at `/data/future_store`, so runtime, learning and control stores are
|
||||
kept inside the add-on data volume.
|
||||
|
||||
@@ -4,13 +4,16 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-future"
|
||||
version = "2.0.0-alpha.1"
|
||||
version = "2.0.0-alpha.10"
|
||||
description = "SillyHome v2 event-core prototype"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.8",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"websockets>=12.0",
|
||||
"pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -32,4 +35,3 @@ target-version = "py311"
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
plugins = []
|
||||
|
||||
|
||||
4
repository.yaml
Normal file
4
repository.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
name: SillyHome Future Add-ons
|
||||
url: http://192.168.6.31:3000/Otto/sillyhome-future
|
||||
maintainer: Otto
|
||||
|
||||
23
tests/test_addon_config.py
Normal file
23
tests/test_addon_config.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml # type: ignore[import-untyped]
|
||||
|
||||
|
||||
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.10"
|
||||
assert config["ingress"] is True
|
||||
assert config["ingress_port"] == 8099
|
||||
assert config["homeassistant_api"] is True
|
||||
assert config["boot"] == "auto"
|
||||
assert config["watchdog"] == "http://[HOST]:[PORT:8099]/health"
|
||||
|
||||
|
||||
def test_repository_points_to_gitea_repo() -> None:
|
||||
repository = yaml.safe_load(Path("repository.yaml").read_text(encoding="utf-8"))
|
||||
|
||||
assert repository["url"] == "http://192.168.6.31:3000/Otto/sillyhome-future"
|
||||
@@ -2,21 +2,110 @@ 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.1"
|
||||
assert health.json()["version"] == "2.0.0-alpha.10"
|
||||
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,
|
||||
},
|
||||
)
|
||||
simulation = client.post(
|
||||
"/v2/simulate",
|
||||
json={
|
||||
"actuator_entity_id": "light.storage",
|
||||
"trigger_entity_id": "binary_sensor.storage_door",
|
||||
"trigger_state": "on",
|
||||
},
|
||||
)
|
||||
readiness = client.get("/v2/control/light.storage/readiness")
|
||||
global_control = client.post("/v2/control/global", json={"enabled": False})
|
||||
detailed_health = client.get("/v2/health")
|
||||
feedback = client.post("/v2/control/light.storage/feedback", json={"kind": "wrong"})
|
||||
summary = client.get("/v2/actuators/summary")
|
||||
parity = client.get("/v2/feature-parity")
|
||||
anomalies = client.get("/v2/anomalies")
|
||||
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 simulation.status_code == 200
|
||||
assert simulation.json()["decision"]["target_state"] == "on"
|
||||
assert readiness.status_code == 200
|
||||
assert readiness.json()["ready"] is False
|
||||
assert global_control.status_code == 200
|
||||
assert global_control.json()["global_enabled"] is False
|
||||
assert detailed_health.status_code == 200
|
||||
assert detailed_health.json()["global_enabled"] is False
|
||||
assert feedback.status_code == 200
|
||||
assert feedback.json()["feedback_negative"] == 1
|
||||
assert summary.status_code == 200
|
||||
assert summary.json()[0]["actuator_entity_id"] == "light.storage"
|
||||
assert parity.status_code == 200
|
||||
assert "Feedback" in parity.json()["implemented"]
|
||||
assert anomalies.status_code == 200
|
||||
assert dashboard.status_code == 200
|
||||
assert dashboard.json()["actuator_count"] == 1
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.core.handoff import HandoffMatrix
|
||||
from app.core.models import (
|
||||
BehaviorPatternV2,
|
||||
ControlProfile,
|
||||
Decision,
|
||||
EntityState,
|
||||
HandoffMode,
|
||||
LearningProfile,
|
||||
@@ -91,3 +92,120 @@ def test_handoff_matrix_detects_conflict_and_rollback() -> None:
|
||||
rolled_back = matrix.rollback(controlled)
|
||||
assert rolled_back.handoff_mode is HandoffMode.ROLLBACK
|
||||
assert rolled_back.paused_automation_ids == []
|
||||
|
||||
|
||||
def test_event_core_executes_allowed_active_decision(tmp_path: Path) -> None:
|
||||
stores = FutureStores(tmp_path)
|
||||
stores.save_runtime(
|
||||
RuntimeState(
|
||||
entities={
|
||||
"light.storage": EntityState(
|
||||
entity_id="light.storage",
|
||||
domain="light",
|
||||
state="off",
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
stores.save_learning(
|
||||
LearningState(
|
||||
profiles={
|
||||
"light.storage": LearningProfile(
|
||||
actuator_entity_id="light.storage",
|
||||
patterns=[
|
||||
BehaviorPatternV2(
|
||||
actuator_entity_id="light.storage",
|
||||
target_state="on",
|
||||
trigger_entity_id="binary_sensor.storage_door",
|
||||
trigger_state="on",
|
||||
support=3,
|
||||
confidence=0.95,
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
stores.save_control(
|
||||
stores.control().model_copy(
|
||||
update={
|
||||
"profiles": {
|
||||
"light.storage": ControlProfile(
|
||||
actuator_entity_id="light.storage",
|
||||
stage=SafetyStage.ACTIVE,
|
||||
min_confidence=0.8,
|
||||
active_ready=True,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
calls: list[str] = []
|
||||
|
||||
def execute(decision: Decision) -> bool:
|
||||
calls.append(decision.actuator_entity_id)
|
||||
return True
|
||||
|
||||
audit = EventCore(stores).process_state_event(
|
||||
StateEvent(entity_id="binary_sensor.storage_door", new_state="on"),
|
||||
execute=execute,
|
||||
)
|
||||
|
||||
decision = [item.decision for item in audit if item.decision is not None][0]
|
||||
assert calls == ["light.storage"]
|
||||
assert decision.executed is True
|
||||
|
||||
|
||||
def test_active_requires_dry_run_readiness(tmp_path: Path) -> None:
|
||||
stores = FutureStores(tmp_path)
|
||||
stores.save_runtime(
|
||||
RuntimeState(
|
||||
entities={
|
||||
"light.storage": EntityState(
|
||||
entity_id="light.storage",
|
||||
domain="light",
|
||||
state="off",
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
stores.save_learning(
|
||||
LearningState(
|
||||
profiles={
|
||||
"light.storage": LearningProfile(
|
||||
actuator_entity_id="light.storage",
|
||||
patterns=[
|
||||
BehaviorPatternV2(
|
||||
actuator_entity_id="light.storage",
|
||||
target_state="on",
|
||||
trigger_entity_id="binary_sensor.storage_door",
|
||||
trigger_state="on",
|
||||
support=3,
|
||||
confidence=0.95,
|
||||
)
|
||||
],
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
stores.save_control(
|
||||
stores.control().model_copy(
|
||||
update={
|
||||
"profiles": {
|
||||
"light.storage": ControlProfile(
|
||||
actuator_entity_id="light.storage",
|
||||
stage=SafetyStage.ACTIVE,
|
||||
min_confidence=0.8,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
audit = EventCore(stores).process_state_event(
|
||||
StateEvent(entity_id="binary_sensor.storage_door", new_state="on")
|
||||
)
|
||||
|
||||
decision = [item.decision for item in audit if item.decision is not None][0]
|
||||
assert decision.allowed is False
|
||||
assert "Active ist noch nicht freigegeben" in decision.blockers[0]
|
||||
|
||||
12
tests/test_ha_client.py
Normal file
12
tests/test_ha_client.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.ha_client import service_for_state
|
||||
|
||||
|
||||
def test_service_for_state_maps_safe_domains() -> None:
|
||||
assert service_for_state("light", "on") == ("light", "turn_on")
|
||||
assert service_for_state("switch", "off") == ("switch", "turn_off")
|
||||
assert service_for_state("cover", "open") == ("cover", "open_cover")
|
||||
assert service_for_state("cover", "closed") == ("cover", "close_cover")
|
||||
assert service_for_state("lock", "unlocked") is None
|
||||
|
||||
Reference in New Issue
Block a user