2026-06-24 11:02:42 +02:00
|
|
|
/**
|
|
|
|
|
* DsmrPanel — shows the latest parsed DSMR telegram (the source of our metering data).
|
|
|
|
|
*
|
|
|
|
|
* This is the "ground truth" view: it fetches GET /api/energy/dsmr/latest and
|
|
|
|
|
* renders the full parsed frame as a key/value table, so you can confirm at a
|
|
|
|
|
* glance whether DSMR ingest is actually landing data (without reading the DB).
|
|
|
|
|
*
|
|
|
|
|
* - Auto-refresh toggle (default on) so new telegrams appear without a manual reload.
|
|
|
|
|
* - Loading / error / empty states; "empty" explains the likely cause.
|
|
|
|
|
* - Null phase values are shown as "—" (kept verbatim in the payload).
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { useState } from 'react'
|
|
|
|
|
import {
|
|
|
|
|
Stack,
|
|
|
|
|
Group,
|
|
|
|
|
Text,
|
|
|
|
|
Loader,
|
|
|
|
|
Center,
|
|
|
|
|
Alert,
|
|
|
|
|
Paper,
|
|
|
|
|
Table,
|
|
|
|
|
ScrollArea,
|
|
|
|
|
Switch,
|
|
|
|
|
Divider,
|
|
|
|
|
Badge,
|
|
|
|
|
} from '@mantine/core'
|
|
|
|
|
import { useDsmrLatest } from './hooks'
|
2026-06-24 13:22:07 +02:00
|
|
|
import { formatLocalDateTime } from '../utils/datetime'
|
2026-06-24 11:02:42 +02:00
|
|
|
|
|
|
|
|
/** Default auto-refresh interval (ms) — DSMR ingest stores ~one row per 10 s. */
|
|
|
|
|
const AUTO_REFRESH_INTERVAL_MS = 10_000
|
|
|
|
|
|
|
|
|
|
function formatValue(value: unknown): string {
|
|
|
|
|
if (value === null || value === undefined) return '—'
|
|
|
|
|
if (typeof value === 'object') return JSON.stringify(value)
|
|
|
|
|
return String(value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function DsmrPanel() {
|
|
|
|
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
|
|
|
|
const latestQuery = useDsmrLatest(
|
|
|
|
|
autoRefresh ? { refetchIntervalMs: AUTO_REFRESH_INTERVAL_MS } : undefined,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Stack gap="md" data-testid="dsmr-panel">
|
|
|
|
|
<Group justify="space-between" align="center">
|
|
|
|
|
<div>
|
|
|
|
|
<Text fw={600}>Latest DSMR reading</Text>
|
|
|
|
|
<Text size="xs" c="dimmed">
|
|
|
|
|
The most recent parsed telegram persisted to <code>dsmr_reading</code>.
|
|
|
|
|
</Text>
|
|
|
|
|
</div>
|
|
|
|
|
<Switch
|
|
|
|
|
label="Auto-refresh"
|
|
|
|
|
checked={autoRefresh}
|
|
|
|
|
onChange={(e) => setAutoRefresh(e.currentTarget.checked)}
|
|
|
|
|
size="sm"
|
|
|
|
|
data-testid="dsmr-auto-refresh-switch"
|
|
|
|
|
/>
|
|
|
|
|
</Group>
|
|
|
|
|
|
|
|
|
|
<Divider />
|
|
|
|
|
|
|
|
|
|
<DsmrContent
|
|
|
|
|
isLoading={latestQuery.isLoading}
|
|
|
|
|
isError={latestQuery.isError}
|
|
|
|
|
data={latestQuery.data}
|
|
|
|
|
/>
|
|
|
|
|
</Stack>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface DsmrContentProps {
|
|
|
|
|
isLoading: boolean
|
|
|
|
|
isError: boolean
|
|
|
|
|
data:
|
|
|
|
|
| { found: boolean; recorded_at?: string | null; payload?: Record<string, unknown> | null }
|
|
|
|
|
| undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DsmrContent({ isLoading, isError, data }: DsmrContentProps) {
|
|
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<Center py="xl" data-testid="dsmr-loading">
|
|
|
|
|
<Loader />
|
|
|
|
|
</Center>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isError || !data) {
|
|
|
|
|
return (
|
|
|
|
|
<Alert color="red" data-testid="dsmr-error">
|
|
|
|
|
Failed to load the latest DSMR reading.
|
|
|
|
|
</Alert>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!data.found || !data.payload) {
|
|
|
|
|
return (
|
|
|
|
|
<Alert color="gray" data-testid="dsmr-empty">
|
|
|
|
|
No DSMR data yet. Enable <strong>DSMR ingest</strong> in Config, make sure MQTT
|
|
|
|
|
is connected, and confirm the DSMR Reader is publishing to the configured topic
|
|
|
|
|
(default <code>dsmr/json</code>). Rows are stored about once every 10 seconds.
|
|
|
|
|
</Alert>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = data.payload
|
|
|
|
|
const keys = Object.keys(payload).sort()
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Stack gap="sm" data-testid="dsmr-data">
|
|
|
|
|
<Group gap="xs">
|
|
|
|
|
<Badge color="teal" variant="light">
|
|
|
|
|
{keys.length} fields
|
|
|
|
|
</Badge>
|
|
|
|
|
{data.recorded_at && (
|
|
|
|
|
<Text size="sm" c="dimmed" data-testid="dsmr-recorded-at">
|
2026-06-24 13:22:07 +02:00
|
|
|
recorded at {formatLocalDateTime(data.recorded_at)}
|
2026-06-24 11:02:42 +02:00
|
|
|
</Text>
|
|
|
|
|
)}
|
|
|
|
|
</Group>
|
|
|
|
|
|
|
|
|
|
<Paper withBorder>
|
|
|
|
|
<ScrollArea.Autosize mah={480}>
|
|
|
|
|
<Table striped highlightOnHover withColumnBorders data-testid="dsmr-table">
|
|
|
|
|
<Table.Thead>
|
|
|
|
|
<Table.Tr>
|
|
|
|
|
<Table.Th>Field</Table.Th>
|
|
|
|
|
<Table.Th>Value</Table.Th>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
</Table.Thead>
|
|
|
|
|
<Table.Tbody>
|
|
|
|
|
{keys.map((key) => (
|
|
|
|
|
<Table.Tr key={key} data-testid={`dsmr-row-${key}`}>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<Text size="sm" ff="monospace">
|
|
|
|
|
{key}
|
|
|
|
|
</Text>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
<Table.Td>
|
|
|
|
|
<Text size="sm">{formatValue(payload[key])}</Text>
|
|
|
|
|
</Table.Td>
|
|
|
|
|
</Table.Tr>
|
|
|
|
|
))}
|
|
|
|
|
</Table.Tbody>
|
|
|
|
|
</Table>
|
|
|
|
|
</ScrollArea.Autosize>
|
|
|
|
|
</Paper>
|
|
|
|
|
</Stack>
|
|
|
|
|
)
|
|
|
|
|
}
|