Prepare SillyHome Next 1.0.0 dashboard and API rework
This commit is contained in:
@@ -14,7 +14,7 @@ from app.actuators.store import ActuatorStore
|
||||
from app.behavior.engine import BehaviorEngine
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_ha_reader
|
||||
from app.ha.discovery import DiscoveredEntity, EntityRole
|
||||
from app.ha.discovery import DiscoveredEntity, EntityRole, discover_entities
|
||||
from app.ha.exceptions import HaClientError
|
||||
from app.ha.models import HaEntitySummary
|
||||
from app.ha.reader import HaReader
|
||||
@@ -79,6 +79,35 @@ class ActuatorSummary(BaseModel):
|
||||
updated_at: str
|
||||
|
||||
|
||||
class EntityCacheStatus(BaseModel):
|
||||
available: bool
|
||||
updated_at: str | None = None
|
||||
entity_count: int = 0
|
||||
|
||||
|
||||
class DashboardSystemStatus(BaseModel):
|
||||
api_status: str = "ok"
|
||||
websocket_status: str = "unavailable"
|
||||
websocket_error: str | None = None
|
||||
reconciliation_last_completed_at: str | None = None
|
||||
configured_actuators: int = 0
|
||||
trained_models: int = 0
|
||||
review_required: int = 0
|
||||
|
||||
|
||||
class DashboardDiscoveryGroup(BaseModel):
|
||||
category: str
|
||||
role: str
|
||||
count: int
|
||||
|
||||
|
||||
class DashboardOverview(BaseModel):
|
||||
system: DashboardSystemStatus
|
||||
cache: EntityCacheStatus
|
||||
actuators: list[ActuatorSummary]
|
||||
discovery_groups: list[DashboardDiscoveryGroup]
|
||||
|
||||
|
||||
@router.get("/discovery", response_model=list[HaEntitySummary])
|
||||
def discover_actuators(
|
||||
request: Request,
|
||||
@@ -92,7 +121,7 @@ def discover_actuators(
|
||||
fresh_entities = list(ha_reader.read_entities())
|
||||
_save_cached_entities(request, fresh_entities)
|
||||
entities = {entity.entity_id: entity for entity in fresh_entities}
|
||||
discovered = ha_reader.discover()
|
||||
discovered = discover_entities(list(entities.values()))
|
||||
actuator_ids = _deduplicate_actuator_ids(
|
||||
[
|
||||
(entity.entity_id, entity.category)
|
||||
@@ -110,7 +139,7 @@ def suggest_actuators(
|
||||
ha_reader: HaReader = Depends(get_ha_reader),
|
||||
) -> list[ActuatorSuggestion]:
|
||||
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
|
||||
discovered = {entity.entity_id: entity for entity in ha_reader.discover()}
|
||||
discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))}
|
||||
configured_ids = {record.actuator_entity_id for record in _service(request).list_configured()}
|
||||
actuator_ids = _deduplicate_actuator_ids(
|
||||
[
|
||||
@@ -222,6 +251,53 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
|
||||
]
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardOverview)
|
||||
def dashboard_overview(request: Request) -> DashboardOverview:
|
||||
cache_payload = _load_entity_cache_payload(request)
|
||||
raw_entities = cache_payload.get("entities", [])
|
||||
if not isinstance(raw_entities, list):
|
||||
raw_entities = []
|
||||
raw_updated_at = cache_payload.get("updated_at")
|
||||
updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None
|
||||
cached_entities = [
|
||||
HaEntitySummary.model_validate(entity)
|
||||
for entity in raw_entities
|
||||
if isinstance(entity, dict)
|
||||
]
|
||||
discovered = discover_entities(cached_entities) if cached_entities else []
|
||||
group_counts: dict[tuple[str, str], int] = {}
|
||||
for entity in discovered:
|
||||
key = (entity.category, entity.role.value)
|
||||
group_counts[key] = group_counts.get(key, 0) + 1
|
||||
reconciliation = _reconciliation_state_or_default(request)
|
||||
ws_status = getattr(request.app.state, "ws_status", None)
|
||||
actuators = list_configured_summary(request)
|
||||
return DashboardOverview(
|
||||
system=DashboardSystemStatus(
|
||||
websocket_status=getattr(ws_status, "status", "unavailable"),
|
||||
websocket_error=getattr(ws_status, "error", None),
|
||||
reconciliation_last_completed_at=(
|
||||
reconciliation.last_completed_at.isoformat()
|
||||
if reconciliation.last_completed_at is not None
|
||||
else None
|
||||
),
|
||||
configured_actuators=len(actuators),
|
||||
trained_models=reconciliation.trained_models,
|
||||
review_required=reconciliation.review_required,
|
||||
),
|
||||
cache=EntityCacheStatus(
|
||||
available=bool(cached_entities),
|
||||
updated_at=updated_at,
|
||||
entity_count=len(cached_entities),
|
||||
),
|
||||
actuators=actuators,
|
||||
discovery_groups=[
|
||||
DashboardDiscoveryGroup(category=category, role=role, count=count)
|
||||
for (category, role), count in sorted(group_counts.items())
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ActuatorRecord])
|
||||
def list_configured(request: Request) -> list[ActuatorRecord]:
|
||||
return _service(request).list_configured()
|
||||
@@ -412,6 +488,16 @@ def _behavior(request: Request) -> BehaviorEngine:
|
||||
return engine
|
||||
|
||||
|
||||
def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
|
||||
store = getattr(request.app.state, "actuator_store", None)
|
||||
if not isinstance(store, ActuatorStore):
|
||||
return ReconciliationState()
|
||||
try:
|
||||
return store.load_reconciliation_state()
|
||||
except ValueError:
|
||||
return ReconciliationState(last_summary="Reconciliation-Status ist unlesbar.")
|
||||
|
||||
|
||||
def _entity_cache_path(request: Request) -> Path:
|
||||
store = getattr(request.app.state, "actuator_store", None)
|
||||
settings = getattr(request.app.state, "settings", None)
|
||||
@@ -425,17 +511,27 @@ def _entity_cache_path(request: Request) -> Path:
|
||||
|
||||
|
||||
def _load_cached_entities(request: Request) -> list[HaEntitySummary]:
|
||||
path = _entity_cache_path(request)
|
||||
if not path.exists():
|
||||
payload = _load_entity_cache_payload(request)
|
||||
raw_entities = payload.get("entities", [])
|
||||
if not isinstance(raw_entities, list):
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
raw_entities = payload.get("entities", [])
|
||||
return [HaEntitySummary.model_validate(entity) for entity in raw_entities]
|
||||
except (OSError, TypeError, ValueError):
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
|
||||
def _load_entity_cache_payload(request: Request) -> dict[str, object]:
|
||||
path = _entity_cache_path(request)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
except (OSError, TypeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None:
|
||||
path = _entity_cache_path(request)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
Reference in New Issue
Block a user