ACT-001: actuator-first sensor lifecycle

This commit is contained in:
2026-06-13 22:45:07 +02:00
parent d6631fe752
commit 6305f52cd2
30 changed files with 2010 additions and 103 deletions

122
app/api/v1/actuators.py Normal file
View File

@@ -0,0 +1,122 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, ManualOverride, ReconciliationState
from app.actuators.store import ActuatorStore
from app.dependencies import get_ha_reader
from app.ha.discovery import EntityRole
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
router = APIRouter(prefix="/v1/actuators", tags=["actuators"])
class ConfigureActuatorRequest(BaseModel):
actuator_entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
enabled: bool = True
class OverrideRequest(BaseModel):
numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
context_entity_ids: list[str] = Field(default_factory=list)
note: str | None = Field(default=None, max_length=300)
clear: bool = False
@router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
discovered = ha_reader.discover()
actuator_ids = sorted(
entity.entity_id for entity in discovered if entity.role is EntityRole.ACTUATOR
)
return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities]
@router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured()
@router.post("", response_model=ActuatorRecord, status_code=201)
def configure(payload: ConfigureActuatorRequest, request: Request) -> ActuatorRecord:
try:
return _service(request).configure_actuator(
payload.actuator_entity_id,
enabled=payload.enabled,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/{actuator_entity_id}", response_model=ActuatorRecord)
def get_actuator(actuator_entity_id: str, request: Request) -> ActuatorRecord:
try:
return _service(request).get_actuator(actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.delete("/{actuator_entity_id}", status_code=204)
def delete_actuator(actuator_entity_id: str, request: Request) -> None:
_service(request).delete_actuator(actuator_entity_id)
@router.post("/{actuator_entity_id}/override", response_model=ActuatorRecord)
def set_override(
actuator_entity_id: str,
payload: OverrideRequest,
request: Request,
) -> ActuatorRecord:
override = None if payload.clear else ManualOverride(
numeric_entity_id=payload.numeric_entity_id,
context_entity_ids=payload.context_entity_ids,
note=payload.note,
)
try:
return _service(request).set_override(actuator_entity_id, override)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/reconcile", response_model=ActuatorRecord)
def reconcile_actuator(
actuator_entity_id: str,
request: Request,
) -> ActuatorRecord:
try:
return _service(request).reconcile_actuator(actuator_entity_id, trigger="manual")
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/reconciliation/state", response_model=ReconciliationState)
def get_reconciliation_state(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Actuator Store nicht initialisiert.",
)
return store.load_reconciliation_state()
@router.post("/reconciliation/run", response_model=ReconciliationState)
def run_reconciliation(
request: Request,
trigger: str = Query(default="manual", pattern=r"^[a-z0-9_-]{1,32}$"),
) -> ReconciliationState:
return _service(request).reconcile_all(trigger=trigger)
def _service(request: Request) -> ActuatorReconciliationService:
service = getattr(request.app.state, "actuator_service", None)
if not isinstance(service, ActuatorReconciliationService):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Actuator-Reconciliation nicht initialisiert.",
)
return service