Compare commits
10 Commits
v2.0.0-alp
...
v2.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 07e3e96c30 | |||
| 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
|
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
|
## Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -29,4 +41,3 @@ pytest
|
|||||||
ruff check .
|
ruff check .
|
||||||
mypy app tests
|
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.11
|
||||||
|
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.11"
|
||||||
|
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.")
|
blockers.append(f"Domain {domain} ist nicht fuer Active-Control freigegeben.")
|
||||||
if control.manual_block:
|
if control.manual_block:
|
||||||
blockers.append("Manuelle Sicherheitssperre ist aktiv.")
|
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
|
best = None
|
||||||
for pattern in learning.patterns:
|
for pattern in learning.patterns:
|
||||||
if pattern.trigger_entity_id != trigger_entity_id:
|
if pattern.trigger_entity_id != trigger_entity_id:
|
||||||
@@ -63,4 +65,3 @@ class DecisionEngineV2:
|
|||||||
blockers=blockers,
|
blockers=blockers,
|
||||||
trigger_entity_id=trigger_entity_id,
|
trigger_entity_id=trigger_entity_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
from app.core.decision import DecisionEngineV2
|
from app.core.decision import DecisionEngineV2
|
||||||
from app.core.handoff import HandoffMatrix
|
from app.core.handoff import HandoffMatrix
|
||||||
from app.core.models import (
|
from app.core.models import (
|
||||||
AuditEvent,
|
AuditEvent,
|
||||||
ControlProfile,
|
ControlProfile,
|
||||||
|
Decision,
|
||||||
EntityState,
|
EntityState,
|
||||||
LearningProfile,
|
LearningProfile,
|
||||||
StateEvent,
|
StateEvent,
|
||||||
@@ -31,7 +33,12 @@ class EventCore:
|
|||||||
self._decision = DecisionEngineV2()
|
self._decision = DecisionEngineV2()
|
||||||
self._handoff = HandoffMatrix()
|
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()
|
runtime = self._stores.runtime()
|
||||||
learning = self._stores.learning()
|
learning = self._stores.learning()
|
||||||
control = self._stores.control()
|
control = self._stores.control()
|
||||||
@@ -69,6 +76,19 @@ class EventCore:
|
|||||||
learning=learning_profile,
|
learning=learning_profile,
|
||||||
control=control_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(
|
audit.append(
|
||||||
AuditEvent(
|
AuditEvent(
|
||||||
event_id=_event_id("decision"),
|
event_id=_event_id("decision"),
|
||||||
@@ -87,3 +107,39 @@ class EventCore:
|
|||||||
def _event_id(prefix: str) -> str:
|
def _event_id(prefix: str) -> str:
|
||||||
return f"{prefix}-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S%f')}"
|
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
|
||||||
|
|
||||||
@@ -21,6 +21,19 @@ class SafetyStage(StrEnum):
|
|||||||
BLOCKED = "blocked"
|
BLOCKED = "blocked"
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalStatus(StrEnum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class JobStatus(StrEnum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
RUNNING = "running"
|
||||||
|
SUCCEEDED = "succeeded"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
class EntityState(BaseModel):
|
class EntityState(BaseModel):
|
||||||
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||||
domain: str
|
domain: str
|
||||||
@@ -67,6 +80,11 @@ class ControlProfile(BaseModel):
|
|||||||
handoff_mode: HandoffMode = HandoffMode.SHADOW
|
handoff_mode: HandoffMode = HandoffMode.SHADOW
|
||||||
related_automation_ids: list[str] = Field(default_factory=list)
|
related_automation_ids: list[str] = Field(default_factory=list)
|
||||||
paused_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):
|
class RoomProfile(BaseModel):
|
||||||
@@ -84,6 +102,51 @@ class SceneProfile(BaseModel):
|
|||||||
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationProposal(BaseModel):
|
||||||
|
proposal_id: str
|
||||||
|
name: str
|
||||||
|
trigger_entity_id: str
|
||||||
|
trigger_state: str | None = None
|
||||||
|
actuator_entity_id: str
|
||||||
|
target_state: str
|
||||||
|
status: ProposalStatus = ProposalStatus.DRAFT
|
||||||
|
revision: int = 1
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
decided_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRecord(BaseModel):
|
||||||
|
model_id: str
|
||||||
|
actuator_entity_id: str
|
||||||
|
pattern_count: int
|
||||||
|
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
||||||
|
trained_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class JobQueueItem(BaseModel):
|
||||||
|
job_id: str
|
||||||
|
kind: str
|
||||||
|
status: JobStatus = JobStatus.QUEUED
|
||||||
|
message: str = ""
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
finished_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SensorWeightOverride(BaseModel):
|
||||||
|
actuator_entity_id: str
|
||||||
|
sensor_weights: dict[str, float] = Field(default_factory=dict)
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
class HistoryAnalysis(BaseModel):
|
||||||
|
entity_id: str
|
||||||
|
samples: int
|
||||||
|
last_state: str | None = None
|
||||||
|
changed_at: datetime | None = None
|
||||||
|
recommendation: str
|
||||||
|
|
||||||
|
|
||||||
class Decision(BaseModel):
|
class Decision(BaseModel):
|
||||||
actuator_entity_id: str
|
actuator_entity_id: str
|
||||||
target_state: str | None = None
|
target_state: str | None = None
|
||||||
@@ -116,10 +179,15 @@ class LearningState(BaseModel):
|
|||||||
profiles: dict[str, LearningProfile] = Field(default_factory=dict)
|
profiles: dict[str, LearningProfile] = Field(default_factory=dict)
|
||||||
rooms: dict[str, RoomProfile] = Field(default_factory=dict)
|
rooms: dict[str, RoomProfile] = Field(default_factory=dict)
|
||||||
scenes: dict[str, SceneProfile] = Field(default_factory=dict)
|
scenes: dict[str, SceneProfile] = Field(default_factory=dict)
|
||||||
|
automation_proposals: dict[str, AutomationProposal] = Field(default_factory=dict)
|
||||||
|
models: dict[str, ModelRecord] = Field(default_factory=dict)
|
||||||
|
jobs: dict[str, JobQueueItem] = Field(default_factory=dict)
|
||||||
|
weight_overrides: dict[str, SensorWeightOverride] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class ControlState(BaseModel):
|
class ControlState(BaseModel):
|
||||||
profiles: dict[str, ControlProfile] = Field(default_factory=dict)
|
profiles: dict[str, ControlProfile] = Field(default_factory=dict)
|
||||||
|
global_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class BackupBundle(BaseModel):
|
class BackupBundle(BaseModel):
|
||||||
@@ -127,4 +195,3 @@ class BackupBundle(BaseModel):
|
|||||||
runtime: RuntimeState
|
runtime: RuntimeState
|
||||||
learning: LearningState
|
learning: LearningState
|
||||||
control: ControlState
|
control: ControlState
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from typing import TypeVar
|
from typing import cast, TypeVar
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -18,13 +18,20 @@ class JsonDocumentStore:
|
|||||||
self.root = Path(root).resolve()
|
self.root = Path(root).resolve()
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
self._lock = RLock()
|
self._lock = RLock()
|
||||||
|
self._cache: dict[str, BaseModel] = {}
|
||||||
|
|
||||||
def load(self, name: str, model: type[T], default: T) -> T:
|
def load(self, name: str, model: type[T], default: T) -> T:
|
||||||
path = self.root / name
|
path = self.root / name
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
cached = self._cache.get(name)
|
||||||
|
if cached is not None:
|
||||||
|
return cast(T, cached)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
|
self._cache[name] = default
|
||||||
return 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:
|
def save(self, name: str, value: T) -> T:
|
||||||
path = self.root / name
|
path = self.root / name
|
||||||
@@ -35,6 +42,7 @@ class JsonDocumentStore:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
os.replace(temporary, path)
|
os.replace(temporary, path)
|
||||||
|
self._cache[name] = value
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -71,4 +79,3 @@ class FutureStores:
|
|||||||
self.save_runtime(bundle.runtime)
|
self.save_runtime(bundle.runtime)
|
||||||
self.save_learning(bundle.learning)
|
self.save_learning(bundle.learning)
|
||||||
self.save_control(bundle.control)
|
self.save_control(bundle.control)
|
||||||
|
|
||||||
|
|||||||
1055
app/main.py
1055
app/main.py
File diff suppressed because it is too large
Load Diff
@@ -27,3 +27,9 @@ state, but switching decisions must remain inspectable and testable.
|
|||||||
6. Dashboard v2
|
6. Dashboard v2
|
||||||
7. Add-on hardening
|
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]
|
[project]
|
||||||
name = "sillyhome-future"
|
name = "sillyhome-future"
|
||||||
version = "2.0.0-alpha.1"
|
version = "2.0.0-alpha.11"
|
||||||
description = "SillyHome v2 event-core prototype"
|
description = "SillyHome v2 event-core prototype"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115",
|
"fastapi>=0.115",
|
||||||
|
"httpx>=0.27",
|
||||||
"pydantic>=2.8",
|
"pydantic>=2.8",
|
||||||
"uvicorn[standard]>=0.30",
|
"uvicorn[standard]>=0.30",
|
||||||
|
"websockets>=12.0",
|
||||||
|
"pyyaml>=6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -32,4 +35,3 @@ target-version = "py311"
|
|||||||
python_version = "3.11"
|
python_version = "3.11"
|
||||||
strict = true
|
strict = true
|
||||||
plugins = []
|
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.11"
|
||||||
|
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,147 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
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]
|
def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||||
monkeypatch.setenv("SILLYHOME_FUTURE_STORE", str(tmp_path))
|
test_stores = FutureStores(tmp_path)
|
||||||
client = TestClient(app)
|
monkeypatch.setattr(main, "stores", test_stores)
|
||||||
|
monkeypatch.setattr(main, "event_core", EventCore(test_stores))
|
||||||
|
client = TestClient(main.app)
|
||||||
|
|
||||||
health = client.get("/health")
|
health = client.get("/health")
|
||||||
backup = client.get("/v2/backup/export")
|
backup = client.get("/v2/backup/export")
|
||||||
restore = client.post("/v2/backup/restore", json=backup.json())
|
restore = client.post("/v2/backup/restore", json=backup.json())
|
||||||
|
|
||||||
assert stores is not None
|
|
||||||
assert health.status_code == 200
|
assert health.status_code == 200
|
||||||
assert health.json()["version"] == "2.0.0-alpha.1"
|
assert health.json()["version"] == "2.0.0-alpha.11"
|
||||||
assert backup.status_code == 200
|
assert backup.status_code == 200
|
||||||
assert restore.status_code == 200
|
assert restore.status_code == 200
|
||||||
assert restore.json() == {"status": "restored"}
|
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")
|
||||||
|
proposal = client.post(
|
||||||
|
"/v2/automations/proposals",
|
||||||
|
json={
|
||||||
|
"name": "Storage light",
|
||||||
|
"trigger_entity_id": "binary_sensor.storage_door",
|
||||||
|
"trigger_state": "on",
|
||||||
|
"actuator_entity_id": "light.storage",
|
||||||
|
"target_state": "on",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
proposal_yaml = client.get("/v2/automations/proposals/proposal-1/yaml")
|
||||||
|
approved = client.post("/v2/automations/proposals/proposal-1/approve")
|
||||||
|
weights = client.post(
|
||||||
|
"/v2/control/light.storage/weights",
|
||||||
|
json={"sensor_weights": {"binary_sensor.storage_door": 1.0}, "note": "door"},
|
||||||
|
)
|
||||||
|
history = client.post(
|
||||||
|
"/v2/history/analyze",
|
||||||
|
json={"entity_ids": ["binary_sensor.storage_door"]},
|
||||||
|
)
|
||||||
|
models = client.post("/v2/models/train", json={"actuator_entity_id": "light.storage"})
|
||||||
|
jobs = client.get("/v2/jobs")
|
||||||
|
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 "Job-Queue" in parity.json()["implemented"]
|
||||||
|
assert anomalies.status_code == 200
|
||||||
|
assert proposal.status_code == 200
|
||||||
|
assert proposal.json()["status"] == "draft"
|
||||||
|
assert proposal_yaml.status_code == 200
|
||||||
|
assert "alias: Storage light" in proposal_yaml.text
|
||||||
|
assert approved.status_code == 200
|
||||||
|
assert approved.json()["status"] == "approved"
|
||||||
|
assert weights.status_code == 200
|
||||||
|
assert weights.json()["sensor_weights"]["binary_sensor.storage_door"] == 1.0
|
||||||
|
assert history.status_code == 200
|
||||||
|
assert history.json()[0]["entity_id"] == "binary_sensor.storage_door"
|
||||||
|
assert models.status_code == 200
|
||||||
|
assert models.json()[0]["actuator_entity_id"] == "light.storage"
|
||||||
|
assert jobs.status_code == 200
|
||||||
|
assert jobs.json()
|
||||||
|
assert dashboard.status_code == 200
|
||||||
|
assert dashboard.json()["actuator_count"] == 1
|
||||||
|
assert dashboard.json()["automation_proposals"] == 1
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.core.handoff import HandoffMatrix
|
|||||||
from app.core.models import (
|
from app.core.models import (
|
||||||
BehaviorPatternV2,
|
BehaviorPatternV2,
|
||||||
ControlProfile,
|
ControlProfile,
|
||||||
|
Decision,
|
||||||
EntityState,
|
EntityState,
|
||||||
HandoffMode,
|
HandoffMode,
|
||||||
LearningProfile,
|
LearningProfile,
|
||||||
@@ -91,3 +92,120 @@ def test_handoff_matrix_detects_conflict_and_rollback() -> None:
|
|||||||
rolled_back = matrix.rollback(controlled)
|
rolled_back = matrix.rollback(controlled)
|
||||||
assert rolled_back.handoff_mode is HandoffMode.ROLLBACK
|
assert rolled_back.handoff_mode is HandoffMode.ROLLBACK
|
||||||
assert rolled_back.paused_automation_ids == []
|
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