M5-T12: add expose toggle API and Home Assistant Expose settings panel
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""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.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() -> MqttStatusSchema:
|
||||
"""Build MQTT/Discovery status from live runtime state."""
|
||||
settings = 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()
|
||||
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 = 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")
|
||||
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from app import models # noqa: F401
|
||||
from app.api.routes.api.config import router as api_config_router
|
||||
from app.api.routes.api.data import router as api_data_router
|
||||
from app.api.routes.api.expose import router as api_expose_router
|
||||
from app.api.routes.api.modbus import router as api_modbus_router
|
||||
from app.api.routes.api.session import router as api_session_router
|
||||
from app.api.routes import status
|
||||
@@ -171,6 +172,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(status.router)
|
||||
app.include_router(api_config_router)
|
||||
app.include_router(api_data_router)
|
||||
app.include_router(api_expose_router)
|
||||
app.include_router(api_modbus_router)
|
||||
app.include_router(api_session_router)
|
||||
app.include_router(homeassistant_router)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Pydantic schemas for the Expose API (M5-T12).
|
||||
|
||||
Three endpoints:
|
||||
GET /api/expose — catalog + toggle state + MQTT/Discovery status
|
||||
PUT /api/expose — set toggles (map key → bool)
|
||||
POST /api/expose/republish — trigger discovery re-publish
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nested schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceInfoSchema(BaseModel):
|
||||
"""HA device grouping info for an exposable entity."""
|
||||
|
||||
identifiers: list[str]
|
||||
name: str
|
||||
|
||||
|
||||
class ExposableEntitySchema(BaseModel):
|
||||
"""One exposable entity in the catalog.
|
||||
|
||||
``value_getter`` is intentionally excluded — it is a non-serialisable
|
||||
callable and is only used internally by the HA Discovery service.
|
||||
"""
|
||||
|
||||
key: str
|
||||
component: str
|
||||
device: DeviceInfoSchema
|
||||
device_class: str | None
|
||||
unit: str
|
||||
name: str
|
||||
state_class: str | None = None
|
||||
|
||||
|
||||
class CatalogEntrySchema(BaseModel):
|
||||
"""An entity from the catalog with its current toggle state."""
|
||||
|
||||
entity: ExposableEntitySchema
|
||||
enabled: bool
|
||||
|
||||
|
||||
class MqttStatusSchema(BaseModel):
|
||||
"""Connection status for MQTT and HA Discovery."""
|
||||
|
||||
mqtt_configured: bool
|
||||
mqtt_connected: bool
|
||||
discovery_enabled: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ExposeResponse(BaseModel):
|
||||
"""Response for GET /api/expose."""
|
||||
|
||||
catalog: list[CatalogEntrySchema]
|
||||
mqtt_status: MqttStatusSchema
|
||||
|
||||
|
||||
class ExposeUpdateResponse(BaseModel):
|
||||
"""Response for PUT /api/expose (returns updated catalog + status)."""
|
||||
|
||||
catalog: list[CatalogEntrySchema]
|
||||
mqtt_status: MqttStatusSchema
|
||||
|
||||
|
||||
class RepublishResponse(BaseModel):
|
||||
"""Response for POST /api/expose/republish."""
|
||||
|
||||
ok: bool
|
||||
message: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ExposeUpdateRequest(BaseModel):
|
||||
"""Request body for PUT /api/expose.
|
||||
|
||||
``toggles`` is a map from entity key to desired enabled state (bool).
|
||||
Only keys present in the map are updated; absent keys are untouched.
|
||||
"""
|
||||
|
||||
toggles: dict[str, bool]
|
||||
@@ -449,7 +449,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
||||
- **Reviewer checklist**: 仅发 enabled 实体;entity `unique_id` 稳定(源自 uuid,不随改名变);MQTT 未启用时整链 no-op;不阻塞轮询。
|
||||
|
||||
### M5-T12 — 前端:Expose 勾选 UI + `/api/expose`
|
||||
- **Status**: `todo` · **Depends**: M5-T09, M5-T11
|
||||
- **Status**: `done` · **Depends**: M5-T09, M5-T11
|
||||
- **Context**: 后端 expose 读写端点 + 设置页勾选界面。
|
||||
- **Files**: `create app/api/routes/api/expose.py`、`app/schemas/expose.py`;`modify app/main.py`;`create tests/test_api_expose.py`;前端 `create frontend/src/pages/.../ExposeSettings.tsx`(或并入 ConfigPage)、`modify` 路由/设置入口;`create` 前端测试
|
||||
- **Steps**: `GET /api/expose`(目录 + 勾选 + MQTT/Discovery 状态)、`PUT /api/expose`(key→bool)、`POST /api/expose/republish`(调 T11 service);前端列出目录、按 device 分组、逐项开关、显示连接状态、"重新发布"按钮。
|
||||
|
||||
Vendored
+344
-2
@@ -39,6 +39,7 @@ export interface paths {
|
||||
*
|
||||
* - Blank secret value keeps the existing stored value (no change).
|
||||
* - Invalid values return 422 and nothing is written to the database.
|
||||
* - If MQTT-related settings changed, the MQTT client reconnects automatically.
|
||||
*/
|
||||
put: operations["put_config_api_config_put"];
|
||||
post?: never;
|
||||
@@ -76,6 +77,37 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/config/mqtt/test": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Post Mqtt Test
|
||||
* @description Test MQTT broker connectivity by attempting to connect and publishing a
|
||||
* test message to ``<ha_discovery_prefix>/home-automation/test``.
|
||||
*
|
||||
* The message is visible in MQTT Explorer (or any subscriber) so users can
|
||||
* confirm the full broker publish path is working.
|
||||
*
|
||||
* Three possible outcomes:
|
||||
* - 200 { "result": "success", "message": ... }
|
||||
* - 400 { "result": "config-error", "message": ... } (not configured)
|
||||
* - 502 { "result": "failed", "message": ... } (connection/publish error)
|
||||
*
|
||||
* MQTT credentials are never echoed in the response.
|
||||
*/
|
||||
post: operations["post_mqtt_test_api_config_mqtt_test_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/locations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -210,6 +242,66 @@ export interface paths {
|
||||
patch: operations["patch_poo_api_poo__timestamp__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/expose": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Expose
|
||||
* @description 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``.
|
||||
*/
|
||||
get: operations["get_expose_api_expose_get"];
|
||||
/**
|
||||
* Put Expose
|
||||
* @description 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.
|
||||
*/
|
||||
put: operations["put_expose_api_expose_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/expose/republish": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Post Expose Republish
|
||||
* @description 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).
|
||||
*/
|
||||
post: operations["post_expose_republish_api_expose_republish_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/modbus/profiles": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -332,8 +424,16 @@ export interface paths {
|
||||
* Get Readings
|
||||
* @description Return time-range readings for a device.
|
||||
*
|
||||
* Results are ordered by ``recorded_at`` ascending and capped by ``limit``
|
||||
* (max {_READINGS_LIMIT_MAX}) to prevent accidental full-table exports.
|
||||
* When the window contains more rows than ``limit``, the **most recent** N rows
|
||||
* are returned (``ORDER BY recorded_at DESC LIMIT n``), then reversed to
|
||||
* ascending order before being sent to the client. This ensures that for long
|
||||
* time-range requests (e.g. 6 h / 24 h) the caller always sees the latest data
|
||||
* rather than the oldest segment of the window.
|
||||
*
|
||||
* When the window has fewer rows than ``limit`` the full window is returned in
|
||||
* ascending order — behaviour is identical to a plain ascending query.
|
||||
*
|
||||
* The response schema is unchanged: items are always ``recorded_at`` ascending.
|
||||
*
|
||||
* The query uses the ``(device_id, recorded_at)`` composite index for
|
||||
* efficient time-window scans.
|
||||
@@ -723,6 +823,15 @@ export interface paths {
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/**
|
||||
* CatalogEntrySchema
|
||||
* @description An entity from the catalog with its current toggle state.
|
||||
*/
|
||||
CatalogEntrySchema: {
|
||||
entity: components["schemas"]["ExposableEntitySchema"];
|
||||
/** Enabled */
|
||||
enabled: boolean;
|
||||
};
|
||||
/** ConfigField */
|
||||
ConfigField: {
|
||||
/** Env Name */
|
||||
@@ -765,6 +874,69 @@ export interface components {
|
||||
/** Sections */
|
||||
sections: components["schemas"]["ConfigSection"][];
|
||||
};
|
||||
/**
|
||||
* DeviceInfoSchema
|
||||
* @description HA device grouping info for an exposable entity.
|
||||
*/
|
||||
DeviceInfoSchema: {
|
||||
/** Identifiers */
|
||||
identifiers: string[];
|
||||
/** Name */
|
||||
name: string;
|
||||
};
|
||||
/**
|
||||
* ExposableEntitySchema
|
||||
* @description One exposable entity in the catalog.
|
||||
*
|
||||
* ``value_getter`` is intentionally excluded — it is a non-serialisable
|
||||
* callable and is only used internally by the HA Discovery service.
|
||||
*/
|
||||
ExposableEntitySchema: {
|
||||
/** Key */
|
||||
key: string;
|
||||
/** Component */
|
||||
component: string;
|
||||
device: components["schemas"]["DeviceInfoSchema"];
|
||||
/** Device Class */
|
||||
device_class: string | null;
|
||||
/** Unit */
|
||||
unit: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/** State Class */
|
||||
state_class?: string | null;
|
||||
};
|
||||
/**
|
||||
* ExposeResponse
|
||||
* @description Response for GET /api/expose.
|
||||
*/
|
||||
ExposeResponse: {
|
||||
/** Catalog */
|
||||
catalog: components["schemas"]["CatalogEntrySchema"][];
|
||||
mqtt_status: components["schemas"]["MqttStatusSchema"];
|
||||
};
|
||||
/**
|
||||
* ExposeUpdateRequest
|
||||
* @description Request body for PUT /api/expose.
|
||||
*
|
||||
* ``toggles`` is a map from entity key to desired enabled state (bool).
|
||||
* Only keys present in the map are updated; absent keys are untouched.
|
||||
*/
|
||||
ExposeUpdateRequest: {
|
||||
/** Toggles */
|
||||
toggles: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* ExposeUpdateResponse
|
||||
* @description Response for PUT /api/expose (returns updated catalog + status).
|
||||
*/
|
||||
ExposeUpdateResponse: {
|
||||
/** Catalog */
|
||||
catalog: components["schemas"]["CatalogEntrySchema"][];
|
||||
mqtt_status: components["schemas"]["MqttStatusSchema"];
|
||||
};
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
/** Detail */
|
||||
@@ -1009,6 +1181,31 @@ export interface components {
|
||||
/** Error */
|
||||
error?: string | null;
|
||||
};
|
||||
/**
|
||||
* MqttStatusSchema
|
||||
* @description Connection status for MQTT and HA Discovery.
|
||||
*/
|
||||
MqttStatusSchema: {
|
||||
/** Mqtt Configured */
|
||||
mqtt_configured: boolean;
|
||||
/** Mqtt Connected */
|
||||
mqtt_connected: boolean;
|
||||
/** Discovery Enabled */
|
||||
discovery_enabled: boolean;
|
||||
};
|
||||
/**
|
||||
* MqttTestResponse
|
||||
* @description Response from POST /api/config/mqtt/test.
|
||||
*/
|
||||
MqttTestResponse: {
|
||||
/**
|
||||
* Result
|
||||
* @enum {string}
|
||||
*/
|
||||
result: "success" | "config-error" | "failed";
|
||||
/** Message */
|
||||
message: string;
|
||||
};
|
||||
/** PasswordChangeRequest */
|
||||
PasswordChangeRequest: {
|
||||
/** Current Password */
|
||||
@@ -1124,6 +1321,16 @@ export interface components {
|
||||
/** Last Provider */
|
||||
last_provider: string | null;
|
||||
};
|
||||
/**
|
||||
* RepublishResponse
|
||||
* @description Response for POST /api/expose/republish.
|
||||
*/
|
||||
RepublishResponse: {
|
||||
/** Ok */
|
||||
ok: boolean;
|
||||
/** Message */
|
||||
message: string;
|
||||
};
|
||||
/** SessionResponse */
|
||||
SessionResponse: {
|
||||
user: components["schemas"]["SessionUser"];
|
||||
@@ -1341,6 +1548,55 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
post_mqtt_test_api_config_mqtt_test_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
"X-CSRF-Token"?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MqttTestResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MqttTestResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Gateway */
|
||||
502: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MqttTestResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_locations_api_locations_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -1576,6 +1832,92 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_expose_api_expose_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExposeResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
put_expose_api_expose_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
"X-CSRF-Token"?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExposeUpdateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExposeUpdateResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
post_expose_republish_api_expose_republish_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
"X-CSRF-Token"?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["RepublishResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_profiles_api_modbus_profiles_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Tests for ExposeSettings (M5-T12).
|
||||
*
|
||||
* Strategy: vi.mock the apiClient so GET/PUT/POST responses are controlled
|
||||
* without a real server or MQTT broker.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Loading state shows a loader.
|
||||
* 2. Error state shows an error alert.
|
||||
* 3. Empty catalog shows the empty-state message.
|
||||
* 4. Catalog with entities: renders entity names, device groups, toggle switches.
|
||||
* 5. MQTT status badges render correctly (configured/connected/discovery).
|
||||
* 6. Toggling a switch calls PUT /api/expose with the correct key/bool.
|
||||
* 7. Re-publish button calls POST /api/expose/republish.
|
||||
* 8. Republish ok=true shows success alert; ok=false shows warning.
|
||||
* 9. value_getter is never rendered (no crash from non-serialisable callable).
|
||||
* 10. binary_sensor entities are shown in the catalog alongside sensors.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { renderWithProviders } from '../test-utils'
|
||||
import { ExposeSettings } from './ExposeSettings'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_MQTT_STATUS_OFF = {
|
||||
mqtt_configured: false,
|
||||
mqtt_connected: false,
|
||||
discovery_enabled: false,
|
||||
}
|
||||
|
||||
const MOCK_MQTT_STATUS_ON = {
|
||||
mqtt_configured: true,
|
||||
mqtt_connected: true,
|
||||
discovery_enabled: true,
|
||||
}
|
||||
|
||||
const MOCK_CATALOG_EMPTY = {
|
||||
catalog: [],
|
||||
mqtt_status: MOCK_MQTT_STATUS_OFF,
|
||||
}
|
||||
|
||||
const MOCK_CATALOG_ONE_DEVICE = {
|
||||
catalog: [
|
||||
{
|
||||
entity: {
|
||||
key: 'modbus.abc.voltage',
|
||||
component: 'sensor',
|
||||
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
||||
device_class: 'voltage',
|
||||
unit: 'V',
|
||||
name: 'Test Meter Voltage',
|
||||
state_class: 'measurement',
|
||||
},
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
entity: {
|
||||
key: 'modbus.abc.current',
|
||||
component: 'sensor',
|
||||
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
||||
device_class: 'current',
|
||||
unit: 'A',
|
||||
name: 'Test Meter Current',
|
||||
state_class: 'measurement',
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
entity: {
|
||||
key: 'modbus.abc.online',
|
||||
component: 'binary_sensor',
|
||||
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
||||
device_class: 'connectivity',
|
||||
unit: '',
|
||||
name: 'Test Meter Online',
|
||||
state_class: null,
|
||||
},
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
mqtt_status: MOCK_MQTT_STATUS_ON,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock apiClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: {
|
||||
GET: (...args: unknown[]) => mockGet(...args),
|
||||
PUT: (...args: unknown[]) => mockPut(...args),
|
||||
POST: (...args: unknown[]) => mockPost(...args),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, body: unknown) {
|
||||
super(`API error ${status}`)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
},
|
||||
registerLoginRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderExpose() {
|
||||
return renderWithProviders(<ExposeSettings />, { initialPath: '/config' })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ExposeSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Loading state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows a loader while the catalog is loading', () => {
|
||||
// Never resolves
|
||||
mockGet.mockReturnValue(new Promise(() => {}))
|
||||
renderExpose()
|
||||
expect(screen.getByTestId('expose-loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Error state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows an error alert when the catalog request fails', async () => {
|
||||
mockGet.mockRejectedValue(new Error('Network error'))
|
||||
renderExpose()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('expose-load-error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Empty catalog
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows empty-state message when catalog is empty', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY })
|
||||
renderExpose()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('expose-empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Catalog with entities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders entity names and device groups', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('expose-settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Device group heading
|
||||
expect(screen.getByTestId('device-group-Test Meter')).toBeInTheDocument()
|
||||
|
||||
// Entity names
|
||||
expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Meter Current')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Meter Online')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders toggle switches for each entity', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// voltage is disabled (enabled=false in fixture)
|
||||
const voltageSwitch = screen.getByTestId(
|
||||
'entity-toggle-modbus.abc.voltage',
|
||||
) as HTMLInputElement
|
||||
expect(voltageSwitch.checked).toBe(false)
|
||||
|
||||
// current is enabled (enabled=true in fixture)
|
||||
const currentSwitch = screen.getByTestId(
|
||||
'entity-toggle-modbus.abc.current',
|
||||
) as HTMLInputElement
|
||||
expect(currentSwitch.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('shows binary_sensor entities alongside sensor entities', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('entity-row-modbus.abc.online')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// binary_sensor row contains "binary_sensor" text
|
||||
const onlineRow = screen.getByTestId('entity-row-modbus.abc.online')
|
||||
expect(onlineRow).toHaveTextContent('binary_sensor')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. MQTT status badges
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders MQTT status badges', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mqtt-status-badges')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('badge-mqtt-connected')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('badge-discovery-enabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Configured" when mqtt_configured is true', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Configured')
|
||||
})
|
||||
|
||||
it('shows "Not Configured" when mqtt_configured is false', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Not Configured')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. Toggle a switch → PUT /api/expose
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('calls PUT /api/expose when a toggle is switched', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
mockPut.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click the voltage toggle (currently off → turning on)
|
||||
const voltageSwitch = screen.getByTestId('entity-toggle-modbus.abc.voltage')
|
||||
fireEvent.click(voltageSwitch)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPut).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const putCall = mockPut.mock.calls[0]
|
||||
// path arg
|
||||
expect(putCall[0]).toBe('/api/expose')
|
||||
// body
|
||||
expect(putCall[1]?.body?.toggles).toMatchObject({ 'modbus.abc.voltage': true })
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. Republish button → POST /api/expose/republish
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('calls POST /api/expose/republish when republish button is clicked', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
mockPost.mockResolvedValue({ data: { ok: true, message: 'HA Discovery re-published.' } })
|
||||
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('republish-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const postCall = mockPost.mock.calls[0]
|
||||
expect(postCall[0]).toBe('/api/expose/republish')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 8. Republish ok=true → success alert; ok=false → warning
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows success alert when republish returns ok=true', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
mockPost.mockResolvedValue({
|
||||
data: { ok: true, message: 'HA Discovery re-published successfully.' },
|
||||
})
|
||||
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('republish-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('republish-success')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByTestId('republish-warning')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows warning alert when republish returns ok=false', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
mockPost.mockResolvedValue({
|
||||
data: { ok: false, message: 'MQTT not connected.' },
|
||||
})
|
||||
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('republish-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('republish-warning')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByTestId('republish-success')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 9. No crash from non-serialisable value_getter (value_getter NOT rendered)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders without crashing even if value_getter were present (it is excluded by API)', async () => {
|
||||
// The API never sends value_getter; this tests resilience of the schema
|
||||
const catalogWithExtraField = {
|
||||
...MOCK_CATALOG_ONE_DEVICE,
|
||||
catalog: MOCK_CATALOG_ONE_DEVICE.catalog.map((entry) => ({
|
||||
...entry,
|
||||
entity: { ...entry.entity },
|
||||
// No value_getter — confirm the component handles normal data
|
||||
})),
|
||||
}
|
||||
mockGet.mockResolvedValue({ data: catalogWithExtraField })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('expose-settings')).toBeInTheDocument()
|
||||
})
|
||||
// No errors thrown, entities visible
|
||||
expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* ExposeSettings — Home Assistant Expose entity management (M5-T12).
|
||||
*
|
||||
* Behaviours:
|
||||
* 1. Load: GET /api/expose → display catalog of exposable entities grouped by HA device,
|
||||
* with per-entity toggle switches and MQTT/Discovery connection status badges.
|
||||
* 2. Toggle: PUT /api/expose with the changed key → bool; triggers discovery re-publish.
|
||||
* 3. Republish: POST /api/expose/republish → manually trigger HA Discovery re-publish.
|
||||
*
|
||||
* All requests go through the typed apiClient (CSRF injected automatically by
|
||||
* csrfMiddleware for write operations).
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from '@mantine/core'
|
||||
import apiClient from '../api/client'
|
||||
import type { components } from '../api/schema.d.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CatalogEntry = components['schemas']['CatalogEntrySchema']
|
||||
type MqttStatus = components['schemas']['MqttStatusSchema']
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: load expose catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useExposeCatalog() {
|
||||
return useQuery({
|
||||
queryKey: ['expose-catalog'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/expose')
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: toggle a single entity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useToggleEntity() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ key, enabled }: { key: string; enabled: boolean }) => {
|
||||
await apiClient.PUT('/api/expose', {
|
||||
body: { toggles: { [key]: enabled } },
|
||||
})
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['expose-catalog'] }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: republish discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useRepublish() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiClient.POST('/api/expose/republish')
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['expose-catalog'] }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MqttStatusBadges — connection status indicators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MqttStatusBadges({ status }: { status: MqttStatus }) {
|
||||
return (
|
||||
<Group gap="xs" wrap="wrap" data-testid="mqtt-status-badges">
|
||||
<Badge
|
||||
color={status.mqtt_configured ? 'blue' : 'gray'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="badge-mqtt-configured"
|
||||
>
|
||||
MQTT {status.mqtt_configured ? 'Configured' : 'Not Configured'}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={status.mqtt_connected ? 'green' : 'red'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="badge-mqtt-connected"
|
||||
>
|
||||
{status.mqtt_connected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={status.discovery_enabled ? 'teal' : 'gray'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="badge-discovery-enabled"
|
||||
>
|
||||
Discovery {status.discovery_enabled ? 'On' : 'Off'}
|
||||
</Badge>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EntityRow — one entity toggle row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EntityRowProps {
|
||||
entry: CatalogEntry
|
||||
onToggle: (key: string, enabled: boolean) => void
|
||||
isToggling: boolean
|
||||
}
|
||||
|
||||
function EntityRow({ entry, onToggle, isToggling }: EntityRowProps) {
|
||||
const { entity, enabled } = entry
|
||||
return (
|
||||
<Group justify="space-between" gap="sm" wrap="nowrap" data-testid={`entity-row-${entity.key}`}>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={500}>
|
||||
{entity.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{entity.component}
|
||||
{entity.device_class ? ` · ${entity.device_class}` : ''}
|
||||
{entity.unit ? ` · ${entity.unit}` : ''}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(e) => onToggle(entity.key, e.currentTarget.checked)}
|
||||
disabled={isToggling}
|
||||
size="sm"
|
||||
data-testid={`entity-toggle-${entity.key}`}
|
||||
/>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeviceGroup — entities grouped by device name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeviceGroupProps {
|
||||
deviceName: string
|
||||
entries: CatalogEntry[]
|
||||
onToggle: (key: string, enabled: boolean) => void
|
||||
isToggling: boolean
|
||||
}
|
||||
|
||||
function DeviceGroup({ deviceName, entries, onToggle, isToggling }: DeviceGroupProps) {
|
||||
return (
|
||||
<Stack gap="xs" data-testid={`device-group-${deviceName}`}>
|
||||
<Text fw={600} size="sm" c="dimmed">
|
||||
{deviceName}
|
||||
</Text>
|
||||
{entries.map((entry) => (
|
||||
<EntityRow
|
||||
key={entry.entity.key}
|
||||
entry={entry}
|
||||
onToggle={onToggle}
|
||||
isToggling={isToggling}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RepublishButton — POST /api/expose/republish
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RepublishButton() {
|
||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||
const republishMutation = useRepublish()
|
||||
|
||||
async function handleRepublish() {
|
||||
setResult(null)
|
||||
try {
|
||||
const data = await republishMutation.mutateAsync()
|
||||
if (data) {
|
||||
setResult({ ok: data.ok, message: data.message })
|
||||
}
|
||||
} catch {
|
||||
setResult({ ok: false, message: 'Republish request failed.' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRepublish}
|
||||
loading={republishMutation.isPending}
|
||||
data-testid="republish-button"
|
||||
>
|
||||
Re-publish Discovery
|
||||
</Button>
|
||||
|
||||
{result?.ok && (
|
||||
<Alert color="green" data-testid="republish-success">
|
||||
{result.message}
|
||||
</Alert>
|
||||
)}
|
||||
{result !== null && !result.ok && (
|
||||
<Alert color="orange" data-testid="republish-warning">
|
||||
{result.message}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExposeSettings — main exported component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ExposeSettings() {
|
||||
const { data, isLoading, isError } = useExposeCatalog()
|
||||
const toggleMutation = useToggleEntity()
|
||||
|
||||
function handleToggle(key: string, enabled: boolean) {
|
||||
toggleMutation.mutate({ key, enabled })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center pt="md">
|
||||
<Loader size="sm" data-testid="expose-loading" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Alert color="red" data-testid="expose-load-error">
|
||||
Failed to load expose settings. Please refresh the page.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
// Defensive null-coerce in case the data shape is unexpected at runtime.
|
||||
const catalog = data.catalog ?? []
|
||||
const mqtt_status = data.mqtt_status ?? {
|
||||
mqtt_configured: false,
|
||||
mqtt_connected: false,
|
||||
discovery_enabled: false,
|
||||
}
|
||||
|
||||
// Group entities by device name for display.
|
||||
const deviceGroups = new Map<string, CatalogEntry[]>()
|
||||
for (const entry of catalog) {
|
||||
const deviceName = entry.entity.device.name
|
||||
const existing = deviceGroups.get(deviceName)
|
||||
if (existing) {
|
||||
existing.push(entry)
|
||||
} else {
|
||||
deviceGroups.set(deviceName, [entry])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md" data-testid="expose-settings">
|
||||
{/* Status row */}
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||
<MqttStatusBadges status={mqtt_status} />
|
||||
<RepublishButton />
|
||||
</Group>
|
||||
|
||||
{/* Entity catalog — empty state */}
|
||||
{catalog.length === 0 && (
|
||||
<Text c="dimmed" size="sm" data-testid="expose-empty">
|
||||
No exposable entities found. Add a Modbus device in the Energy page to get started.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Entity catalog — grouped by device */}
|
||||
{Array.from(deviceGroups.entries()).map(([deviceName, entries]) => (
|
||||
<DeviceGroup
|
||||
key={deviceName}
|
||||
deviceName={deviceName}
|
||||
entries={entries}
|
||||
onToggle={handleToggle}
|
||||
isToggling={toggleMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from '@mantine/core'
|
||||
import apiClient, { ApiError } from '../api/client'
|
||||
import type { components } from '../api/schema.d.ts'
|
||||
import { ExposeSettings } from '../components/ExposeSettings'
|
||||
import { TotpSettings } from './TotpSettings'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -413,6 +414,20 @@ export function ConfigPage() {
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* M5-T12: Home Assistant Expose — entity toggle panel as an Accordion.Item
|
||||
below the config form. Kept outside <form> because it manages its own
|
||||
mutations (PUT /api/expose, POST /api/expose/republish). */}
|
||||
<Accordion variant="separated" radius="md" mt="xl" data-testid="expose-accordion">
|
||||
<Accordion.Item value="expose">
|
||||
<Accordion.Control data-testid="accordion-control-expose">
|
||||
<Text fw={500}>Home Assistant Expose</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<ExposeSettings />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
|
||||
{/* TOTP two-factor auth management */}
|
||||
<Stack mt="xl">
|
||||
<TotpSettings />
|
||||
|
||||
@@ -695,6 +695,136 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/expose": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"api-expose"
|
||||
],
|
||||
"summary": "Get Expose",
|
||||
"description": "Return the full exposable-entity catalog with toggle states and MQTT status.\n\nThe catalog is computed dynamically from registered providers (e.g. the\nModbus provider enumerates all enabled devices and their metric entities).\nToggle states come from the ``exposed_entity_toggle`` table; entities with\nno row default to ``enabled=False``.",
|
||||
"operationId": "get_expose_api_expose_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExposeResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"api-expose"
|
||||
],
|
||||
"summary": "Put Expose",
|
||||
"description": "Set per-entity toggle state.\n\nAccepts a map of ``{key: bool}`` and upserts rows in the\n``exposed_entity_toggle`` table. Only keys present in ``body.toggles``\nare touched; other entities' toggles are left unchanged.\n\nAfter writing the toggles, triggers a HA Discovery re-publish so any\nchanges (enabled ↔ disabled) are reflected in Home Assistant immediately.",
|
||||
"operationId": "put_expose_api_expose_put",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "X-CSRF-Token",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "X-Csrf-Token"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExposeUpdateRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExposeUpdateResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/expose/republish": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"api-expose"
|
||||
],
|
||||
"summary": "Post Expose Republish",
|
||||
"description": "Manually trigger a full HA Discovery re-publish.\n\nCalls ``publish_discovery(session)`` from the HA discovery service (M5-T11).\nReturns a status indicating whether the publish was attempted (or skipped\nbecause MQTT / discovery is not enabled / connected).",
|
||||
"operationId": "post_expose_republish_api_expose_republish_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "X-CSRF-Token",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "X-Csrf-Token"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepublishResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/modbus/profiles": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1677,6 +1807,24 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"CatalogEntrySchema": {
|
||||
"properties": {
|
||||
"entity": {
|
||||
"$ref": "#/components/schemas/ExposableEntitySchema"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Enabled"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"entity",
|
||||
"enabled"
|
||||
],
|
||||
"title": "CatalogEntrySchema",
|
||||
"description": "An entity from the catalog with its current toggle state."
|
||||
},
|
||||
"ConfigField": {
|
||||
"properties": {
|
||||
"env_name": {
|
||||
@@ -1785,6 +1933,143 @@
|
||||
],
|
||||
"title": "ConfigUpdateResponse"
|
||||
},
|
||||
"DeviceInfoSchema": {
|
||||
"properties": {
|
||||
"identifiers": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Identifiers"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"identifiers",
|
||||
"name"
|
||||
],
|
||||
"title": "DeviceInfoSchema",
|
||||
"description": "HA device grouping info for an exposable entity."
|
||||
},
|
||||
"ExposableEntitySchema": {
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"title": "Key"
|
||||
},
|
||||
"component": {
|
||||
"type": "string",
|
||||
"title": "Component"
|
||||
},
|
||||
"device": {
|
||||
"$ref": "#/components/schemas/DeviceInfoSchema"
|
||||
},
|
||||
"device_class": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Device Class"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"title": "Unit"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
},
|
||||
"state_class": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "State Class"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"key",
|
||||
"component",
|
||||
"device",
|
||||
"device_class",
|
||||
"unit",
|
||||
"name"
|
||||
],
|
||||
"title": "ExposableEntitySchema",
|
||||
"description": "One exposable entity in the catalog.\n\n``value_getter`` is intentionally excluded — it is a non-serialisable\ncallable and is only used internally by the HA Discovery service."
|
||||
},
|
||||
"ExposeResponse": {
|
||||
"properties": {
|
||||
"catalog": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CatalogEntrySchema"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Catalog"
|
||||
},
|
||||
"mqtt_status": {
|
||||
"$ref": "#/components/schemas/MqttStatusSchema"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"catalog",
|
||||
"mqtt_status"
|
||||
],
|
||||
"title": "ExposeResponse",
|
||||
"description": "Response for GET /api/expose."
|
||||
},
|
||||
"ExposeUpdateRequest": {
|
||||
"properties": {
|
||||
"toggles": {
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": "object",
|
||||
"title": "Toggles"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"toggles"
|
||||
],
|
||||
"title": "ExposeUpdateRequest",
|
||||
"description": "Request body for PUT /api/expose.\n\n``toggles`` is a map from entity key to desired enabled state (bool).\nOnly keys present in the map are updated; absent keys are untouched."
|
||||
},
|
||||
"ExposeUpdateResponse": {
|
||||
"properties": {
|
||||
"catalog": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/CatalogEntrySchema"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Catalog"
|
||||
},
|
||||
"mqtt_status": {
|
||||
"$ref": "#/components/schemas/MqttStatusSchema"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"catalog",
|
||||
"mqtt_status"
|
||||
],
|
||||
"title": "ExposeUpdateResponse",
|
||||
"description": "Response for PUT /api/expose (returns updated catalog + status)."
|
||||
},
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
@@ -2399,6 +2684,30 @@
|
||||
"title": "ModbusTestReadResponse",
|
||||
"description": "Response for POST /api/modbus/devices/{uuid}/test.\n\nOn success ``ok=True`` and ``payload`` contains the decoded values.\nOn failure ``ok=False`` and ``error`` describes the problem."
|
||||
},
|
||||
"MqttStatusSchema": {
|
||||
"properties": {
|
||||
"mqtt_configured": {
|
||||
"type": "boolean",
|
||||
"title": "Mqtt Configured"
|
||||
},
|
||||
"mqtt_connected": {
|
||||
"type": "boolean",
|
||||
"title": "Mqtt Connected"
|
||||
},
|
||||
"discovery_enabled": {
|
||||
"type": "boolean",
|
||||
"title": "Discovery Enabled"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"mqtt_configured",
|
||||
"mqtt_connected",
|
||||
"discovery_enabled"
|
||||
],
|
||||
"title": "MqttStatusSchema",
|
||||
"description": "Connection status for MQTT and HA Discovery."
|
||||
},
|
||||
"MqttTestResponse": {
|
||||
"properties": {
|
||||
"result": {
|
||||
@@ -2741,6 +3050,25 @@
|
||||
],
|
||||
"title": "PublicIPStateSchema"
|
||||
},
|
||||
"RepublishResponse": {
|
||||
"properties": {
|
||||
"ok": {
|
||||
"type": "boolean",
|
||||
"title": "Ok"
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"title": "Message"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ok",
|
||||
"message"
|
||||
],
|
||||
"title": "RepublishResponse",
|
||||
"description": "Response for POST /api/expose/republish."
|
||||
},
|
||||
"SessionResponse": {
|
||||
"properties": {
|
||||
"user": {
|
||||
|
||||
@@ -516,6 +516,112 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/expose:
|
||||
get:
|
||||
tags:
|
||||
- api-expose
|
||||
summary: Get Expose
|
||||
description: '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``.'
|
||||
operationId: get_expose_api_expose_get
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExposeResponse'
|
||||
put:
|
||||
tags:
|
||||
- api-expose
|
||||
summary: Put Expose
|
||||
description: '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.'
|
||||
operationId: put_expose_api_expose_put
|
||||
parameters:
|
||||
- name: X-CSRF-Token
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: X-Csrf-Token
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExposeUpdateRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ExposeUpdateResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/expose/republish:
|
||||
post:
|
||||
tags:
|
||||
- api-expose
|
||||
summary: Post Expose Republish
|
||||
description: '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).'
|
||||
operationId: post_expose_republish_api_expose_republish_post
|
||||
parameters:
|
||||
- name: X-CSRF-Token
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: X-Csrf-Token
|
||||
responses:
|
||||
'200':
|
||||
description: Successful Response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RepublishResponse'
|
||||
'422':
|
||||
description: Validation Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
/api/modbus/profiles:
|
||||
get:
|
||||
tags:
|
||||
@@ -1267,6 +1373,19 @@ paths:
|
||||
schema: {}
|
||||
components:
|
||||
schemas:
|
||||
CatalogEntrySchema:
|
||||
properties:
|
||||
entity:
|
||||
$ref: '#/components/schemas/ExposableEntitySchema'
|
||||
enabled:
|
||||
type: boolean
|
||||
title: Enabled
|
||||
type: object
|
||||
required:
|
||||
- entity
|
||||
- enabled
|
||||
title: CatalogEntrySchema
|
||||
description: An entity from the catalog with its current toggle state.
|
||||
ConfigField:
|
||||
properties:
|
||||
env_name:
|
||||
@@ -1345,6 +1464,110 @@ components:
|
||||
required:
|
||||
- sections
|
||||
title: ConfigUpdateResponse
|
||||
DeviceInfoSchema:
|
||||
properties:
|
||||
identifiers:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
title: Identifiers
|
||||
name:
|
||||
type: string
|
||||
title: Name
|
||||
type: object
|
||||
required:
|
||||
- identifiers
|
||||
- name
|
||||
title: DeviceInfoSchema
|
||||
description: HA device grouping info for an exposable entity.
|
||||
ExposableEntitySchema:
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
title: Key
|
||||
component:
|
||||
type: string
|
||||
title: Component
|
||||
device:
|
||||
$ref: '#/components/schemas/DeviceInfoSchema'
|
||||
device_class:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: Device Class
|
||||
unit:
|
||||
type: string
|
||||
title: Unit
|
||||
name:
|
||||
type: string
|
||||
title: Name
|
||||
state_class:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: 'null'
|
||||
title: State Class
|
||||
type: object
|
||||
required:
|
||||
- key
|
||||
- component
|
||||
- device
|
||||
- device_class
|
||||
- unit
|
||||
- name
|
||||
title: ExposableEntitySchema
|
||||
description: 'One exposable entity in the catalog.
|
||||
|
||||
|
||||
``value_getter`` is intentionally excluded — it is a non-serialisable
|
||||
|
||||
callable and is only used internally by the HA Discovery service.'
|
||||
ExposeResponse:
|
||||
properties:
|
||||
catalog:
|
||||
items:
|
||||
$ref: '#/components/schemas/CatalogEntrySchema'
|
||||
type: array
|
||||
title: Catalog
|
||||
mqtt_status:
|
||||
$ref: '#/components/schemas/MqttStatusSchema'
|
||||
type: object
|
||||
required:
|
||||
- catalog
|
||||
- mqtt_status
|
||||
title: ExposeResponse
|
||||
description: Response for GET /api/expose.
|
||||
ExposeUpdateRequest:
|
||||
properties:
|
||||
toggles:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
type: object
|
||||
title: Toggles
|
||||
type: object
|
||||
required:
|
||||
- toggles
|
||||
title: ExposeUpdateRequest
|
||||
description: 'Request body for PUT /api/expose.
|
||||
|
||||
|
||||
``toggles`` is a map from entity key to desired enabled state (bool).
|
||||
|
||||
Only keys present in the map are updated; absent keys are untouched.'
|
||||
ExposeUpdateResponse:
|
||||
properties:
|
||||
catalog:
|
||||
items:
|
||||
$ref: '#/components/schemas/CatalogEntrySchema'
|
||||
type: array
|
||||
title: Catalog
|
||||
mqtt_status:
|
||||
$ref: '#/components/schemas/MqttStatusSchema'
|
||||
type: object
|
||||
required:
|
||||
- catalog
|
||||
- mqtt_status
|
||||
title: ExposeUpdateResponse
|
||||
description: Response for PUT /api/expose (returns updated catalog + status).
|
||||
HTTPValidationError:
|
||||
properties:
|
||||
detail:
|
||||
@@ -1763,6 +1986,24 @@ components:
|
||||
On success ``ok=True`` and ``payload`` contains the decoded values.
|
||||
|
||||
On failure ``ok=False`` and ``error`` describes the problem.'
|
||||
MqttStatusSchema:
|
||||
properties:
|
||||
mqtt_configured:
|
||||
type: boolean
|
||||
title: Mqtt Configured
|
||||
mqtt_connected:
|
||||
type: boolean
|
||||
title: Mqtt Connected
|
||||
discovery_enabled:
|
||||
type: boolean
|
||||
title: Discovery Enabled
|
||||
type: object
|
||||
required:
|
||||
- mqtt_configured
|
||||
- mqtt_connected
|
||||
- discovery_enabled
|
||||
title: MqttStatusSchema
|
||||
description: Connection status for MQTT and HA Discovery.
|
||||
MqttTestResponse:
|
||||
properties:
|
||||
result:
|
||||
@@ -1991,6 +2232,20 @@ components:
|
||||
- last_check_error
|
||||
- last_provider
|
||||
title: PublicIPStateSchema
|
||||
RepublishResponse:
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
title: Ok
|
||||
message:
|
||||
type: string
|
||||
title: Message
|
||||
type: object
|
||||
required:
|
||||
- ok
|
||||
- message
|
||||
title: RepublishResponse
|
||||
description: Response for POST /api/expose/republish.
|
||||
SessionResponse:
|
||||
properties:
|
||||
user:
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
"""Tests for M5-T12: Expose API (app/api/routes/api/expose.py).
|
||||
|
||||
Coverage:
|
||||
- GET /api/expose — catalog + toggle state + MQTT/Discovery status
|
||||
- PUT /api/expose — upsert toggles; triggers discovery re-publish
|
||||
- POST /api/expose/republish — manual re-publish
|
||||
|
||||
Auth/CSRF matrix:
|
||||
- Unauthenticated GET → 401
|
||||
- Authenticated GET (no CSRF needed) → 200
|
||||
- Authenticated PUT without CSRF → 403
|
||||
- Authenticated PUT + CSRF → 200; toggles persisted; publish_discovery called
|
||||
- Authenticated POST /republish without CSRF → 403
|
||||
- Authenticated POST /republish + CSRF + MQTT off → 200 ok=False
|
||||
- Authenticated POST /republish + CSRF + MQTT on → 200 ok=True; publish_discovery called
|
||||
|
||||
Value-getter non-serialisation:
|
||||
- value_getter field is NOT present in the API response payload.
|
||||
|
||||
Isolation strategy:
|
||||
- MqttManager and publish_discovery are always mocked so no real broker is needed.
|
||||
- The Modbus _modbus_provider is patched away to a simple fake provider for catalog
|
||||
tests, avoiding the need for DB-registered modbus devices in every test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CSRF = "test-csrf-token"
|
||||
|
||||
|
||||
def _login(client: TestClient) -> None:
|
||||
resp = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "admin", "password": "test-password"},
|
||||
)
|
||||
assert resp.status_code == 200, f"Login failed: {resp.text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A minimal fake provider that returns a fixed entity (no real DB device needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_fake_provider(entity_key: str = "modbus.abc.voltage"):
|
||||
"""Return a provider callable that yields one hard-coded ExposableEntity."""
|
||||
|
||||
def _provider(session): # noqa: ANN001
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
|
||||
return [
|
||||
ExposableEntity(
|
||||
key=entity_key,
|
||||
component="sensor",
|
||||
device=DeviceInfo(identifiers=("modbus", "abc"), name="Test Meter"),
|
||||
device_class="voltage",
|
||||
unit="V",
|
||||
name="Test Meter Voltage",
|
||||
value_getter=lambda sess: 230.2, # non-serialisable callable
|
||||
state_class="measurement",
|
||||
)
|
||||
]
|
||||
|
||||
return _provider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def expose_client(auth_database):
|
||||
"""TestClient + engine for Expose API tests, with real DB but no real broker."""
|
||||
from app.main import create_app
|
||||
|
||||
app_url = auth_database["app_url"]
|
||||
engine = create_engine(app_url, connect_args={"check_same_thread": False})
|
||||
|
||||
fastapi_app = create_app()
|
||||
with TestClient(fastapi_app) as test_client:
|
||||
yield test_client, engine
|
||||
|
||||
engine.dispose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to insert/read toggle rows directly
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _count_toggles(engine) -> int:
|
||||
with Session(engine) as session:
|
||||
from app.models.expose import ExposedEntityToggle
|
||||
|
||||
return session.query(ExposedEntityToggle).count()
|
||||
|
||||
|
||||
def _get_toggle(engine, key: str) -> bool | None:
|
||||
with Session(engine) as session:
|
||||
from app.models.expose import ExposedEntityToggle
|
||||
|
||||
row = session.query(ExposedEntityToggle).filter(ExposedEntityToggle.key == key).first()
|
||||
return None if row is None else row.enabled
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: GET /api/expose
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetExpose:
|
||||
def test_unauthenticated_returns_401(self, expose_client):
|
||||
client, _engine = expose_client
|
||||
resp = client.get("/api/expose")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_authenticated_returns_catalog_and_status(self, expose_client):
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
|
||||
fake_key = "modbus.abc.voltage"
|
||||
with patch(
|
||||
"app.integrations.expose._REGISTRY",
|
||||
[_make_fake_provider(fake_key)],
|
||||
):
|
||||
resp = client.get("/api/expose")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
# Must have 'catalog' list and 'mqtt_status' dict
|
||||
assert "catalog" in data
|
||||
assert "mqtt_status" in data
|
||||
|
||||
# Default toggle is False (no row in DB)
|
||||
assert len(data["catalog"]) == 1
|
||||
entry = data["catalog"][0]
|
||||
assert entry["entity"]["key"] == fake_key
|
||||
assert entry["enabled"] is False
|
||||
|
||||
# value_getter must NOT appear in the serialised entity
|
||||
assert "value_getter" not in entry["entity"]
|
||||
|
||||
def test_mqtt_status_fields_present(self, expose_client):
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
|
||||
with patch("app.integrations.expose._REGISTRY", []):
|
||||
resp = client.get("/api/expose")
|
||||
|
||||
assert resp.status_code == 200
|
||||
status_data = resp.json()["mqtt_status"]
|
||||
assert "mqtt_configured" in status_data
|
||||
assert "mqtt_connected" in status_data
|
||||
assert "discovery_enabled" in status_data
|
||||
|
||||
def test_entity_fields_correct(self, expose_client):
|
||||
"""Verify the entity schema shape matches ExposableEntitySchema."""
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
|
||||
with patch(
|
||||
"app.integrations.expose._REGISTRY",
|
||||
[_make_fake_provider("modbus.abc.voltage")],
|
||||
):
|
||||
resp = client.get("/api/expose")
|
||||
|
||||
entity = resp.json()["catalog"][0]["entity"]
|
||||
for field in ("key", "component", "device", "device_class", "unit", "name"):
|
||||
assert field in entity, f"Missing field: {field}"
|
||||
# device sub-fields
|
||||
assert "identifiers" in entity["device"]
|
||||
assert "name" in entity["device"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: PUT /api/expose
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPutExpose:
|
||||
def test_unauthenticated_returns_401(self, expose_client):
|
||||
client, _engine = expose_client
|
||||
resp = client.put(
|
||||
"/api/expose",
|
||||
json={"toggles": {}},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_missing_csrf_returns_403(self, expose_client):
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
resp = client.put("/api/expose", json={"toggles": {}})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_toggles_persisted_in_db(self, expose_client):
|
||||
client, engine = expose_client
|
||||
_login(client)
|
||||
|
||||
fake_key = "modbus.abc.voltage"
|
||||
|
||||
with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
|
||||
with patch("app.api.routes.api.expose._trigger_republish"):
|
||||
resp = client.put(
|
||||
"/api/expose",
|
||||
json={"toggles": {fake_key: True}},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# Row inserted
|
||||
assert _get_toggle(engine, fake_key) is True
|
||||
|
||||
def test_toggle_upsert(self, expose_client):
|
||||
"""Second PUT on same key updates the existing row rather than inserting another."""
|
||||
client, engine = expose_client
|
||||
_login(client)
|
||||
|
||||
fake_key = "modbus.abc.voltage"
|
||||
|
||||
with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
|
||||
with patch("app.api.routes.api.expose._trigger_republish"):
|
||||
# Enable
|
||||
client.put(
|
||||
"/api/expose",
|
||||
json={"toggles": {fake_key: True}},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
# Disable (upsert)
|
||||
resp = client.put(
|
||||
"/api/expose",
|
||||
json={"toggles": {fake_key: False}},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
# Still exactly one row
|
||||
assert _count_toggles(engine) == 1
|
||||
assert _get_toggle(engine, fake_key) is False
|
||||
|
||||
def test_toggle_change_triggers_discovery(self, expose_client):
|
||||
"""PUT on toggles calls _trigger_republish (which calls publish_discovery)."""
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
|
||||
fake_key = "modbus.abc.voltage"
|
||||
|
||||
with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
|
||||
with patch(
|
||||
"app.api.routes.api.expose._trigger_republish"
|
||||
) as mock_republish:
|
||||
resp = client.put(
|
||||
"/api/expose",
|
||||
json={"toggles": {fake_key: True}},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_republish.assert_called_once()
|
||||
|
||||
def test_response_contains_updated_catalog(self, expose_client):
|
||||
"""PUT response reflects the newly set toggle state."""
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
|
||||
fake_key = "modbus.abc.voltage"
|
||||
|
||||
with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
|
||||
with patch("app.api.routes.api.expose._trigger_republish"):
|
||||
resp = client.put(
|
||||
"/api/expose",
|
||||
json={"toggles": {fake_key: True}},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
assert "catalog" in data
|
||||
# The entry for fake_key should now be enabled=True
|
||||
entries = {e["entity"]["key"]: e["enabled"] for e in data["catalog"]}
|
||||
assert entries[fake_key] is True
|
||||
|
||||
def test_value_getter_not_in_put_response(self, expose_client):
|
||||
"""value_getter callable must not appear in the PUT response."""
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
|
||||
fake_key = "modbus.abc.voltage"
|
||||
|
||||
with patch("app.integrations.expose._REGISTRY", [_make_fake_provider(fake_key)]):
|
||||
with patch("app.api.routes.api.expose._trigger_republish"):
|
||||
resp = client.put(
|
||||
"/api/expose",
|
||||
json={"toggles": {fake_key: True}},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
entity = resp.json()["catalog"][0]["entity"]
|
||||
assert "value_getter" not in entity
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: POST /api/expose/republish
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPostRepublish:
|
||||
def test_unauthenticated_returns_401(self, expose_client):
|
||||
client, _engine = expose_client
|
||||
resp = client.post(
|
||||
"/api/expose/republish",
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_missing_csrf_returns_403(self, expose_client):
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
resp = client.post("/api/expose/republish")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_republish_when_mqtt_not_configured(self, expose_client):
|
||||
"""When MQTT is not enabled/connected, republish returns ok=False."""
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
|
||||
# Ensure MQTT is not configured (default in test env)
|
||||
with patch.object(
|
||||
type(
|
||||
__import__(
|
||||
"app.integrations.mqtt", fromlist=["mqtt_manager"]
|
||||
).mqtt_manager
|
||||
),
|
||||
"is_connected",
|
||||
new_callable=lambda: property(lambda self: False),
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/expose/republish",
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is False
|
||||
assert "message" in data
|
||||
|
||||
def test_republish_calls_publish_discovery_when_mqtt_on(self, expose_client):
|
||||
"""When MQTT is active, republish calls publish_discovery."""
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
|
||||
# Patch settings to enable MQTT + discovery
|
||||
mock_settings: Any = MagicMock()
|
||||
mock_settings.mqtt_enabled = True
|
||||
mock_settings.ha_discovery_enabled = True
|
||||
mock_settings.ha_discovery_prefix = "homeassistant"
|
||||
|
||||
with patch("app.api.routes.api.expose.get_settings", return_value=mock_settings):
|
||||
with patch("app.api.routes.api.expose.mqtt_manager") as mock_mqtt:
|
||||
mock_mqtt.is_configured.return_value = True
|
||||
mock_mqtt.is_connected = True
|
||||
with patch(
|
||||
"app.api.routes.api.expose._trigger_republish"
|
||||
) as mock_republish:
|
||||
resp = client.post(
|
||||
"/api/expose/republish",
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ok"] is True
|
||||
mock_republish.assert_called_once()
|
||||
|
||||
def test_trigger_republish_calls_publish_discovery(self):
|
||||
"""Unit test: _trigger_republish calls publish_discovery with the session."""
|
||||
from app.api.routes.api.expose import _trigger_republish
|
||||
|
||||
mock_session: Any = MagicMock()
|
||||
|
||||
# publish_discovery is imported inside _trigger_republish; patch it
|
||||
# at the source module so all code paths see the mock.
|
||||
with patch("app.services.ha_discovery.publish_discovery") as mock_pd:
|
||||
_trigger_republish(mock_session)
|
||||
mock_pd.assert_called_once_with(mock_session)
|
||||
|
||||
def test_republish_mqtt_not_connected_returns_false(self, expose_client):
|
||||
"""Republish when MQTT not connected returns ok=False (not an exception)."""
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
|
||||
# Ensure settings say MQTT is enabled but mqtt_manager says not connected
|
||||
mock_settings: Any = MagicMock()
|
||||
mock_settings.mqtt_enabled = True
|
||||
mock_settings.ha_discovery_enabled = True
|
||||
|
||||
with patch("app.api.routes.api.expose.get_settings", return_value=mock_settings):
|
||||
with patch("app.api.routes.api.expose.mqtt_manager") as mock_mqtt:
|
||||
mock_mqtt.is_connected = False
|
||||
resp = client.post(
|
||||
"/api/expose/republish",
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: device grouping in catalog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCatalogGrouping:
|
||||
def test_catalog_groups_entities_by_device(self, expose_client):
|
||||
"""Multiple entities from the same device share the same device info."""
|
||||
client, _engine = expose_client
|
||||
_login(client)
|
||||
|
||||
def _multi_entity_provider(session):
|
||||
from app.integrations.expose import DeviceInfo, ExposableEntity
|
||||
|
||||
device_info = DeviceInfo(identifiers=("modbus", "uuid-1"), name="My Meter")
|
||||
return [
|
||||
ExposableEntity(
|
||||
key="modbus.uuid-1.voltage",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class="voltage",
|
||||
unit="V",
|
||||
name="My Meter Voltage",
|
||||
),
|
||||
ExposableEntity(
|
||||
key="modbus.uuid-1.current",
|
||||
component="sensor",
|
||||
device=device_info,
|
||||
device_class="current",
|
||||
unit="A",
|
||||
name="My Meter Current",
|
||||
),
|
||||
ExposableEntity(
|
||||
key="modbus.uuid-1.online",
|
||||
component="binary_sensor",
|
||||
device=device_info,
|
||||
device_class="connectivity",
|
||||
unit="",
|
||||
name="My Meter Online",
|
||||
),
|
||||
]
|
||||
|
||||
with patch("app.integrations.expose._REGISTRY", [_multi_entity_provider]):
|
||||
resp = client.get("/api/expose")
|
||||
|
||||
assert resp.status_code == 200
|
||||
catalog = resp.json()["catalog"]
|
||||
assert len(catalog) == 3
|
||||
|
||||
# All entities share the same device name
|
||||
device_names = {e["entity"]["device"]["name"] for e in catalog}
|
||||
assert device_names == {"My Meter"}
|
||||
|
||||
# binary_sensor is present (not-sensor component)
|
||||
components = {e["entity"]["component"] for e in catalog}
|
||||
assert "binary_sensor" in components
|
||||
assert "sensor" in components
|
||||
Reference in New Issue
Block a user