/** * ExposeSettings — Home Assistant Expose entity management (M5-T12). * * Behaviours: * 1. Load: GET /api/expose → display catalog of exposable entities grouped by HA device, * with per-entity toggle switches and MQTT/Discovery connection status badges. * 2. Toggle: PUT /api/expose with the changed key → bool; triggers discovery re-publish. * 3. Republish: POST /api/expose/republish → manually trigger HA Discovery re-publish. * * All requests go through the typed apiClient (CSRF injected automatically by * csrfMiddleware for write operations). */ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Alert, Badge, Button, Center, Group, Loader, Stack, Switch, Text, } from '@mantine/core' import apiClient from '../api/client' import type { components } from '../api/schema.d.ts' // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type CatalogEntry = components['schemas']['CatalogEntrySchema'] type MqttStatus = components['schemas']['MqttStatusSchema'] // --------------------------------------------------------------------------- // Hook: load expose catalog // --------------------------------------------------------------------------- function useExposeCatalog() { return useQuery({ queryKey: ['expose-catalog'], queryFn: async () => { const res = await apiClient.GET('/api/expose') return res.data }, }) } // --------------------------------------------------------------------------- // Hook: toggle a single entity // --------------------------------------------------------------------------- function useToggleEntity() { const qc = useQueryClient() return useMutation({ mutationFn: async ({ key, enabled }: { key: string; enabled: boolean }) => { await apiClient.PUT('/api/expose', { body: { toggles: { [key]: enabled } }, }) }, onSuccess: () => qc.invalidateQueries({ queryKey: ['expose-catalog'] }), }) } // --------------------------------------------------------------------------- // Hook: republish discovery // --------------------------------------------------------------------------- function useRepublish() { const qc = useQueryClient() return useMutation({ mutationFn: async () => { const res = await apiClient.POST('/api/expose/republish') return res.data }, onSuccess: () => qc.invalidateQueries({ queryKey: ['expose-catalog'] }), }) } // --------------------------------------------------------------------------- // MqttStatusBadges — connection status indicators // --------------------------------------------------------------------------- function MqttStatusBadges({ status }: { status: MqttStatus }) { return ( MQTT {status.mqtt_configured ? 'Configured' : 'Not Configured'} {status.mqtt_connected ? 'Connected' : 'Disconnected'} Discovery {status.discovery_enabled ? 'On' : 'Off'} ) } // --------------------------------------------------------------------------- // EntityRow — one entity toggle row // --------------------------------------------------------------------------- interface EntityRowProps { entry: CatalogEntry onToggle: (key: string, enabled: boolean) => void isToggling: boolean } function EntityRow({ entry, onToggle, isToggling }: EntityRowProps) { const { entity, enabled } = entry return ( {entity.name} {entity.component} {entity.device_class ? ` · ${entity.device_class}` : ''} {entity.unit ? ` · ${entity.unit}` : ''} onToggle(entity.key, e.currentTarget.checked)} disabled={isToggling} size="sm" data-testid={`entity-toggle-${entity.key}`} /> ) } // --------------------------------------------------------------------------- // DeviceGroup — entities grouped by device name // --------------------------------------------------------------------------- interface DeviceGroupProps { deviceName: string entries: CatalogEntry[] onToggle: (key: string, enabled: boolean) => void isToggling: boolean } function DeviceGroup({ deviceName, entries, onToggle, isToggling }: DeviceGroupProps) { return ( {deviceName} {entries.map((entry) => ( ))} ) } // --------------------------------------------------------------------------- // RepublishButton — POST /api/expose/republish // --------------------------------------------------------------------------- function RepublishButton() { const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null) const republishMutation = useRepublish() async function handleRepublish() { setResult(null) try { const data = await republishMutation.mutateAsync() if (data) { setResult({ ok: data.ok, message: data.message }) } } catch { setResult({ ok: false, message: 'Republish request failed.' }) } } return ( {result?.ok && ( {result.message} )} {result !== null && !result.ok && ( {result.message} )} ) } // --------------------------------------------------------------------------- // ExposeSettings — main exported component // --------------------------------------------------------------------------- export function ExposeSettings() { const { data, isLoading, isError } = useExposeCatalog() const toggleMutation = useToggleEntity() function handleToggle(key: string, enabled: boolean) { toggleMutation.mutate({ key, enabled }) } if (isLoading) { return (
) } if (isError || !data) { return ( Failed to load expose settings. Please refresh the page. ) } // Defensive null-coerce in case the data shape is unexpected at runtime. const catalog = data.catalog ?? [] const mqtt_status = data.mqtt_status ?? { mqtt_configured: false, mqtt_connected: false, discovery_enabled: false, } // Group entities by device name for display. const deviceGroups = new Map() for (const entry of catalog) { const deviceName = entry.entity.device.name const existing = deviceGroups.get(deviceName) if (existing) { existing.push(entry) } else { deviceGroups.set(deviceName, [entry]) } } return ( {/* Status row */} {/* Entity catalog — empty state */} {catalog.length === 0 && ( No exposable entities found. Add a Modbus device in the Energy page to get started. )} {/* Entity catalog — grouped by device */} {Array.from(deviceGroups.entries()).map(([deviceName, entries]) => ( ))} ) }