Files
home-automation/frontend/src/components/ExposeSettings.tsx
T

303 lines
8.9 KiB
TypeScript
Raw Normal View History

/**
* 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>
)
}