From 45d87f36f75fc024a01d9a0597307f736757ac49 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 22 Jun 2026 15:50:49 +0200 Subject: [PATCH] M5-T12: add expose toggle API and Home Assistant Expose settings panel --- app/api/routes/api/expose.py | 220 ++++++++ app/main.py | 2 + app/schemas/expose.py | 95 ++++ docs/design/m5-iot-energy.md | 2 +- frontend/src/api/schema.d.ts | 346 ++++++++++++- .../src/components/ExposeSettings.test.tsx | 381 ++++++++++++++ frontend/src/components/ExposeSettings.tsx | 302 +++++++++++ frontend/src/pages/ConfigPage.tsx | 15 + openapi/openapi.json | 328 ++++++++++++ openapi/openapi.yaml | 255 ++++++++++ tests/test_api_expose.py | 477 ++++++++++++++++++ 11 files changed, 2420 insertions(+), 3 deletions(-) create mode 100644 app/api/routes/api/expose.py create mode 100644 app/schemas/expose.py create mode 100644 frontend/src/components/ExposeSettings.test.tsx create mode 100644 frontend/src/components/ExposeSettings.tsx create mode 100644 tests/test_api_expose.py diff --git a/app/api/routes/api/expose.py b/app/api/routes/api/expose.py new file mode 100644 index 0000000..9d048cf --- /dev/null +++ b/app/api/routes/api/expose.py @@ -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") diff --git a/app/main.py b/app/main.py index 991b947..43b4364 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/schemas/expose.py b/app/schemas/expose.py new file mode 100644 index 0000000..c351c5b --- /dev/null +++ b/app/schemas/expose.py @@ -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] diff --git a/docs/design/m5-iot-energy.md b/docs/design/m5-iot-energy.md index 9d7eb57..06c3fce 100644 --- a/docs/design/m5-iot-energy.md +++ b/docs/design/m5-iot-energy.md @@ -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 分组、逐项开关、显示连接状态、"重新发布"按钮。 diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index f24a518..fd14a4d 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -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 ``/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; 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; diff --git a/frontend/src/components/ExposeSettings.test.tsx b/frontend/src/components/ExposeSettings.test.tsx new file mode 100644 index 0000000..e48cf38 --- /dev/null +++ b/frontend/src/components/ExposeSettings.test.tsx @@ -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(, { 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() + }) +}) diff --git a/frontend/src/components/ExposeSettings.tsx b/frontend/src/components/ExposeSettings.tsx new file mode 100644 index 0000000..6e769cf --- /dev/null +++ b/frontend/src/components/ExposeSettings.tsx @@ -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 ( + + + MQTT {status.mqtt_configured ? 'Configured' : 'Not Configured'} + + + {status.mqtt_connected ? 'Connected' : 'Disconnected'} + + + Discovery {status.discovery_enabled ? 'On' : 'Off'} + + + ) +} + +// --------------------------------------------------------------------------- +// 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 ( + + + + {entity.name} + + + {entity.component} + {entity.device_class ? ` · ${entity.device_class}` : ''} + {entity.unit ? ` · ${entity.unit}` : ''} + + + onToggle(entity.key, e.currentTarget.checked)} + disabled={isToggling} + size="sm" + data-testid={`entity-toggle-${entity.key}`} + /> + + ) +} + +// --------------------------------------------------------------------------- +// 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 ( + + + {deviceName} + + {entries.map((entry) => ( + + ))} + + ) +} + +// --------------------------------------------------------------------------- +// 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 ( + + + + {result?.ok && ( + + {result.message} + + )} + {result !== null && !result.ok && ( + + {result.message} + + )} + + ) +} + +// --------------------------------------------------------------------------- +// 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 ( +
+ +
+ ) + } + + if (isError || !data) { + return ( + + Failed to load expose settings. Please refresh the page. + + ) + } + + // 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() + 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 ( + + {/* Status row */} + + + + + + {/* Entity catalog — empty state */} + {catalog.length === 0 && ( + + No exposable entities found. Add a Modbus device in the Energy page to get started. + + )} + + {/* Entity catalog — grouped by device */} + {Array.from(deviceGroups.entries()).map(([deviceName, entries]) => ( + + ))} + + ) +} diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index b625084..e1a1fd4 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -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() { )} + {/* M5-T12: Home Assistant Expose — entity toggle panel as an Accordion.Item + below the config form. Kept outside
because it manages its own + mutations (PUT /api/expose, POST /api/expose/republish). */} + + + + Home Assistant Expose + + + + + + + {/* TOTP two-factor auth management */} diff --git a/openapi/openapi.json b/openapi/openapi.json index dfb3c55..c8d7c6f 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -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": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index a74e67f..d20e2b0 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -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: diff --git a/tests/test_api_expose.py b/tests/test_api_expose.py new file mode 100644 index 0000000..578fbda --- /dev/null +++ b/tests/test_api_expose.py @@ -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