/** * Energy / Modbus hooks — typed TanStack Query wrappers for /api/modbus/*. * * All write operations go through the typed apiClient (openapi-fetch), which * injects CSRF via the csrfMiddleware already wired into the client. * * Query-key conventions: * ['modbus-devices'] — device list * ['modbus-device', uuid] — single device * ['modbus-profiles'] — profile list (rarely changes) * ['modbus-latest', uuid] — latest reading per device * ['modbus-metrics', uuid] — profile metric metadata per device * ['modbus-readings', uuid, params] — time-range readings per device * * On success, mutations invalidate the device list so the UI refreshes. */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import apiClient from '../api/client' import type { components } from '../api/schema.d.ts' // --------------------------------------------------------------------------- // Re-exported types for consumers // --------------------------------------------------------------------------- export type ModbusDevice = components['schemas']['ModbusDeviceResponse'] export type ModbusDeviceCreate = components['schemas']['ModbusDeviceCreate'] export type ModbusDeviceUpdate = components['schemas']['ModbusDeviceUpdate'] export type ProfileSummary = components['schemas']['ProfileSummary'] export type ModbusTestReadResponse = components['schemas']['ModbusTestReadResponse'] export type ModbusLatestResponse = components['schemas']['ModbusLatestResponse'] export type ModbusMetricsResponse = components['schemas']['ModbusMetricsResponse'] export type MetricInfo = components['schemas']['MetricInfo'] export type ModbusReadingResponse = components['schemas']['ModbusReadingResponse'] export type ModbusReadingsResponse = components['schemas']['ModbusReadingsResponse'] // --------------------------------------------------------------------------- // Reading query params // --------------------------------------------------------------------------- export interface ReadingsQueryParams { /** * How many milliseconds of history to show. When provided the queryFn * computes end=now() and start=now()-spanMs on every invocation, so * refetchInterval-triggered re-fetches always use a rolling window. * Mutually exclusive with `start`/`end`. */ spanMs?: number start?: string | null end?: string | null /** Capped server-side; default max is 1000 to avoid pulling full history. */ limit?: number } // --------------------------------------------------------------------------- // Query: list all devices // --------------------------------------------------------------------------- export function useDevices() { return useQuery({ queryKey: ['modbus-devices'], queryFn: async () => { const res = await apiClient.GET('/api/modbus/devices') return res.data }, }) } // --------------------------------------------------------------------------- // Query: list available profiles // --------------------------------------------------------------------------- export function useProfiles() { return useQuery({ queryKey: ['modbus-profiles'], queryFn: async () => { const res = await apiClient.GET('/api/modbus/profiles') return res.data }, // Profiles are static — 5 min stale time. staleTime: 5 * 60 * 1000, }) } // --------------------------------------------------------------------------- // Mutation: create device // --------------------------------------------------------------------------- export function useCreateDevice() { const qc = useQueryClient() return useMutation({ mutationFn: (body: ModbusDeviceCreate) => apiClient.POST('/api/modbus/devices', { body }), onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }), }) } // --------------------------------------------------------------------------- // Mutation: update (PATCH) device // --------------------------------------------------------------------------- export function useUpdateDevice() { const qc = useQueryClient() return useMutation({ mutationFn: ({ uuid, body }: { uuid: string; body: ModbusDeviceUpdate }) => apiClient.PATCH('/api/modbus/devices/{uuid}', { params: { path: { uuid } }, body, }), onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }), }) } // --------------------------------------------------------------------------- // Mutation: delete device // --------------------------------------------------------------------------- export function useDeleteDevice() { const qc = useQueryClient() return useMutation({ mutationFn: (uuid: string) => apiClient.DELETE('/api/modbus/devices/{uuid}', { params: { path: { uuid } }, }), onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }), }) } // --------------------------------------------------------------------------- // Mutation: test-read device (POST /devices/{uuid}/test, does NOT persist) // --------------------------------------------------------------------------- export function useTestReadDevice() { return useMutation({ mutationFn: (uuid: string) => apiClient.POST('/api/modbus/devices/{uuid}/test', { params: { path: { uuid } }, }), }) } // --------------------------------------------------------------------------- // Query options shared by auto-refresh–capable queries // --------------------------------------------------------------------------- export interface AutoRefreshOptions { /** * When provided, TanStack Query will automatically re-fetch this query at * this interval (milliseconds). Pass `undefined` to disable auto-refresh. * Minimum enforced value: 2 000 ms. */ refetchIntervalMs?: number } // --------------------------------------------------------------------------- // Query: latest reading for a device // --------------------------------------------------------------------------- export function useLatestReading(uuid: string, options?: AutoRefreshOptions) { const refetchInterval = options?.refetchIntervalMs != null ? Math.max(2_000, options.refetchIntervalMs) : undefined return useQuery({ queryKey: ['modbus-latest', uuid], queryFn: async () => { const res = await apiClient.GET('/api/modbus/devices/{uuid}/latest', { params: { path: { uuid } }, }) return res.data }, refetchInterval, }) } // --------------------------------------------------------------------------- // Query: profile metric metadata for a device (label/unit per key) // --------------------------------------------------------------------------- export function useMetrics(uuid: string) { return useQuery({ queryKey: ['modbus-metrics', uuid], queryFn: async () => { const res = await apiClient.GET('/api/modbus/devices/{uuid}/metrics', { params: { path: { uuid } }, }) return res.data }, // Metrics are static (tied to profile version); 5 min stale time. staleTime: 5 * 60 * 1000, }) } // =========================================================================== // Energy / Pricing hooks — typed TanStack Query wrappers for /api/energy/*. // // Query-key conventions: // ['energy-contracts'] — contract list // ['energy-contract', id] — single contract with versions // ['energy-profiles'] — pricing profile structures // ['energy-prices', start, end] — price curve // ['energy-costs', start, end, limit] — cost periods // ['energy-costs-summary', start, end] — summary // ['dsmr-latest'] — DSMR latest // =========================================================================== // Re-exported energy types for consumers export type ContractResponse = components['schemas']['ContractResponse'] export type ContractDetailResponse = components['schemas']['ContractDetailResponse'] export type ContractVersionResponse = components['schemas']['ContractVersionResponse'] export type ContractListResponse = components['schemas']['ContractListResponse'] export type ContractCreate = components['schemas']['ContractCreate'] export type ContractPatch = components['schemas']['ContractPatch'] export type VersionCreate = components['schemas']['VersionCreate'] export type ProfilesResponse = components['schemas']['ProfilesResponse'] export type PricesResponse = components['schemas']['PricesResponse'] export type PricePointSchema = components['schemas']['PricePointSchema'] export type ManualTariffSchema = components['schemas']['ManualTariffSchema'] export type CostsResponse = components['schemas']['CostsResponse'] export type CostPeriodSchema = components['schemas']['CostPeriodSchema'] export type SummaryResponse = components['schemas']['SummaryResponse'] export type DsmrLatestResponse = components['schemas']['DsmrLatestResponse'] export type TibberTestResponse = components['schemas']['TibberTestResponse'] export type TibberTestPriceSchema = components['schemas']['TibberTestPriceSchema'] // --------------------------------------------------------------------------- // Query: list all energy contracts // --------------------------------------------------------------------------- export function useContracts() { return useQuery({ queryKey: ['energy-contracts'], queryFn: async () => { const res = await apiClient.GET('/api/energy/contracts') return res.data }, }) } // --------------------------------------------------------------------------- // Query: single contract with full version history // --------------------------------------------------------------------------- export function useContractDetail(id: number) { return useQuery({ queryKey: ['energy-contract', id], queryFn: async () => { const res = await apiClient.GET('/api/energy/contracts/{contract_id}', { params: { path: { contract_id: id } }, }) return res.data }, enabled: !!id, }) } // --------------------------------------------------------------------------- // Query: energy pricing profile structures // --------------------------------------------------------------------------- export function useEnergyProfiles() { return useQuery({ queryKey: ['energy-profiles'], queryFn: async () => { const res = await apiClient.GET('/api/energy/profiles') return res.data }, staleTime: 5 * 60 * 1000, }) } // --------------------------------------------------------------------------- // Mutation: create energy contract // --------------------------------------------------------------------------- export function useCreateContract() { const qc = useQueryClient() return useMutation({ mutationFn: (body: ContractCreate) => apiClient.POST('/api/energy/contracts', { body }), onSuccess: () => qc.invalidateQueries({ queryKey: ['energy-contracts'] }), }) } // --------------------------------------------------------------------------- // Mutation: update (PATCH) energy contract // --------------------------------------------------------------------------- export function useUpdateContract() { const qc = useQueryClient() return useMutation({ mutationFn: ({ id, body }: { id: number; body: ContractPatch }) => apiClient.PATCH('/api/energy/contracts/{contract_id}', { params: { path: { contract_id: id } }, body, }), onSuccess: () => qc.invalidateQueries({ queryKey: ['energy-contracts'] }), }) } // --------------------------------------------------------------------------- // Mutation: add a new version to an existing contract // --------------------------------------------------------------------------- export function useAddContractVersion() { const qc = useQueryClient() return useMutation({ mutationFn: ({ id, body }: { id: number; body: VersionCreate }) => apiClient.POST('/api/energy/contracts/{contract_id}/versions', { params: { path: { contract_id: id } }, body, }), onSuccess: (_data, vars) => { void qc.invalidateQueries({ queryKey: ['energy-contracts'] }) void qc.invalidateQueries({ queryKey: ['energy-contract', vars.id] }) }, }) } // --------------------------------------------------------------------------- // Query: energy price curve (Tibber 15-min / manual tariff) // --------------------------------------------------------------------------- export function useEnergyPrices(start?: string, end?: string) { return useQuery({ queryKey: ['energy-prices', start, end], queryFn: async () => { const res = await apiClient.GET('/api/energy/prices', { params: { query: { ...(start ? { start } : {}), ...(end ? { end } : {}), }, }, }) return res.data }, }) } // --------------------------------------------------------------------------- // Query: cost periods // --------------------------------------------------------------------------- export function useEnergyCosts(start?: string, end?: string, limit?: number) { return useQuery({ queryKey: ['energy-costs', start, end, limit], queryFn: async () => { const res = await apiClient.GET('/api/energy/costs', { params: { query: { ...(start ? { start } : {}), ...(end ? { end } : {}), ...(limit != null ? { limit } : {}), }, }, }) return res.data }, }) } // --------------------------------------------------------------------------- // Query: cost summary // --------------------------------------------------------------------------- export function useEnergyCostSummary(start?: string, end?: string) { return useQuery({ queryKey: ['energy-costs-summary', start, end], queryFn: async () => { const res = await apiClient.GET('/api/energy/costs/summary', { params: { query: { ...(start ? { start } : {}), ...(end ? { end } : {}), }, }, }) return res.data }, }) } // --------------------------------------------------------------------------- // Query: DSMR latest reading // --------------------------------------------------------------------------- export function useDsmrLatest() { return useQuery({ queryKey: ['dsmr-latest'], queryFn: async () => { const res = await apiClient.GET('/api/energy/dsmr/latest') return res.data }, }) } // --------------------------------------------------------------------------- // Mutation: recompute costs // --------------------------------------------------------------------------- export function useRecomputeCosts() { const qc = useQueryClient() return useMutation({ mutationFn: ({ start, end }: { start?: string; end?: string } = {}) => { // The schema marks start/end as required, but the backend accepts them as // optional query params; we spread only defined values. const query = { ...(start ? { start } : {}), ...(end ? { end } : {}), } as { start: string; end: string } return apiClient.POST('/api/energy/costs/recompute', { params: { query }, }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['energy-costs'] }) void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] }) }, }) } // --------------------------------------------------------------------------- // Query: time-range readings for a device (window + limit — never full-table) // --------------------------------------------------------------------------- /** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */ const READINGS_MAX_LIMIT = 1000 export function useReadings( uuid: string, params: ReadingsQueryParams, options?: AutoRefreshOptions, ) { const { spanMs, start, end, limit } = params const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT) const refetchInterval = options?.refetchIntervalMs != null ? Math.max(2_000, options.refetchIntervalMs) : undefined // When spanMs is provided, the query key uses the stable span value (not // absolute timestamps), so switching presets triggers a fresh fetch while // refetchInterval-triggered re-fetches reuse the cached key and run the // queryFn again — computing end=now() each time, giving a rolling window. const queryKey = spanMs != null ? ['modbus-readings', uuid, { spanMs, limit: effectiveLimit }] : ['modbus-readings', uuid, { start, end, limit: effectiveLimit }] return useQuery({ queryKey, queryFn: async () => { let resolvedStart = start let resolvedEnd = end if (spanMs != null) { const now = new Date() resolvedEnd = now.toISOString() resolvedStart = new Date(now.getTime() - spanMs).toISOString() } const res = await apiClient.GET('/api/modbus/devices/{uuid}/readings', { params: { path: { uuid }, query: { ...(resolvedStart ? { start: resolvedStart } : {}), ...(resolvedEnd ? { end: resolvedEnd } : {}), limit: effectiveLimit, }, }, }) return res.data }, // Enabled only when we have valid uuid; start/end may be null (full window). enabled: !!uuid, refetchInterval, }) }