-
Latest DSMR reading
+
Latest DSMR reading (compatibility view)
The most recent parsed telegram persisted to dsmr_reading.
@@ -100,9 +100,9 @@ function DsmrContent({ isLoading, isError, data }: DsmrContentProps) {
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.
+ No DSMR data yet. In this DSMR Source, enable or edit the broker, topic, and profile
+ configuration, then confirm the publisher is sending to the configured topic (default
+ dsmr/json). Rows are stored about once every 10 seconds.
)
}
diff --git a/frontend/src/energy/MeterManager.test.tsx b/frontend/src/energy/MeterManager.test.tsx
index 16e10c4..10aaaa9 100644
--- a/frontend/src/energy/MeterManager.test.tsx
+++ b/frontend/src/energy/MeterManager.test.tsx
@@ -84,6 +84,7 @@ const METERS_RESPONSE = {
// ---------------------------------------------------------------------------
describe('MeterManager — loading / error / empty states', () => {
+ // M8 keeps the existing Modbus-facing meter regressions alongside commodity additions.
beforeEach(() => vi.clearAllMocks())
it('renders loading state initially', () => {
@@ -115,6 +116,24 @@ describe('MeterManager — loading / error / empty states', () => {
})
})
+describe('MeterManager — binding switch safety', () => {
+ beforeEach(() => vi.clearAllMocks())
+ it('does not offer switching for a closed meter epoch', async () => {
+ mockGet.mockResolvedValue({ data: { items: [CLOSED_METER], total: 1 } })
+ renderWithProviders(
)
+ await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument())
+ expect(screen.queryByRole('button', { name: 'Switch source' })).not.toBeInTheDocument()
+ })
+ it('disables switch submit until the binding timeline has loaded', async () => {
+ const user = userEvent.setup()
+ mockGet.mockImplementation((path: string) => path === '/api/energy/meters' ? Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } }) : new Promise(() => {}))
+ renderWithProviders(
)
+ await user.click(await screen.findByRole('button', { name: 'Switch source' }))
+ expect(await screen.findByText('Loading binding timeline…')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Switch binding' })).toBeDisabled()
+ })
+})
+
describe('MeterManager — meter list', () => {
beforeEach(() => vi.clearAllMocks())
@@ -140,6 +159,24 @@ describe('MeterManager — meter list', () => {
expect(screen.getByText('meter_swap')).toBeInTheDocument()
})
+ it('renders every binding timeline segment with source, channel, and half-open boundaries', async () => {
+ const meterWithBindings = {
+ ...ACTIVE_METER,
+ bindings: [
+ { uuid: 'binding-closed', source_uuid: 'source-old', source_channel_uuid: 'channel-old', started_at: '2025-01-01T00:00:00Z', ended_at: '2025-02-01T00:00:00Z' },
+ { uuid: 'binding-active', source_uuid: 'source-new', source_channel_uuid: 'channel-new', started_at: '2025-02-01T00:00:00Z', ended_at: null },
+ ],
+ }
+ mockGet.mockResolvedValue({ data: { items: [meterWithBindings], total: 1 } })
+ renderWithProviders(
)
+ const closed = await screen.findByTestId('binding-timeline-binding-closed')
+ const active = screen.getByTestId('binding-timeline-binding-active')
+ expect(closed).toHaveTextContent('source-old → channel-old')
+ expect(closed).toHaveTextContent('[1/1/2025, 00:00:00, 2/1/2025, 00:00:00) (closed)')
+ expect(active).toHaveTextContent('source-new → channel-new')
+ expect(active).toHaveTextContent('[2/1/2025, 00:00:00, open-ended) (active)')
+ })
+
it('renders "Declare New Meter" button', async () => {
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
diff --git a/frontend/src/energy/MeterManager.tsx b/frontend/src/energy/MeterManager.tsx
index 2dfa954..c88ba79 100644
--- a/frontend/src/energy/MeterManager.tsx
+++ b/frontend/src/energy/MeterManager.tsx
@@ -34,11 +34,16 @@ import {
useMeters,
useDeclareMeter,
useUpdateMeter,
+ useSources,
+ useSourceChannels,
+ useMeterBindings,
+ useCreateBinding,
+ useCloseBinding,
type MeterResponse,
type MeterReason,
} from './hooks'
import { ApiError } from '../api/client'
-import { formatLocalDate, parseBackendTimestamp } from '../utils/datetime'
+import { formatLocalDate, formatLocalDateTime, parseBackendTimestamp } from '../utils/datetime'
// ---------------------------------------------------------------------------
// Helpers
@@ -88,8 +93,15 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
const [reason, setReason] = useState
(null)
const [note, setNote] = useState('')
const [error, setError] = useState(null)
+ const [commodity, setCommodity] = useState('electricity')
+ const [sourceUuid, setSourceUuid] = useState(null)
+ const [channelUuid, setChannelUuid] = useState(null)
+ const sources = useSources()
+ const channels = useSourceChannels(sourceUuid)
const declareMutation = useDeclareMeter()
+ const compatible = (channel: { unit: string; binding_count: number; bound_meter_ids: number[] }) =>
+ ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record)[commodity ?? 'electricity'] === channel.unit && channel.binding_count === 0 && channel.bound_meter_ids.length === 0
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
@@ -114,7 +126,8 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
started_at: toLocalMidnightNaive(dateStr),
reason: reason as MeterReason,
note: note.trim() || undefined,
- commodity: 'electricity',
+ commodity: commodity ?? 'electricity',
+ ...(channelUuid ? { source_channel_uuid: channelUuid } : {}),
})
onSaved()
onClose()
@@ -166,6 +179,14 @@ function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
data-testid="meter-reason"
/>
+
+