2026-06-22 15:50:49 +02:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-06-22 20:11:54 +02:00
|
|
|
type="button"
|
2026-06-22 15:50:49 +02:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|