M8-R05: recover WarmteLink ingestion and discovery automatically
This commit is contained in:
@@ -177,7 +177,6 @@ class WarmteLinkWorker:
|
|||||||
|
|
||||||
def _run(self) -> None:
|
def _run(self) -> None:
|
||||||
backoff_index = 0
|
backoff_index = 0
|
||||||
framer = TelegramFramer()
|
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
device = self._serial_factory(self.config)
|
device = self._serial_factory(self.config)
|
||||||
@@ -187,6 +186,11 @@ class WarmteLinkWorker:
|
|||||||
device.close()
|
device.close()
|
||||||
return
|
return
|
||||||
self._serial = device
|
self._serial = device
|
||||||
|
# A disconnect makes any bytes buffered from the previous
|
||||||
|
# descriptor untrustworthy. In particular, never let a
|
||||||
|
# trailing partial telegram be completed by a newly opened
|
||||||
|
# device.
|
||||||
|
framer = TelegramFramer()
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
self._expire_discoveries()
|
self._expire_discoveries()
|
||||||
chunk = device.read(1024)
|
chunk = device.read(1024)
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ vi.mock('../api/client', () => ({ default: { GET: (...a: unknown[]) => mockGet(.
|
|||||||
const source = { uuid: 's1', name: 'WarmteLink', kind: 'warmtelink_serial', enabled: true, status: 'online', config: {} }
|
const source = { uuid: 's1', name: 'WarmteLink', kind: 'warmtelink_serial', enabled: true, status: 'online', config: {} }
|
||||||
describe('SourceManager API states', () => {
|
describe('SourceManager API states', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
it('renders source list, detail and a completed discovery result', async () => { const user = userEvent.setup(); mockGet.mockImplementation((path: string) => Promise.resolve({ data: path === '/api/energy/sources' ? { items: [source] } : path.includes('channels') ? { items: [] } : source })); mockPost.mockResolvedValue({ data: { status: 'completed' } }); renderWithProviders(<SourceManager />); await user.click(await screen.findByText('WarmteLink')); expect(await screen.findByText(/No channels discovered yet/)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /Discover channels/ })); expect(await screen.findByText(/Discovery completed/)).toBeInTheDocument() })
|
it('renders source list, detail and a completed bounded discovery refresh', async () => { const user = userEvent.setup(); mockGet.mockImplementation((path: string) => Promise.resolve({ data: path === '/api/energy/sources' ? { items: [source] } : path.includes('channels') ? { items: [] } : source })); mockPost.mockResolvedValue({ data: { status: 'completed' } }); renderWithProviders(<SourceManager />); await user.click(await screen.findByText('WarmteLink')); expect(await screen.findByText(/No channels discovered yet/)).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /Refresh discovered channels/ })); expect(await screen.findByText(/Discovery completed/)).toBeInTheDocument() })
|
||||||
|
it('explains that an enabled serial source reconnects automatically', async () => { const user = userEvent.setup(); mockGet.mockImplementation((path: string) => Promise.resolve({ data: path === '/api/energy/sources' ? { items: [{ ...source, status: 'error', last_error: 'serial unavailable' }] } : path.includes('channels') ? { items: [] } : { ...source, status: 'error', last_error: 'serial unavailable' } })); renderWithProviders(<SourceManager />); await user.click(await screen.findByText('WarmteLink')); expect(await screen.findAllByText(/reconnecting automatically/i)).not.toHaveLength(0); expect(screen.getByRole('button', { name: /Refresh discovered channels/ })).toBeInTheDocument() })
|
||||||
it('renders source list empty and error states', async () => { mockGet.mockResolvedValueOnce({ data: { items: [] } }); const { unmount } = renderWithProviders(<SourceManager />); expect(await screen.findByText(/No sources configured/)).toBeInTheDocument(); unmount(); mockGet.mockRejectedValueOnce(new Error('offline')); renderWithProviders(<SourceManager />); expect(await screen.findByText(/Failed to load sources/)).toBeInTheDocument() })
|
it('renders source list empty and error states', async () => { mockGet.mockResolvedValueOnce({ data: { items: [] } }); const { unmount } = renderWithProviders(<SourceManager />); expect(await screen.findByText(/No sources configured/)).toBeInTheDocument(); unmount(); mockGet.mockRejectedValueOnce(new Error('offline')); renderWithProviders(<SourceManager />); expect(await screen.findByText(/Failed to load sources/)).toBeInTheDocument() })
|
||||||
it('contains the wide four-column source table in a scroll area', async () => { mockGet.mockResolvedValue({ data: { items: [source] } }); renderWithProviders(<SourceManager />); expect(await screen.findByTestId('sources-table-scrollarea')).toBeInTheDocument(); expect(screen.getByTestId('sources-table')).toHaveStyle({ minWidth: '640px' }) })
|
it('contains the wide four-column source table in a scroll area', async () => { mockGet.mockResolvedValue({ data: { items: [source] } }); renderWithProviders(<SourceManager />); expect(await screen.findByTestId('sources-table-scrollarea')).toBeInTheDocument(); expect(screen.getByTestId('sources-table')).toHaveStyle({ minWidth: '640px' }) })
|
||||||
it('safely deletes an unreferenced source and clears its selection', async () => { const user = userEvent.setup(); let items = [source]; mockGet.mockImplementation((path: string) => Promise.resolve({ data: path === '/api/energy/sources' ? { items } : path.includes('channels') ? { items: [] } : source })); mockDelete.mockImplementation(async () => { items = []; return { data: undefined } }); renderWithProviders(<SourceManager />); await user.click(await screen.findByText('WarmteLink')); await user.click(screen.getByRole('button', { name: 'Delete source' })); await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: 's1' } } })); expect(await screen.findByText(/No sources configured/)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Delete source' })).not.toBeInTheDocument() })
|
it('safely deletes an unreferenced source and clears its selection', async () => { const user = userEvent.setup(); let items = [source]; mockGet.mockImplementation((path: string) => Promise.resolve({ data: path === '/api/energy/sources' ? { items } : path.includes('channels') ? { items: [] } : source })); mockDelete.mockImplementation(async () => { items = []; return { data: undefined } }); renderWithProviders(<SourceManager />); await user.click(await screen.findByText('WarmteLink')); await user.click(screen.getByRole('button', { name: 'Delete source' })); await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: 's1' } } })); expect(await screen.findByText(/No sources configured/)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Delete source' })).not.toBeInTheDocument() })
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function SourceManager() {
|
|||||||
if (sources.isLoading) return <Center data-testid="sources-loading"><Loader /></Center>
|
if (sources.isLoading) return <Center data-testid="sources-loading"><Loader /></Center>
|
||||||
if (sources.isError || !sources.data) return <Alert color="red">Failed to load sources.</Alert>
|
if (sources.isError || !sources.data) return <Alert color="red">Failed to load sources.</Alert>
|
||||||
return <Stack data-testid="source-manager"><Group justify="space-between"><Text fw={600}>Sources</Text><Button onClick={() => setForm(undefined)}>New Source</Button></Group>
|
return <Stack data-testid="source-manager"><Group justify="space-between"><Text fw={600}>Sources</Text><Button onClick={() => setForm(undefined)}>New Source</Button></Group>
|
||||||
{sources.data.items.length === 0 ? <Text c="dimmed">No sources configured yet.</Text> : <ScrollArea data-testid="sources-table-scrollarea"><Table data-testid="sources-table" style={{ minWidth: 640 }}><Table.Thead><Table.Tr><Table.Th>Name</Table.Th><Table.Th>Type</Table.Th><Table.Th>Status</Table.Th><Table.Th>Last seen</Table.Th></Table.Tr></Table.Thead><Table.Tbody>{sources.data.items.map((source) => <Table.Tr key={source.uuid}><Table.Td><Button variant="subtle" onClick={() => setSelected(source.uuid)}>{source.name}</Button></Table.Td><Table.Td>{source.kind}</Table.Td><Table.Td><Badge color={source.enabled && source.status === 'online' ? 'green' : 'gray'}>{source.enabled ? source.status : 'disabled'}</Badge>{source.last_error && <Text c="red" size="xs">{source.last_error}</Text>}</Table.Td><Table.Td>{source.last_seen_at ? formatLocalDateTime(source.last_seen_at) : '—'}</Table.Td></Table.Tr>)}</Table.Tbody></Table></ScrollArea>}
|
{sources.data.items.length === 0 ? <Text c="dimmed">No sources configured yet.</Text> : <ScrollArea data-testid="sources-table-scrollarea"><Table data-testid="sources-table" style={{ minWidth: 640 }}><Table.Thead><Table.Tr><Table.Th>Name</Table.Th><Table.Th>Type</Table.Th><Table.Th>Status</Table.Th><Table.Th>Last seen</Table.Th></Table.Tr></Table.Thead><Table.Tbody>{sources.data.items.map((source) => <Table.Tr key={source.uuid}><Table.Td><Button variant="subtle" onClick={() => setSelected(source.uuid)}>{source.name}</Button></Table.Td><Table.Td>{source.kind}</Table.Td><Table.Td><Badge color={source.enabled && source.status === 'online' ? 'green' : 'gray'}>{source.enabled ? source.status : 'disabled'}</Badge>{source.enabled && source.status === 'error' && <Text c="orange" size="xs">Reconnecting automatically.</Text>}{source.last_error && <Text c="red" size="xs">{source.last_error}</Text>}</Table.Td><Table.Td>{source.last_seen_at ? formatLocalDateTime(source.last_seen_at) : '—'}</Table.Td></Table.Tr>)}</Table.Tbody></Table></ScrollArea>}
|
||||||
{selected && <SourceDetail uuid={selected} onEdit={setForm} onDeleted={() => setSelected(null)} />}{form !== null && <SourceForm source={form} onClose={() => setForm(null)} />}
|
{selected && <SourceDetail uuid={selected} onEdit={setForm} onDeleted={() => setSelected(null)} />}{form !== null && <SourceForm source={form} onClose={() => setForm(null)} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,9 @@ function SourceDetail({ uuid, onEdit, onDeleted }: { uuid: string; onEdit: (sour
|
|||||||
if (source.isLoading) return <Loader />; if (source.isError || !source.data) return <Alert color="red">Failed to load source.</Alert>
|
if (source.isLoading) return <Loader />; if (source.isError || !source.data) return <Alert color="red">Failed to load source.</Alert>
|
||||||
const detail = source.data!
|
const detail = source.data!
|
||||||
const result = discover.data?.data
|
const result = discover.data?.data
|
||||||
return <Paper withBorder p="md"><Stack><Group justify="space-between"><Text fw={600}>{detail.name}</Text><Group><Button variant="default" onClick={() => onEdit(detail)}>Edit</Button><Button loading={discover.isPending} onClick={() => discover.mutate(uuid)}>Discover channels</Button><Button color="red" variant="outline" loading={remove.isPending} onClick={deleteSource}>Delete source</Button></Group></Group>
|
return <Paper withBorder p="md"><Stack><Group justify="space-between"><Text fw={600}>{detail.name}</Text><Group><Button variant="default" onClick={() => onEdit(detail)}>Edit</Button><Button loading={discover.isPending} onClick={() => discover.mutate(uuid)}>Refresh discovered channels</Button><Button color="red" variant="outline" loading={remove.isPending} onClick={deleteSource}>Delete source</Button></Group></Group>
|
||||||
{deleteError && <Alert color="red">{deleteError}</Alert>}
|
{deleteError && <Alert color="red">{deleteError}</Alert>}
|
||||||
|
{detail.enabled && detail.status === 'error' && <Alert color="orange">The worker is reconnecting automatically. Refresh only requests a bounded status update; it does not start ingestion.</Alert>}
|
||||||
{discover.isPending && <Alert color="blue">Discovery pending…</Alert>}{discover.isError && <Alert color="red">Discovery request failed. Check the source and try again.</Alert>}
|
{discover.isPending && <Alert color="blue">Discovery pending…</Alert>}{discover.isError && <Alert color="red">Discovery request failed. Check the source and try again.</Alert>}
|
||||||
{result && <Alert color={result.status === 'error' || result.status === 'timeout' ? 'red' : 'blue'}>Discovery {result.status}: {result.detail ?? (result.status === 'completed' ? 'Channels refreshed.' : 'Waiting for discovery.')}</Alert>}
|
{result && <Alert color={result.status === 'error' || result.status === 'timeout' ? 'red' : 'blue'}>Discovery {result.status}: {result.detail ?? (result.status === 'completed' ? 'Channels refreshed.' : 'Waiting for discovery.')}</Alert>}
|
||||||
{detail.kind.includes('dsmr') && <DsmrPanel />}
|
{detail.kind.includes('dsmr') && <DsmrPanel />}
|
||||||
|
|||||||
@@ -523,12 +523,12 @@ export function useSourceProfiles() {
|
|||||||
export function useSources() {
|
export function useSources() {
|
||||||
return useQuery({ queryKey: ['energy-sources'], queryFn: async () => {
|
return useQuery({ queryKey: ['energy-sources'], queryFn: async () => {
|
||||||
const res = await apiClient.GET('/api/energy/sources'); return res.data
|
const res = await apiClient.GET('/api/energy/sources'); return res.data
|
||||||
} })
|
}, refetchInterval: 5_000 })
|
||||||
}
|
}
|
||||||
export function useSource(uuid: string | null) {
|
export function useSource(uuid: string | null) {
|
||||||
return useQuery({ queryKey: ['energy-source', uuid], enabled: !!uuid, queryFn: async () => {
|
return useQuery({ queryKey: ['energy-source', uuid], enabled: !!uuid, queryFn: async () => {
|
||||||
const res = await apiClient.GET('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid! } } }); return res.data
|
const res = await apiClient.GET('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid! } } }); return res.data
|
||||||
} })
|
}, refetchInterval: 3_000 })
|
||||||
}
|
}
|
||||||
function invalidateSourceQueries(qc: ReturnType<typeof useQueryClient>) {
|
function invalidateSourceQueries(qc: ReturnType<typeof useQueryClient>) {
|
||||||
void qc.invalidateQueries({ queryKey: ['energy-sources'] }); void qc.invalidateQueries({ queryKey: ['energy-source'] });
|
void qc.invalidateQueries({ queryKey: ['energy-sources'] }); void qc.invalidateQueries({ queryKey: ['energy-source'] });
|
||||||
@@ -540,8 +540,8 @@ export function useCreateSource() { const qc = useQueryClient(); return useMutat
|
|||||||
export function useUpdateSource() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ uuid, body }: { uuid: string; body: MeterSourcePatch }) => apiClient.PATCH('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid } }, body }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
export function useUpdateSource() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ uuid, body }: { uuid: string; body: MeterSourcePatch }) => apiClient.PATCH('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid } }, body }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
||||||
export function useDeleteSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (uuid: string) => apiClient.DELETE('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid } } }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
export function useDeleteSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (uuid: string) => apiClient.DELETE('/api/energy/sources/{source_uuid}', { params: { path: { source_uuid: uuid } } }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
||||||
export function useDiscoverSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (uuid: string) => apiClient.POST('/api/energy/sources/{source_uuid}/discover', { params: { path: { source_uuid: uuid } } }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
export function useDiscoverSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (uuid: string) => apiClient.POST('/api/energy/sources/{source_uuid}/discover', { params: { path: { source_uuid: uuid } } }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
||||||
export function useSourceChannels(uuid: string | null) { return useQuery({ queryKey: ['energy-source-channels', uuid], enabled: !!uuid, queryFn: async () => { const res = await apiClient.GET('/api/energy/sources/{source_uuid}/channels', { params: { path: { source_uuid: uuid! } } }); return res.data } }) }
|
export function useSourceChannels(uuid: string | null) { return useQuery({ queryKey: ['energy-source-channels', uuid], enabled: !!uuid, queryFn: async () => { const res = await apiClient.GET('/api/energy/sources/{source_uuid}/channels', { params: { path: { source_uuid: uuid! } } }); return res.data }, refetchInterval: 3_000 }) }
|
||||||
export function useChannelReadings(sourceUuid: string | null, channelUuid: string | null) { return useQuery({ queryKey: ['energy-channel-readings', sourceUuid, channelUuid], enabled: !!sourceUuid && !!channelUuid, queryFn: async () => { const res = await apiClient.GET('/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings', { params: { path: { source_uuid: sourceUuid!, channel_uuid: channelUuid! }, query: { limit: 60 } } }); return res.data } }) }
|
export function useChannelReadings(sourceUuid: string | null, channelUuid: string | null) { return useQuery({ queryKey: ['energy-channel-readings', sourceUuid, channelUuid], enabled: !!sourceUuid && !!channelUuid, queryFn: async () => { const res = await apiClient.GET('/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings', { params: { path: { source_uuid: sourceUuid!, channel_uuid: channelUuid! }, query: { limit: 60 } } }); return res.data }, refetchInterval: 5_000 }) }
|
||||||
export function useMeterBindings(id: number | null) { return useQuery({ queryKey: ['energy-meter-bindings', id], enabled: id != null, queryFn: async () => { const res = await apiClient.GET('/api/energy/meters/{meter_id}/bindings', { params: { path: { meter_id: id! } } }); return res.data } }) }
|
export function useMeterBindings(id: number | null) { return useQuery({ queryKey: ['energy-meter-bindings', id], enabled: id != null, queryFn: async () => { const res = await apiClient.GET('/api/energy/meters/{meter_id}/bindings', { params: { path: { meter_id: id! } } }); return res.data } }) }
|
||||||
export function useCreateBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, body }: { id: number; body: BindingCreate }) => apiClient.POST('/api/energy/meters/{meter_id}/bindings', { params: { path: { meter_id: id } }, body }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
export function useCreateBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, body }: { id: number; body: BindingCreate }) => apiClient.POST('/api/energy/meters/{meter_id}/bindings', { params: { path: { meter_id: id } }, body }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
||||||
export function useCloseBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ uuid, ended_at }: { uuid: string; ended_at: string }) => apiClient.PATCH('/api/energy/bindings/{binding_uuid}', { params: { path: { binding_uuid: uuid } }, body: { ended_at } }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
export function useCloseBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ uuid, ended_at }: { uuid: string; ended_at: string }) => apiClient.PATCH('/api/energy/bindings/{binding_uuid}', { params: { path: { binding_uuid: uuid } }, body: { ended_at } }), onSuccess: () => invalidateSourceQueries(qc) }) }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from queue import Queue
|
from queue import Empty, Queue
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
|
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
from app.models.meter_source import MeterSource
|
from app.models.meter_source import MeterSource
|
||||||
|
from app.services.warmtelink_ingest import WarmteLinkIngestor
|
||||||
from app.services.warmtelink_worker import WarmteLinkWorker, WarmteLinkWorkerManager, open_warmtelink_serial
|
from app.services.warmtelink_worker import WarmteLinkWorker, WarmteLinkWorkerManager, open_warmtelink_serial
|
||||||
|
|
||||||
|
|
||||||
@@ -156,6 +157,128 @@ def test_worker_resets_backoff_after_failures_then_a_complete_frame():
|
|||||||
assert clock.delays[:3] == [1, 2, 1]
|
assert clock.delays[:3] == [1, 2, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_discards_partial_frame_when_a_new_serial_descriptor_reconnects():
|
||||||
|
"""A new device must not complete bytes buffered from its predecessor."""
|
||||||
|
class _PartialThenDisconnect:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def read(self, _size=1):
|
||||||
|
self.calls += 1
|
||||||
|
if self.calls == 1:
|
||||||
|
return b"/stale-partial"
|
||||||
|
raise OSError("disconnected")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
class _FreshDevice:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def read(self, _size=1):
|
||||||
|
self.calls += 1
|
||||||
|
if self.calls == 1:
|
||||||
|
return b"/fresh\r\n!\r\n"
|
||||||
|
raise OSError("disconnected")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
stale, fresh = _PartialThenDisconnect(), _FreshDevice()
|
||||||
|
clock, ingestor = _Clock(), _Ingestor()
|
||||||
|
devices = iter([stale, fresh, _BrokenSerial()])
|
||||||
|
worker = WarmteLinkWorker(
|
||||||
|
3, {"path": "/dev/private"}, session_factory=lambda: None,
|
||||||
|
serial_factory=lambda _config: next(devices), ingestor=ingestor, clock=clock,
|
||||||
|
)
|
||||||
|
worker.start()
|
||||||
|
deadline = time.monotonic() + 1
|
||||||
|
while not ingestor.frames and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.005)
|
||||||
|
worker.stop()
|
||||||
|
assert worker.join(1)
|
||||||
|
assert ingestor.frames == [(3, b"/fresh\r\n!\r\n")]
|
||||||
|
assert stale.closed and fresh.closed
|
||||||
|
assert clock.delays[:2] == [1, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_recovers_from_initial_open_failure_and_ingests_without_discovery(tmp_path):
|
||||||
|
"""Startup retries independently of a manual discovery request."""
|
||||||
|
from app.integrations.p1 import dsmr_crc16
|
||||||
|
from app.models.meter_source import MeterSourceChannel
|
||||||
|
|
||||||
|
factory, engine = _session_factory(tmp_path)
|
||||||
|
received_at = datetime(2026, 8, 22, 10, 0, 30, tzinfo=UTC)
|
||||||
|
with factory() as session:
|
||||||
|
source = session.query(MeterSource).filter_by(name="one").one()
|
||||||
|
source_id = source.id
|
||||||
|
session.query(MeterSource).filter_by(name="two").one().enabled = False
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
class _QueueSerial:
|
||||||
|
def __init__(self):
|
||||||
|
self.closed = False
|
||||||
|
self.frames: Queue[bytes] = Queue()
|
||||||
|
|
||||||
|
def read(self, _size=1):
|
||||||
|
try:
|
||||||
|
return self.frames.get(timeout=0.01)
|
||||||
|
except Empty:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
def frame() -> bytes:
|
||||||
|
body = (
|
||||||
|
b"/WARMTE\r\n"
|
||||||
|
b"0-0:1.0.0(260822120000S)\r\n"
|
||||||
|
b"0-0:96.1.1(REDACTED)\r\n"
|
||||||
|
b"0-1:24.1.0(006)\r\n"
|
||||||
|
b"0-1:96.1.0(REDACTED)\r\n"
|
||||||
|
b"0-1:24.2.1(260822120000S)(5.900*m3)\r\n"
|
||||||
|
b"0-2:24.1.0(012)\r\n"
|
||||||
|
b"0-2:96.1.0(REDACTED)\r\n"
|
||||||
|
b"0-2:24.2.1(260822120000S)(0.017*GJ)\r\n"
|
||||||
|
)
|
||||||
|
payload = body + b"!"
|
||||||
|
return payload + f"{dsmr_crc16(payload):04X}".encode() + b"\r\n"
|
||||||
|
|
||||||
|
serial, clock = _QueueSerial(), _Clock()
|
||||||
|
attempts = 0
|
||||||
|
|
||||||
|
def open_after_one_failure(_config):
|
||||||
|
nonlocal attempts
|
||||||
|
attempts += 1
|
||||||
|
if attempts == 1:
|
||||||
|
raise OSError("unavailable")
|
||||||
|
return serial
|
||||||
|
|
||||||
|
worker = WarmteLinkWorker(
|
||||||
|
source_id, {"path": "/dev/fake"}, session_factory=factory,
|
||||||
|
serial_factory=open_after_one_failure, ingestor=WarmteLinkIngestor(clock=lambda: received_at), clock=clock,
|
||||||
|
)
|
||||||
|
worker.start()
|
||||||
|
serial.frames.put(frame())
|
||||||
|
deadline = time.monotonic() + 1
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
with factory() as session:
|
||||||
|
source = session.get(MeterSource, source_id)
|
||||||
|
if source is not None and source.status == "online":
|
||||||
|
assert session.query(MeterSourceChannel).filter_by(source_id=source_id).count() == 2
|
||||||
|
break
|
||||||
|
time.sleep(0.005)
|
||||||
|
else:
|
||||||
|
raise AssertionError("worker did not recover and ingest its startup frame")
|
||||||
|
worker.stop()
|
||||||
|
assert worker.join(1)
|
||||||
|
assert attempts >= 2 and clock.delays[0] == 1 and serial.closed
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def test_worker_is_non_daemon_even_when_constructed_by_a_daemon_parent():
|
def test_worker_is_non_daemon_even_when_constructed_by_a_daemon_parent():
|
||||||
result: Queue[bool] = Queue()
|
result: Queue[bool] = Queue()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user