diff --git a/frontend/src/energy/DsmrPanel.test.tsx b/frontend/src/energy/DsmrPanel.test.tsx new file mode 100644 index 0000000..c62e01f --- /dev/null +++ b/frontend/src/energy/DsmrPanel.test.tsx @@ -0,0 +1,82 @@ +/** + * Tests for DsmrPanel — latest parsed DSMR telegram view. + * + * Coverage: + * 1. Loading state. + * 2. Empty state (found=false) with a helpful hint. + * 3. Renders the payload as a key/value table; null values shown as "—". + * 4. Error state. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import { renderWithProviders } from '../test-utils' + +const mockGet = vi.fn() + +vi.mock('../api/client', () => ({ + default: { + GET: (...args: unknown[]) => mockGet(...args), + POST: vi.fn(), + PATCH: vi.fn(), + DELETE: vi.fn(), + }, + 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(), +})) + +import { DsmrPanel } from './DsmrPanel' + +describe('DsmrPanel', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders loading state initially', () => { + mockGet.mockImplementation(() => new Promise(() => {})) + renderWithProviders() + expect(screen.getByTestId('dsmr-loading')).toBeInTheDocument() + }) + + it('shows an empty hint when no DSMR data has been ingested', async () => { + mockGet.mockResolvedValue({ data: { found: false, recorded_at: null, payload: null } }) + renderWithProviders() + await waitFor(() => expect(screen.getByTestId('dsmr-empty')).toBeInTheDocument()) + expect(screen.queryByTestId('dsmr-table')).not.toBeInTheDocument() + }) + + it('renders the latest telegram as a key/value table; null shown as dash', async () => { + mockGet.mockResolvedValue({ + data: { + found: true, + recorded_at: '2026-06-23T12:16:00Z', + payload: { + electricity_delivered_1: '20915.154', + phase_voltage_l2: null, + }, + }, + }) + renderWithProviders() + + await waitFor(() => expect(screen.getByTestId('dsmr-table')).toBeInTheDocument()) + + // Field rows present (keys are sorted). + expect(screen.getByTestId('dsmr-row-electricity_delivered_1')).toHaveTextContent('20915.154') + // Null phase value rendered as an em dash, not omitted. + expect(screen.getByTestId('dsmr-row-phase_voltage_l2')).toHaveTextContent('—') + expect(screen.getByTestId('dsmr-recorded-at')).toBeInTheDocument() + }) + + it('renders an error state when the request fails', async () => { + mockGet.mockRejectedValue(new Error('network down')) + renderWithProviders() + await waitFor(() => expect(screen.getByTestId('dsmr-error')).toBeInTheDocument()) + }) +}) diff --git a/frontend/src/energy/DsmrPanel.tsx b/frontend/src/energy/DsmrPanel.tsx new file mode 100644 index 0000000..4a2c21a --- /dev/null +++ b/frontend/src/energy/DsmrPanel.tsx @@ -0,0 +1,153 @@ +/** + * 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' + +/** 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 ( + + +
+ Latest DSMR reading + + The most recent parsed telegram persisted to dsmr_reading. + +
+ setAutoRefresh(e.currentTarget.checked)} + size="sm" + data-testid="dsmr-auto-refresh-switch" + /> +
+ + + + +
+ ) +} + +interface DsmrContentProps { + isLoading: boolean + isError: boolean + data: + | { found: boolean; recorded_at?: string | null; payload?: Record | null } + | undefined +} + +function DsmrContent({ isLoading, isError, data }: DsmrContentProps) { + if (isLoading) { + return ( +
+ +
+ ) + } + + if (isError || !data) { + return ( + + Failed to load the latest DSMR reading. + + ) + } + + if (!data.found || !data.payload) { + return ( + + No DSMR data yet. Enable DSMR ingest in Config, make sure MQTT + is connected, and confirm the DSMR Reader is publishing to the configured topic + (default dsmr/json). Rows are stored about once every 10 seconds. + + ) + } + + const payload = data.payload + const keys = Object.keys(payload).sort() + + return ( + + + + {keys.length} fields + + {data.recorded_at && ( + + recorded at {new Date(data.recorded_at).toLocaleString()} + + )} + + + + + + + + Field + Value + + + + {keys.map((key) => ( + + + + {key} + + + + {formatValue(payload[key])} + + + ))} + +
+
+
+
+ ) +} diff --git a/frontend/src/energy/hooks.ts b/frontend/src/energy/hooks.ts index 5dbf4a0..e13ae1e 100644 --- a/frontend/src/energy/hooks.ts +++ b/frontend/src/energy/hooks.ts @@ -385,13 +385,18 @@ export function useEnergyCostSummary(start?: string, end?: string) { // Query: DSMR latest reading // --------------------------------------------------------------------------- -export function useDsmrLatest() { +export function useDsmrLatest(options?: AutoRefreshOptions) { + const refetchInterval = options?.refetchIntervalMs != null + ? Math.max(2_000, options.refetchIntervalMs) + : undefined + return useQuery({ queryKey: ['dsmr-latest'], queryFn: async () => { const res = await apiClient.GET('/api/energy/dsmr/latest') return res.data }, + refetchInterval, }) } diff --git a/frontend/src/pages/EnergyPage.tsx b/frontend/src/pages/EnergyPage.tsx index 4268ac0..fdf9f18 100644 --- a/frontend/src/pages/EnergyPage.tsx +++ b/frontend/src/pages/EnergyPage.tsx @@ -44,6 +44,7 @@ import { EnergyCharts } from '../energy/EnergyCharts' import { ContractManager } from '../energy/ContractManager' import { TibberPrices } from '../energy/TibberPrices' import { CostView } from '../energy/CostView' +import { DsmrPanel } from '../energy/DsmrPanel' import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks' import { ApiError } from '../api/client' import { formatMetricValue } from '../energy/format' @@ -628,6 +629,9 @@ export function EnergyPage() { Costs + + DSMR + @@ -645,6 +649,10 @@ export function EnergyPage() { + + + + )