222 lines
7.6 KiB
Python
222 lines
7.6 KiB
Python
"""Expose API routes (M5-T12).
|
|
|
|
Three endpoints:
|
|
GET /api/expose — Return catalog of exposable entities, per-key
|
|
toggle state, and MQTT/Discovery connection status.
|
|
PUT /api/expose — Set per-key toggle state (map key → bool);
|
|
triggers a HA Discovery re-publish on success.
|
|
POST /api/expose/republish — Manually trigger a full HA Discovery re-publish.
|
|
|
|
Auth model:
|
|
- GET: session required (no CSRF — read-only).
|
|
- PUT, POST: session + CSRF required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.routes.api.deps import require_csrf, require_session
|
|
from app.config import get_settings
|
|
from app.dependencies import get_db
|
|
from app.services.config_page import build_runtime_settings
|
|
from app.integrations.expose import CatalogEntry, build_catalog
|
|
from app.integrations.mqtt import mqtt_manager
|
|
from app.models.expose import ExposedEntityToggle
|
|
from app.schemas.expose import (
|
|
CatalogEntrySchema,
|
|
DeviceInfoSchema,
|
|
ExposeResponse,
|
|
ExposeUpdateRequest,
|
|
ExposeUpdateResponse,
|
|
ExposableEntitySchema,
|
|
MqttStatusSchema,
|
|
RepublishResponse,
|
|
)
|
|
from app.services.auth import AuthenticatedSession
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api", tags=["api-expose"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_mqtt_status(db: Session) -> MqttStatusSchema:
|
|
"""Build MQTT/Discovery status from live runtime state (DB-merged settings)."""
|
|
settings = build_runtime_settings(db, get_settings())
|
|
return MqttStatusSchema(
|
|
mqtt_configured=mqtt_manager.is_configured(settings),
|
|
mqtt_connected=mqtt_manager.is_connected,
|
|
discovery_enabled=bool(settings.ha_discovery_enabled),
|
|
)
|
|
|
|
|
|
def _entry_to_schema(entry: CatalogEntry) -> CatalogEntrySchema:
|
|
"""Convert a CatalogEntry to its Pydantic schema form.
|
|
|
|
Excludes ``value_getter`` because it is a non-serialisable callable.
|
|
"""
|
|
entity = entry.entity
|
|
return CatalogEntrySchema(
|
|
entity=ExposableEntitySchema(
|
|
key=entity.key,
|
|
component=entity.component,
|
|
device=DeviceInfoSchema(
|
|
identifiers=list(entity.device.identifiers),
|
|
name=entity.device.name,
|
|
),
|
|
device_class=entity.device_class,
|
|
unit=entity.unit,
|
|
name=entity.name,
|
|
state_class=entity.state_class,
|
|
),
|
|
enabled=entry.enabled,
|
|
)
|
|
|
|
|
|
def _build_response_data(
|
|
session: Session,
|
|
) -> tuple[list[CatalogEntrySchema], MqttStatusSchema]:
|
|
"""Return (catalog_entries, mqtt_status) for building GET / PUT responses."""
|
|
catalog = build_catalog(session)
|
|
catalog_schema = [_entry_to_schema(e) for e in catalog]
|
|
mqtt_status = _get_mqtt_status(session)
|
|
return catalog_schema, mqtt_status
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/expose
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/expose", response_model=ExposeResponse)
|
|
def get_expose(
|
|
db: Session = Depends(get_db),
|
|
_auth: AuthenticatedSession = Depends(require_session),
|
|
) -> ExposeResponse:
|
|
"""Return the full exposable-entity catalog with toggle states and MQTT status.
|
|
|
|
The catalog is computed dynamically from registered providers (e.g. the
|
|
Modbus provider enumerates all enabled devices and their metric entities).
|
|
Toggle states come from the ``exposed_entity_toggle`` table; entities with
|
|
no row default to ``enabled=False``.
|
|
"""
|
|
catalog_schema, mqtt_status = _build_response_data(db)
|
|
return ExposeResponse(catalog=catalog_schema, mqtt_status=mqtt_status)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PUT /api/expose
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.put("/expose", response_model=ExposeUpdateResponse)
|
|
def put_expose(
|
|
body: ExposeUpdateRequest,
|
|
db: Session = Depends(get_db),
|
|
_auth: AuthenticatedSession = Depends(require_session),
|
|
_csrf: None = Depends(require_csrf),
|
|
) -> ExposeUpdateResponse:
|
|
"""Set per-entity toggle state.
|
|
|
|
Accepts a map of ``{key: bool}`` and upserts rows in the
|
|
``exposed_entity_toggle`` table. Only keys present in ``body.toggles``
|
|
are touched; other entities' toggles are left unchanged.
|
|
|
|
After writing the toggles, triggers a HA Discovery re-publish so any
|
|
changes (enabled ↔ disabled) are reflected in Home Assistant immediately.
|
|
"""
|
|
now = datetime.now(UTC)
|
|
|
|
for key, enabled in body.toggles.items():
|
|
existing = (
|
|
db.query(ExposedEntityToggle)
|
|
.filter(ExposedEntityToggle.key == key)
|
|
.first()
|
|
)
|
|
if existing is not None:
|
|
existing.enabled = enabled
|
|
existing.updated_at = now
|
|
else:
|
|
db.add(
|
|
ExposedEntityToggle(
|
|
key=key,
|
|
enabled=enabled,
|
|
updated_at=now,
|
|
)
|
|
)
|
|
|
|
db.commit()
|
|
|
|
# Trigger discovery re-publish after toggle change.
|
|
_trigger_republish(db)
|
|
|
|
catalog_schema, mqtt_status = _build_response_data(db)
|
|
return ExposeUpdateResponse(catalog=catalog_schema, mqtt_status=mqtt_status)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/expose/republish
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/expose/republish", response_model=RepublishResponse)
|
|
def post_expose_republish(
|
|
db: Session = Depends(get_db),
|
|
_auth: AuthenticatedSession = Depends(require_session),
|
|
_csrf: None = Depends(require_csrf),
|
|
) -> RepublishResponse:
|
|
"""Manually trigger a full HA Discovery re-publish.
|
|
|
|
Calls ``publish_discovery(session)`` from the HA discovery service (M5-T11).
|
|
Returns a status indicating whether the publish was attempted (or skipped
|
|
because MQTT / discovery is not enabled / connected).
|
|
"""
|
|
settings = build_runtime_settings(db, get_settings())
|
|
if not (settings.mqtt_enabled and settings.ha_discovery_enabled and mqtt_manager.is_connected):
|
|
return RepublishResponse(
|
|
ok=False,
|
|
message="MQTT / HA Discovery not enabled or broker not connected.",
|
|
)
|
|
|
|
try:
|
|
_trigger_republish(db)
|
|
return RepublishResponse(
|
|
ok=True,
|
|
message="HA Discovery re-published successfully.",
|
|
)
|
|
except Exception as exc:
|
|
logger.exception("post_expose_republish: unexpected error during publish")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Discovery re-publish failed.",
|
|
) from exc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal: trigger discovery re-publish
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _trigger_republish(session: Session) -> None:
|
|
"""Call publish_discovery from the HA discovery service.
|
|
|
|
No-op if MQTT / discovery is not enabled or broker is not connected
|
|
(publish_discovery guards internally). All errors are swallowed to avoid
|
|
breaking the API response.
|
|
"""
|
|
try:
|
|
from app.services.ha_discovery import publish_discovery
|
|
|
|
publish_discovery(session)
|
|
except Exception:
|
|
logger.exception("_trigger_republish: publish_discovery raised an error")
|