M5-T12: add expose toggle API and Home Assistant Expose settings panel

This commit is contained in:
2026-06-22 15:50:49 +02:00
parent 35b95f5c88
commit 45d87f36f7
11 changed files with 2420 additions and 3 deletions
+220
View File
@@ -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")
+2
View File
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from app import models # noqa: F401 from app import models # noqa: F401
from app.api.routes.api.config import router as api_config_router 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.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.modbus import router as api_modbus_router
from app.api.routes.api.session import router as api_session_router from app.api.routes.api.session import router as api_session_router
from app.api.routes import status from app.api.routes import status
@@ -171,6 +172,7 @@ def create_app() -> FastAPI:
app.include_router(status.router) app.include_router(status.router)
app.include_router(api_config_router) app.include_router(api_config_router)
app.include_router(api_data_router) app.include_router(api_data_router)
app.include_router(api_expose_router)
app.include_router(api_modbus_router) app.include_router(api_modbus_router)
app.include_router(api_session_router) app.include_router(api_session_router)
app.include_router(homeassistant_router) app.include_router(homeassistant_router)
+95
View File
@@ -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]
+1 -1
View File
@@ -449,7 +449,7 @@ Phase CMQTT / Discovery,依赖 B 的设备数据与 provider 接口)
- **Reviewer checklist**: 仅发 enabled 实体;entity `unique_id` 稳定(源自 uuid,不随改名变);MQTT 未启用时整链 no-op;不阻塞轮询。 - **Reviewer checklist**: 仅发 enabled 实体;entity `unique_id` 稳定(源自 uuid,不随改名变);MQTT 未启用时整链 no-op;不阻塞轮询。
### M5-T12 — 前端:Expose 勾选 UI + `/api/expose` ### M5-T12 — 前端:Expose 勾选 UI + `/api/expose`
- **Status**: `todo` · **Depends**: M5-T09, M5-T11 - **Status**: `done` · **Depends**: M5-T09, M5-T11
- **Context**: 后端 expose 读写端点 + 设置页勾选界面。 - **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` 前端测试 - **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 分组、逐项开关、显示连接状态、"重新发布"按钮。 - **Steps**: `GET /api/expose`(目录 + 勾选 + MQTT/Discovery 状态)、`PUT /api/expose`key→bool)、`POST /api/expose/republish`(调 T11 service);前端列出目录、按 device 分组、逐项开关、显示连接状态、"重新发布"按钮。
+344 -2
View File
@@ -39,6 +39,7 @@ export interface paths {
* *
* - Blank secret value keeps the existing stored value (no change). * - Blank secret value keeps the existing stored value (no change).
* - Invalid values return 422 and nothing is written to the database. * - 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"]; put: operations["put_config_api_config_put"];
post?: never; post?: never;
@@ -76,6 +77,37 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/locations": {
parameters: { parameters: {
query?: never; query?: never;
@@ -210,6 +242,66 @@ export interface paths {
patch: operations["patch_poo_api_poo__timestamp__patch"]; patch: operations["patch_poo_api_poo__timestamp__patch"];
trace?: never; 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": { "/api/modbus/profiles": {
parameters: { parameters: {
query?: never; query?: never;
@@ -332,8 +424,16 @@ export interface paths {
* Get Readings * Get Readings
* @description Return time-range readings for a device. * @description Return time-range readings for a device.
* *
* Results are ordered by ``recorded_at`` ascending and capped by ``limit`` * When the window contains more rows than ``limit``, the **most recent** N rows
* (max {_READINGS_LIMIT_MAX}) to prevent accidental full-table exports. * 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 * The query uses the ``(device_id, recorded_at)`` composite index for
* efficient time-window scans. * efficient time-window scans.
@@ -723,6 +823,15 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
/**
* CatalogEntrySchema
* @description An entity from the catalog with its current toggle state.
*/
CatalogEntrySchema: {
entity: components["schemas"]["ExposableEntitySchema"];
/** Enabled */
enabled: boolean;
};
/** ConfigField */ /** ConfigField */
ConfigField: { ConfigField: {
/** Env Name */ /** Env Name */
@@ -765,6 +874,69 @@ export interface components {
/** Sections */ /** Sections */
sections: components["schemas"]["ConfigSection"][]; 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 */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
@@ -1009,6 +1181,31 @@ export interface components {
/** Error */ /** Error */
error?: string | null; 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 */
PasswordChangeRequest: { PasswordChangeRequest: {
/** Current Password */ /** Current Password */
@@ -1124,6 +1321,16 @@ export interface components {
/** Last Provider */ /** Last Provider */
last_provider: string | null; last_provider: string | null;
}; };
/**
* RepublishResponse
* @description Response for POST /api/expose/republish.
*/
RepublishResponse: {
/** Ok */
ok: boolean;
/** Message */
message: string;
};
/** SessionResponse */ /** SessionResponse */
SessionResponse: { SessionResponse: {
user: components["schemas"]["SessionUser"]; 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: { get_locations_api_locations_get: {
parameters: { parameters: {
query?: { 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: { get_profiles_api_modbus_profiles_get: {
parameters: { parameters: {
query?: never; 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()
})
})
+302
View File
@@ -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>
)
}
+15
View File
@@ -39,6 +39,7 @@ import {
} from '@mantine/core' } from '@mantine/core'
import apiClient, { ApiError } from '../api/client' import apiClient, { ApiError } from '../api/client'
import type { components } from '../api/schema.d.ts' import type { components } from '../api/schema.d.ts'
import { ExposeSettings } from '../components/ExposeSettings'
import { TotpSettings } from './TotpSettings' import { TotpSettings } from './TotpSettings'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -413,6 +414,20 @@ export function ConfigPage() {
</Stack> </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 */} {/* TOTP two-factor auth management */}
<Stack mt="xl"> <Stack mt="xl">
<TotpSettings /> <TotpSettings />
+328
View File
@@ -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": { "/api/modbus/profiles": {
"get": { "get": {
"tags": [ "tags": [
@@ -1677,6 +1807,24 @@
}, },
"components": { "components": {
"schemas": { "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": { "ConfigField": {
"properties": { "properties": {
"env_name": { "env_name": {
@@ -1785,6 +1933,143 @@
], ],
"title": "ConfigUpdateResponse" "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": { "HTTPValidationError": {
"properties": { "properties": {
"detail": { "detail": {
@@ -2399,6 +2684,30 @@
"title": "ModbusTestReadResponse", "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." "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": { "MqttTestResponse": {
"properties": { "properties": {
"result": { "result": {
@@ -2741,6 +3050,25 @@
], ],
"title": "PublicIPStateSchema" "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": { "SessionResponse": {
"properties": { "properties": {
"user": { "user": {
+255
View File
@@ -516,6 +516,112 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $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: /api/modbus/profiles:
get: get:
tags: tags:
@@ -1267,6 +1373,19 @@ paths:
schema: {} schema: {}
components: components:
schemas: 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: ConfigField:
properties: properties:
env_name: env_name:
@@ -1345,6 +1464,110 @@ components:
required: required:
- sections - sections
title: ConfigUpdateResponse 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: HTTPValidationError:
properties: properties:
detail: detail:
@@ -1763,6 +1986,24 @@ components:
On success ``ok=True`` and ``payload`` contains the decoded values. On success ``ok=True`` and ``payload`` contains the decoded values.
On failure ``ok=False`` and ``error`` describes the problem.' 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: MqttTestResponse:
properties: properties:
result: result:
@@ -1991,6 +2232,20 @@ components:
- last_check_error - last_check_error
- last_provider - last_provider
title: PublicIPStateSchema 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: SessionResponse:
properties: properties:
user: user:
+477
View File
@@ -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