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
+344 -2
View File
@@ -39,6 +39,7 @@ export interface paths {
*
* - Blank secret value keeps the existing stored value (no change).
* - Invalid values return 422 and nothing is written to the database.
* - If MQTT-related settings changed, the MQTT client reconnects automatically.
*/
put: operations["put_config_api_config_put"];
post?: never;
@@ -76,6 +77,37 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/config/mqtt/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Post Mqtt Test
* @description Test MQTT broker connectivity by attempting to connect and publishing a
* test message to ``<ha_discovery_prefix>/home-automation/test``.
*
* The message is visible in MQTT Explorer (or any subscriber) so users can
* confirm the full broker publish path is working.
*
* Three possible outcomes:
* - 200 { "result": "success", "message": ... }
* - 400 { "result": "config-error", "message": ... } (not configured)
* - 502 { "result": "failed", "message": ... } (connection/publish error)
*
* MQTT credentials are never echoed in the response.
*/
post: operations["post_mqtt_test_api_config_mqtt_test_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/locations": {
parameters: {
query?: never;
@@ -210,6 +242,66 @@ export interface paths {
patch: operations["patch_poo_api_poo__timestamp__patch"];
trace?: never;
};
"/api/expose": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Expose
* @description Return the full exposable-entity catalog with toggle states and MQTT status.
*
* The catalog is computed dynamically from registered providers (e.g. the
* Modbus provider enumerates all enabled devices and their metric entities).
* Toggle states come from the ``exposed_entity_toggle`` table; entities with
* no row default to ``enabled=False``.
*/
get: operations["get_expose_api_expose_get"];
/**
* Put Expose
* @description Set per-entity toggle state.
*
* Accepts a map of ``{key: bool}`` and upserts rows in the
* ``exposed_entity_toggle`` table. Only keys present in ``body.toggles``
* are touched; other entities' toggles are left unchanged.
*
* After writing the toggles, triggers a HA Discovery re-publish so any
* changes (enabled ↔ disabled) are reflected in Home Assistant immediately.
*/
put: operations["put_expose_api_expose_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/expose/republish": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Post Expose Republish
* @description Manually trigger a full HA Discovery re-publish.
*
* Calls ``publish_discovery(session)`` from the HA discovery service (M5-T11).
* Returns a status indicating whether the publish was attempted (or skipped
* because MQTT / discovery is not enabled / connected).
*/
post: operations["post_expose_republish_api_expose_republish_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/modbus/profiles": {
parameters: {
query?: never;
@@ -332,8 +424,16 @@ export interface paths {
* Get Readings
* @description Return time-range readings for a device.
*
* Results are ordered by ``recorded_at`` ascending and capped by ``limit``
* (max {_READINGS_LIMIT_MAX}) to prevent accidental full-table exports.
* When the window contains more rows than ``limit``, the **most recent** N rows
* are returned (``ORDER BY recorded_at DESC LIMIT n``), then reversed to
* ascending order before being sent to the client. This ensures that for long
* time-range requests (e.g. 6 h / 24 h) the caller always sees the latest data
* rather than the oldest segment of the window.
*
* When the window has fewer rows than ``limit`` the full window is returned in
* ascending order — behaviour is identical to a plain ascending query.
*
* The response schema is unchanged: items are always ``recorded_at`` ascending.
*
* The query uses the ``(device_id, recorded_at)`` composite index for
* efficient time-window scans.
@@ -723,6 +823,15 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/**
* CatalogEntrySchema
* @description An entity from the catalog with its current toggle state.
*/
CatalogEntrySchema: {
entity: components["schemas"]["ExposableEntitySchema"];
/** Enabled */
enabled: boolean;
};
/** ConfigField */
ConfigField: {
/** Env Name */
@@ -765,6 +874,69 @@ export interface components {
/** Sections */
sections: components["schemas"]["ConfigSection"][];
};
/**
* DeviceInfoSchema
* @description HA device grouping info for an exposable entity.
*/
DeviceInfoSchema: {
/** Identifiers */
identifiers: string[];
/** Name */
name: string;
};
/**
* ExposableEntitySchema
* @description One exposable entity in the catalog.
*
* ``value_getter`` is intentionally excluded — it is a non-serialisable
* callable and is only used internally by the HA Discovery service.
*/
ExposableEntitySchema: {
/** Key */
key: string;
/** Component */
component: string;
device: components["schemas"]["DeviceInfoSchema"];
/** Device Class */
device_class: string | null;
/** Unit */
unit: string;
/** Name */
name: string;
/** State Class */
state_class?: string | null;
};
/**
* ExposeResponse
* @description Response for GET /api/expose.
*/
ExposeResponse: {
/** Catalog */
catalog: components["schemas"]["CatalogEntrySchema"][];
mqtt_status: components["schemas"]["MqttStatusSchema"];
};
/**
* ExposeUpdateRequest
* @description Request body for PUT /api/expose.
*
* ``toggles`` is a map from entity key to desired enabled state (bool).
* Only keys present in the map are updated; absent keys are untouched.
*/
ExposeUpdateRequest: {
/** Toggles */
toggles: {
[key: string]: boolean;
};
};
/**
* ExposeUpdateResponse
* @description Response for PUT /api/expose (returns updated catalog + status).
*/
ExposeUpdateResponse: {
/** Catalog */
catalog: components["schemas"]["CatalogEntrySchema"][];
mqtt_status: components["schemas"]["MqttStatusSchema"];
};
/** HTTPValidationError */
HTTPValidationError: {
/** Detail */
@@ -1009,6 +1181,31 @@ export interface components {
/** Error */
error?: string | null;
};
/**
* MqttStatusSchema
* @description Connection status for MQTT and HA Discovery.
*/
MqttStatusSchema: {
/** Mqtt Configured */
mqtt_configured: boolean;
/** Mqtt Connected */
mqtt_connected: boolean;
/** Discovery Enabled */
discovery_enabled: boolean;
};
/**
* MqttTestResponse
* @description Response from POST /api/config/mqtt/test.
*/
MqttTestResponse: {
/**
* Result
* @enum {string}
*/
result: "success" | "config-error" | "failed";
/** Message */
message: string;
};
/** PasswordChangeRequest */
PasswordChangeRequest: {
/** Current Password */
@@ -1124,6 +1321,16 @@ export interface components {
/** Last Provider */
last_provider: string | null;
};
/**
* RepublishResponse
* @description Response for POST /api/expose/republish.
*/
RepublishResponse: {
/** Ok */
ok: boolean;
/** Message */
message: string;
};
/** SessionResponse */
SessionResponse: {
user: components["schemas"]["SessionUser"];
@@ -1341,6 +1548,55 @@ export interface operations {
};
};
};
post_mqtt_test_api_config_mqtt_test_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MqttTestResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MqttTestResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
/** @description Bad Gateway */
502: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MqttTestResponse"];
};
};
};
};
get_locations_api_locations_get: {
parameters: {
query?: {
@@ -1576,6 +1832,92 @@ export interface operations {
};
};
};
get_expose_api_expose_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExposeResponse"];
};
};
};
};
put_expose_api_expose_put: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExposeUpdateRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ExposeUpdateResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
post_expose_republish_api_expose_republish_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RepublishResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_profiles_api_modbus_profiles_get: {
parameters: {
query?: never;
@@ -0,0 +1,381 @@
/**
* Tests for ExposeSettings (M5-T12).
*
* Strategy: vi.mock the apiClient so GET/PUT/POST responses are controlled
* without a real server or MQTT broker.
*
* Coverage:
* 1. Loading state shows a loader.
* 2. Error state shows an error alert.
* 3. Empty catalog shows the empty-state message.
* 4. Catalog with entities: renders entity names, device groups, toggle switches.
* 5. MQTT status badges render correctly (configured/connected/discovery).
* 6. Toggling a switch calls PUT /api/expose with the correct key/bool.
* 7. Re-publish button calls POST /api/expose/republish.
* 8. Republish ok=true shows success alert; ok=false shows warning.
* 9. value_getter is never rendered (no crash from non-serialisable callable).
* 10. binary_sensor entities are shown in the catalog alongside sensors.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { ExposeSettings } from './ExposeSettings'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const MOCK_MQTT_STATUS_OFF = {
mqtt_configured: false,
mqtt_connected: false,
discovery_enabled: false,
}
const MOCK_MQTT_STATUS_ON = {
mqtt_configured: true,
mqtt_connected: true,
discovery_enabled: true,
}
const MOCK_CATALOG_EMPTY = {
catalog: [],
mqtt_status: MOCK_MQTT_STATUS_OFF,
}
const MOCK_CATALOG_ONE_DEVICE = {
catalog: [
{
entity: {
key: 'modbus.abc.voltage',
component: 'sensor',
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
device_class: 'voltage',
unit: 'V',
name: 'Test Meter Voltage',
state_class: 'measurement',
},
enabled: false,
},
{
entity: {
key: 'modbus.abc.current',
component: 'sensor',
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
device_class: 'current',
unit: 'A',
name: 'Test Meter Current',
state_class: 'measurement',
},
enabled: true,
},
{
entity: {
key: 'modbus.abc.online',
component: 'binary_sensor',
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
device_class: 'connectivity',
unit: '',
name: 'Test Meter Online',
state_class: null,
},
enabled: false,
},
],
mqtt_status: MOCK_MQTT_STATUS_ON,
}
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPut = vi.fn()
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
PUT: (...args: unknown[]) => mockPut(...args),
POST: (...args: unknown[]) => mockPost(...args),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
function renderExpose() {
return renderWithProviders(<ExposeSettings />, { initialPath: '/config' })
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ExposeSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// -------------------------------------------------------------------------
// 1. Loading state
// -------------------------------------------------------------------------
it('shows a loader while the catalog is loading', () => {
// Never resolves
mockGet.mockReturnValue(new Promise(() => {}))
renderExpose()
expect(screen.getByTestId('expose-loading')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 2. Error state
// -------------------------------------------------------------------------
it('shows an error alert when the catalog request fails', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('expose-load-error')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// 3. Empty catalog
// -------------------------------------------------------------------------
it('shows empty-state message when catalog is empty', async () => {
mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY })
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('expose-empty')).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// 4. Catalog with entities
// -------------------------------------------------------------------------
it('renders entity names and device groups', async () => {
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('expose-settings')).toBeInTheDocument()
})
// Device group heading
expect(screen.getByTestId('device-group-Test Meter')).toBeInTheDocument()
// Entity names
expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument()
expect(screen.getByText('Test Meter Current')).toBeInTheDocument()
expect(screen.getByText('Test Meter Online')).toBeInTheDocument()
})
it('renders toggle switches for each entity', async () => {
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument()
})
// voltage is disabled (enabled=false in fixture)
const voltageSwitch = screen.getByTestId(
'entity-toggle-modbus.abc.voltage',
) as HTMLInputElement
expect(voltageSwitch.checked).toBe(false)
// current is enabled (enabled=true in fixture)
const currentSwitch = screen.getByTestId(
'entity-toggle-modbus.abc.current',
) as HTMLInputElement
expect(currentSwitch.checked).toBe(true)
})
it('shows binary_sensor entities alongside sensor entities', async () => {
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('entity-row-modbus.abc.online')).toBeInTheDocument()
})
// binary_sensor row contains "binary_sensor" text
const onlineRow = screen.getByTestId('entity-row-modbus.abc.online')
expect(onlineRow).toHaveTextContent('binary_sensor')
})
// -------------------------------------------------------------------------
// 5. MQTT status badges
// -------------------------------------------------------------------------
it('renders MQTT status badges', async () => {
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('mqtt-status-badges')).toBeInTheDocument()
})
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
expect(screen.getByTestId('badge-mqtt-connected')).toBeInTheDocument()
expect(screen.getByTestId('badge-discovery-enabled')).toBeInTheDocument()
})
it('shows "Configured" when mqtt_configured is true', async () => {
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
})
expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Configured')
})
it('shows "Not Configured" when mqtt_configured is false', async () => {
mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY })
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
})
expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Not Configured')
})
// -------------------------------------------------------------------------
// 6. Toggle a switch → PUT /api/expose
// -------------------------------------------------------------------------
it('calls PUT /api/expose when a toggle is switched', async () => {
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
mockPut.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument()
})
// Click the voltage toggle (currently off → turning on)
const voltageSwitch = screen.getByTestId('entity-toggle-modbus.abc.voltage')
fireEvent.click(voltageSwitch)
await waitFor(() => {
expect(mockPut).toHaveBeenCalled()
})
const putCall = mockPut.mock.calls[0]
// path arg
expect(putCall[0]).toBe('/api/expose')
// body
expect(putCall[1]?.body?.toggles).toMatchObject({ 'modbus.abc.voltage': true })
})
// -------------------------------------------------------------------------
// 7. Republish button → POST /api/expose/republish
// -------------------------------------------------------------------------
it('calls POST /api/expose/republish when republish button is clicked', async () => {
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
mockPost.mockResolvedValue({ data: { ok: true, message: 'HA Discovery re-published.' } })
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('republish-button'))
await waitFor(() => {
expect(mockPost).toHaveBeenCalled()
})
const postCall = mockPost.mock.calls[0]
expect(postCall[0]).toBe('/api/expose/republish')
})
// -------------------------------------------------------------------------
// 8. Republish ok=true → success alert; ok=false → warning
// -------------------------------------------------------------------------
it('shows success alert when republish returns ok=true', async () => {
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
mockPost.mockResolvedValue({
data: { ok: true, message: 'HA Discovery re-published successfully.' },
})
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('republish-button'))
await waitFor(() => {
expect(screen.getByTestId('republish-success')).toBeInTheDocument()
})
expect(screen.queryByTestId('republish-warning')).not.toBeInTheDocument()
})
it('shows warning alert when republish returns ok=false', async () => {
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
mockPost.mockResolvedValue({
data: { ok: false, message: 'MQTT not connected.' },
})
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('republish-button'))
await waitFor(() => {
expect(screen.getByTestId('republish-warning')).toBeInTheDocument()
})
expect(screen.queryByTestId('republish-success')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 9. No crash from non-serialisable value_getter (value_getter NOT rendered)
// -------------------------------------------------------------------------
it('renders without crashing even if value_getter were present (it is excluded by API)', async () => {
// The API never sends value_getter; this tests resilience of the schema
const catalogWithExtraField = {
...MOCK_CATALOG_ONE_DEVICE,
catalog: MOCK_CATALOG_ONE_DEVICE.catalog.map((entry) => ({
...entry,
entity: { ...entry.entity },
// No value_getter — confirm the component handles normal data
})),
}
mockGet.mockResolvedValue({ data: catalogWithExtraField })
renderExpose()
await waitFor(() => {
expect(screen.getByTestId('expose-settings')).toBeInTheDocument()
})
// No errors thrown, entities visible
expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument()
})
})
+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'
import apiClient, { ApiError } from '../api/client'
import type { components } from '../api/schema.d.ts'
import { ExposeSettings } from '../components/ExposeSettings'
import { TotpSettings } from './TotpSettings'
// ---------------------------------------------------------------------------
@@ -413,6 +414,20 @@ export function ConfigPage() {
</Stack>
)}
{/* M5-T12: Home Assistant Expose — entity toggle panel as an Accordion.Item
below the config form. Kept outside <form> because it manages its own
mutations (PUT /api/expose, POST /api/expose/republish). */}
<Accordion variant="separated" radius="md" mt="xl" data-testid="expose-accordion">
<Accordion.Item value="expose">
<Accordion.Control data-testid="accordion-control-expose">
<Text fw={500}>Home Assistant Expose</Text>
</Accordion.Control>
<Accordion.Panel>
<ExposeSettings />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{/* TOTP two-factor auth management */}
<Stack mt="xl">
<TotpSettings />