Compare commits
11 Commits
feature/ap
...
445e4bcdf4
| Author | SHA1 | Date | |
|---|---|---|---|
| 445e4bcdf4 | |||
| d550030a1a | |||
| 29ec53cc5e | |||
| 8841a68c8d | |||
| 6540d62ff7 | |||
| 6b1e2ad0dc | |||
| 6bba8f5947 | |||
| 009d4b68cb | |||
| 9016dbad18 | |||
| d81ebf399c | |||
| 43fb8fac2e |
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""SillyHome Next application package."""
|
||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API package."""
|
||||
1
app/api/v1/__init__.py
Normal file
1
app/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Version 1 API package."""
|
||||
@@ -1,19 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Sequence
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
from app.rules.recommender import Recommender
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["entities"])
|
||||
|
||||
|
||||
def _state_ha_reader(request: Request) -> HaReader:
|
||||
try:
|
||||
return request.app.state.ha_reader
|
||||
except AttributeError as exc:
|
||||
raise HTTPException(status_code=503, detail="HA-Reader nicht initialisiert.") from exc
|
||||
|
||||
|
||||
def _state_recommender(request: Request) -> Recommender:
|
||||
try:
|
||||
return request.app.state.recommender
|
||||
except AttributeError as exc:
|
||||
raise HTTPException(status_code=503, detail="Recommender nicht initialisiert.") from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/entities",
|
||||
summary="Home-Assistant-Entities auflisten",
|
||||
description="Gibt eine kompakte Zusammenfassung aller erreichbaren HA-Entitäten zurück.",
|
||||
response_model=List[HaEntitySummary],
|
||||
)
|
||||
def list_entities() -> Sequence[HaEntitySummary]:
|
||||
raise NotImplementedError("Integration mit dem HA-Client folgt in separatem Issue.")
|
||||
def list_entities(request: Request) -> List[HaEntitySummary]:
|
||||
ha_reader = _state_ha_reader(request)
|
||||
recommender = _state_recommender(request)
|
||||
entities = ha_reader.read_entities()
|
||||
recommender.run(entities)
|
||||
return entities
|
||||
21
app/config.py
Normal file
21
app/config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
ha_url: str | None = None
|
||||
ha_token: str | None = None
|
||||
|
||||
@property
|
||||
def ha_configured(self) -> bool:
|
||||
return bool(self.ha_url and self.ha_token)
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
return Settings(
|
||||
ha_url=os.getenv("SILLYHOME_HA_URL") or os.getenv("HA_URL"),
|
||||
ha_token=os.getenv("SILLYHOME_HA_TOKEN") or os.getenv("HA_TOKEN"),
|
||||
)
|
||||
1
app/core/__init__.py
Normal file
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core application helpers."""
|
||||
23
app/core/exception_handlers.py
Normal file
23
app/core/exception_handlers.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.ha.exceptions import HaAuthError, HaClientError, HaHttpError, HaTimeoutError
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(HaClientError)
|
||||
async def handle_ha_client_error(_: Request, exc: HaClientError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=_status_code_for_ha_error(exc),
|
||||
content={"detail": exc.public_detail},
|
||||
)
|
||||
|
||||
|
||||
def _status_code_for_ha_error(exc: HaClientError) -> int:
|
||||
if isinstance(exc, HaTimeoutError):
|
||||
return status.HTTP_504_GATEWAY_TIMEOUT
|
||||
if isinstance(exc, (HaAuthError, HaHttpError)):
|
||||
return status.HTTP_502_BAD_GATEWAY
|
||||
return status.HTTP_502_BAD_GATEWAY
|
||||
20
app/core/exceptions.py
Normal file
20
app/core/exceptions.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from app.ha.exceptions import HaAuthError, HaClientError, HaHttpError
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(HaClientError)
|
||||
async def handle_ha_client_error(request: Request, exc: HaClientError) -> Any: # pragma: no cover - einfacher Wrapper
|
||||
if isinstance(exc, HaAuthError):
|
||||
return {"detail": "Ungültige Authentifizierung gegenüber Home Assistant."}
|
||||
if isinstance(exc, HaHttpError):
|
||||
return {
|
||||
"detail": "Home Assistant meldet einen Fehler.",
|
||||
"upstream_status": exc.status_code,
|
||||
}
|
||||
return {"detail": str(exc)}
|
||||
15
app/dependencies.py
Normal file
15
app/dependencies.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.ha.reader import HaReader
|
||||
|
||||
|
||||
def get_ha_reader(request: Request) -> HaReader:
|
||||
reader = getattr(request.app.state, "ha_reader", None)
|
||||
if not isinstance(reader, HaReader):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Home Assistant is not configured.",
|
||||
)
|
||||
return reader
|
||||
1
app/ha/__init__.py
Normal file
1
app/ha/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# sillyhome-next.ha
|
||||
74
app/ha/client.py
Normal file
74
app/ha/client.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
from app.ha.exceptions import (
|
||||
HaAuthError,
|
||||
HaHttpError,
|
||||
HaTimeoutError,
|
||||
HaUnexpectedPayloadError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HaClientSettings:
|
||||
url: str
|
||||
token: str
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
class HaClient:
|
||||
def __init__(self, settings: HaClientSettings) -> None:
|
||||
self._settings = settings
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({
|
||||
"Authorization": f"Bearer {settings.token}",
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
|
||||
def list_entities(self) -> list[dict[str, object]]:
|
||||
try:
|
||||
response = self._session.get(
|
||||
f"{self._settings.url}/api/states",
|
||||
timeout=self._settings.timeout_seconds,
|
||||
)
|
||||
except requests.Timeout as exc:
|
||||
raise HaTimeoutError("Zeitüberschreitung beim Zugriff auf Home Assistant.") from exc
|
||||
except requests.RequestException as exc:
|
||||
raise HaHttpError(
|
||||
getattr(getattr(exc, "response", None), "status_code", 502),
|
||||
"Netzwerkfehler beim Zugriff auf Home Assistant.",
|
||||
) from exc
|
||||
|
||||
if response.status_code in (401, 403):
|
||||
raise HaAuthError(
|
||||
response.status_code,
|
||||
"Authentifizierung bei Home Assistant fehlgeschlagen.",
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
raise HaHttpError(
|
||||
response.status_code,
|
||||
"Home Assistant meldet einen Fehler.",
|
||||
) from exc
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise HaUnexpectedPayloadError(
|
||||
"Antwort von Home Assistant ist kein gültiges JSON."
|
||||
) from exc
|
||||
|
||||
if not isinstance(payload, list):
|
||||
raise HaUnexpectedPayloadError(
|
||||
"Antwort von Home Assistant hat unerwartetes Format."
|
||||
)
|
||||
|
||||
return payload
|
||||
35
app/ha/exceptions.py
Normal file
35
app/ha/exceptions.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class HaClientError(Exception):
|
||||
"""Basisklasse für HA-Client-Fehler."""
|
||||
|
||||
public_detail: str | None = None
|
||||
|
||||
|
||||
class HaTimeoutError(HaClientError):
|
||||
"""Zeitüberschreitung bei Request an Home Assistant."""
|
||||
|
||||
public_detail = "Home Assistant request timed out."
|
||||
|
||||
|
||||
class HaHttpError(HaClientError):
|
||||
"""Nicht erfolgreicher HTTP-Statuscode."""
|
||||
|
||||
public_detail = "Home Assistant request failed."
|
||||
|
||||
def __init__(self, status_code: int, message: str = "") -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class HaAuthError(HaHttpError):
|
||||
"""Authentifizierung oder Berechtigung fehlgeschlagen."""
|
||||
|
||||
public_detail = "Home Assistant authentication failed."
|
||||
|
||||
|
||||
class HaUnexpectedPayloadError(HaClientError):
|
||||
"""Antwort hat nicht das erwartete Format."""
|
||||
|
||||
public_detail = "Home Assistant returned an unexpected payload."
|
||||
19
app/ha/models.py
Normal file
19
app/ha/models.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class HaState(BaseModel):
|
||||
entity_id: str
|
||||
state: str
|
||||
attributes: dict[str, object] | None = None
|
||||
last_changed: str | None = None
|
||||
last_updated: str | None = None
|
||||
|
||||
|
||||
class HaEntitySummary(BaseModel):
|
||||
entity_id: str
|
||||
domain: str
|
||||
state_class: str | None = None
|
||||
device_class: str | None = None
|
||||
unit_of_measurement: str | None = None
|
||||
39
app/ha/reader.py
Normal file
39
app/ha/reader.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from app.ha.client import HaClient
|
||||
from app.ha.models import HaEntitySummary
|
||||
|
||||
|
||||
class HaReader:
|
||||
def __init__(self, client: HaClient) -> None:
|
||||
self._client = client
|
||||
|
||||
def read_entities(self) -> Sequence[HaEntitySummary]:
|
||||
entities = self._client.list_entities()
|
||||
summaries: list[HaEntitySummary] = []
|
||||
for item in entities:
|
||||
entity_id = item.get("entity_id", "")
|
||||
if "." not in entity_id:
|
||||
continue
|
||||
domain = entity_id.split(".", 1)[0]
|
||||
raw_attributes = item.get("attributes") or {}
|
||||
attributes: dict[str, Any] = raw_attributes if isinstance(raw_attributes, dict) else {}
|
||||
summaries.append(
|
||||
HaEntitySummary(
|
||||
entity_id=entity_id,
|
||||
domain=domain,
|
||||
state_class=_optional_str(attributes.get("state_class")),
|
||||
device_class=_optional_str(attributes.get("device_class")),
|
||||
unit_of_measurement=_optional_str(attributes.get("unit_of_measurement")),
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
def _optional_str(value: object) -> str | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return str(value)
|
||||
37
app/main.py
37
app/main.py
@@ -1,10 +1,45 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.v1.entities import router as entities_router
|
||||
from app.core.exception_handlers import register_exception_handlers
|
||||
from app.ha.client import HaClient, HaClientSettings
|
||||
from app.ha.reader import HaReader
|
||||
from app.rules.recommender import Recommender
|
||||
from app.rules.heating import HeatingRule
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = app.state.settings
|
||||
ha_url = getattr(settings, "ha_url", None)
|
||||
ha_token = getattr(settings, "ha_token", None)
|
||||
client = HaClient(
|
||||
settings=HaClientSettings(
|
||||
url=ha_url or "",
|
||||
token=ha_token or "",
|
||||
)
|
||||
)
|
||||
app.state.ha_reader = HaReader(client=client)
|
||||
app.state.recommender = Recommender(rules=[HeatingRule()])
|
||||
yield
|
||||
|
||||
|
||||
class Settings:
|
||||
ha_url: str = "http://localhost:8123"
|
||||
ha_token: str = ""
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="SillyHome Next API",
|
||||
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.settings = Settings()
|
||||
register_exception_handlers(app)
|
||||
app.include_router(entities_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -14,4 +49,4 @@ def health() -> dict[str, str]:
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict[str, str]:
|
||||
return {"service": "sillyhome-next", "docs": "/docs"}
|
||||
return {"service": "sillyhome-next", "docs": "/docs"}
|
||||
1
app/rules/__init__.py
Normal file
1
app/rules/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# sillyhome-next.rules
|
||||
15
app/rules/heating.py
Normal file
15
app/rules/heating.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.rules.recommender import Rule
|
||||
|
||||
|
||||
class HeatingRule(Rule):
|
||||
def matches(self, entities: Sequence[HaEntitySummary]) -> bool:
|
||||
domains = {item.domain for item in entities}
|
||||
return "climate" in domains or "sensor" in domains
|
||||
|
||||
def recommendation(self, entities: Sequence[HaEntitySummary]) -> str:
|
||||
return "Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit."
|
||||
25
app/rules/recommender.py
Normal file
25
app/rules/recommender.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from app.ha.models import HaEntitySummary
|
||||
|
||||
|
||||
class Rule:
|
||||
def matches(self, entities: Sequence[HaEntitySummary]) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def recommendation(self, entities: Sequence[HaEntitySummary]) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Recommender:
|
||||
def __init__(self, rules: Sequence[Rule]) -> None:
|
||||
self._rules = rules
|
||||
|
||||
def run(self, entities: Sequence[HaEntitySummary]) -> list[str]:
|
||||
results: list[str] = []
|
||||
for rule in self._rules:
|
||||
if rule.matches(entities):
|
||||
results.append(rule.recommendation(entities))
|
||||
return results
|
||||
@@ -7,10 +7,12 @@ dependencies = [
|
||||
"fastapi>=0.110.0",
|
||||
"uvicorn[standard]>=0.29.0",
|
||||
"pydantic>=2.6.0",
|
||||
"requests>=2.31.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"httpx2>=2.3.0",
|
||||
"pytest>=8.0.0",
|
||||
"ruff>=0.4.0",
|
||||
"mypy>=1.9.0",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import requests
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
p = Path('/root/.openclaw/secrets/gitea.env')
|
||||
|
||||
@@ -1,10 +1,63 @@
|
||||
from collections.abc import Sequence
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.ha.exceptions import HaTimeoutError
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
class FakeHaReader(HaReader):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def read_entities(self) -> Sequence[HaEntitySummary]:
|
||||
return [HaEntitySummary(entity_id="sensor.temperature", domain="sensor")]
|
||||
|
||||
|
||||
class TimeoutHaReader(HaReader):
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def read_entities(self) -> Sequence[HaEntitySummary]:
|
||||
raise HaTimeoutError("contains internal details that must not leak")
|
||||
|
||||
|
||||
def test_openapi_docs_are_available() -> None:
|
||||
response = client.get("/docs")
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/docs")
|
||||
assert response.status_code == 200
|
||||
assert "SillyHome Next API" in response.text
|
||||
|
||||
|
||||
def test_entities_returns_reader_data() -> None:
|
||||
with TestClient(app) as client:
|
||||
app.state.ha_reader = FakeHaReader()
|
||||
response = client.get("/v1/entities")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == [
|
||||
{
|
||||
"entity_id": "sensor.temperature",
|
||||
"domain": "sensor",
|
||||
"state_class": None,
|
||||
"device_class": None,
|
||||
"unit_of_measurement": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_entities_returns_503_without_home_assistant_config() -> None:
|
||||
with TestClient(app) as client:
|
||||
if hasattr(app.state, "ha_reader"):
|
||||
delattr(app.state, "ha_reader")
|
||||
response = client.get("/v1/entities")
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_entities_maps_ha_errors_without_leaking_details() -> None:
|
||||
with TestClient(app) as client:
|
||||
app.state.ha_reader = TimeoutHaReader()
|
||||
response = client.get("/v1/entities")
|
||||
assert response.status_code == 504
|
||||
assert response.json() == {"detail": "Home Assistant request timed out."}
|
||||
|
||||
71
tests/ha/test_ha_client.py
Normal file
71
tests/ha/test_ha_client.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from app.ha.client import HaClient, HaClientSettings
|
||||
from app.ha.exceptions import (
|
||||
HaAuthError,
|
||||
HaHttpError,
|
||||
HaTimeoutError,
|
||||
HaUnexpectedPayloadError,
|
||||
)
|
||||
|
||||
|
||||
def _client_with_response(response: Mock) -> HaClient:
|
||||
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
|
||||
client._session.get = Mock(return_value=response) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
|
||||
def _response(status_code: int = 200, payload: object | None = None) -> Mock:
|
||||
response = Mock()
|
||||
response.status_code = status_code
|
||||
response.json.return_value = [] if payload is None else payload
|
||||
if status_code >= 400:
|
||||
response.raise_for_status.side_effect = requests.HTTPError("upstream failed")
|
||||
return response
|
||||
|
||||
|
||||
def test_list_entities_returns_home_assistant_payload() -> None:
|
||||
payload = [{"entity_id": "sensor.temperature", "state": "21"}]
|
||||
client = _client_with_response(_response(payload=payload))
|
||||
assert client.list_entities() == payload
|
||||
|
||||
|
||||
def test_list_entities_maps_timeout() -> None:
|
||||
client = HaClient(HaClientSettings(url="http://ha.local", token="test-token"))
|
||||
client._session.get = Mock(side_effect=requests.Timeout("timed out")) # type: ignore[method-assign]
|
||||
with pytest.raises(HaTimeoutError):
|
||||
client.list_entities()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status_code", [401, 403])
|
||||
def test_list_entities_maps_auth_errors(status_code: int) -> None:
|
||||
client = _client_with_response(_response(status_code=status_code))
|
||||
with pytest.raises(HaAuthError) as exc_info:
|
||||
client.list_entities()
|
||||
assert exc_info.value.status_code == status_code
|
||||
|
||||
|
||||
def test_list_entities_maps_http_errors() -> None:
|
||||
client = _client_with_response(_response(status_code=500))
|
||||
with pytest.raises(HaHttpError) as exc_info:
|
||||
client.list_entities()
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
|
||||
def test_list_entities_rejects_invalid_json() -> None:
|
||||
response = _response()
|
||||
response.json.side_effect = ValueError("not json")
|
||||
client = _client_with_response(response)
|
||||
with pytest.raises(HaUnexpectedPayloadError):
|
||||
client.list_entities()
|
||||
|
||||
|
||||
def test_list_entities_rejects_non_list_payload() -> None:
|
||||
client = _client_with_response(_response(payload={"entity_id": "sensor.temperature"}))
|
||||
with pytest.raises(HaUnexpectedPayloadError):
|
||||
client.list_entities()
|
||||
37
tests/ha/test_ha_reader.py
Normal file
37
tests/ha/test_ha_reader.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.ha.client import HaClient, HaClientSettings
|
||||
from app.ha.reader import HaReader
|
||||
|
||||
|
||||
class FakeHaClient(HaClient):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(HaClientSettings(url="http://test", token="token"))
|
||||
|
||||
def list_entities(self) -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"entity_id": "sensor.temperature",
|
||||
"state": "21.5",
|
||||
"attributes": {
|
||||
"state_class": "measurement",
|
||||
"device_class": "temperature",
|
||||
"unit_of_measurement": "°C",
|
||||
},
|
||||
},
|
||||
{
|
||||
"entity_id": "light.living_room",
|
||||
"state": "on",
|
||||
"attributes": {},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_ha_reader_returns_summaries() -> None:
|
||||
reader = HaReader(FakeHaClient())
|
||||
summaries = reader.read_entities()
|
||||
assert len(summaries) == 2
|
||||
domains = {summary.domain for summary in summaries}
|
||||
assert domains == {"sensor", "light"}
|
||||
sensor = next(item for item in summaries if item.entity_id == "sensor.temperature")
|
||||
assert sensor.unit_of_measurement == "°C"
|
||||
26
tests/rules/test_heating.py
Normal file
26
tests/rules/test_heating.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.rules.heating import HeatingRule
|
||||
from app.rules.recommender import Recommender
|
||||
|
||||
|
||||
def _sensor(entity_id: str) -> HaEntitySummary:
|
||||
return HaEntitySummary(entity_id=entity_id, domain="sensor")
|
||||
|
||||
|
||||
def _climate(entity_id: str) -> HaEntitySummary:
|
||||
return HaEntitySummary(entity_id=entity_id, domain="climate")
|
||||
|
||||
|
||||
def test_heating_rule_triggers() -> None:
|
||||
rule = HeatingRule()
|
||||
assert rule.matches([_climate("climate.living_room")])
|
||||
assert rule.matches([_sensor("sensor.temperature_living")])
|
||||
|
||||
|
||||
def test_recommender_uses_rule() -> None:
|
||||
recommender = Recommender(rules=[HeatingRule()])
|
||||
assert recommender.run([_climate("climate.living_room")]) == [
|
||||
"Prüfe Heizungsregelung: Aktiviere energiesparenden Modus bei Abwesenheit."
|
||||
]
|
||||
@@ -1,10 +1,10 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_health_returns_ok() -> None:
|
||||
response = client.get("/health")
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
Reference in New Issue
Block a user