2026-06-22 13:52:33 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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)
|
2026-06-22 14:17:59 +02:00
|
|
|
|
* ['modbus-latest', uuid] — latest reading per device
|
|
|
|
|
|
* ['modbus-metrics', uuid] — profile metric metadata per device
|
|
|
|
|
|
* ['modbus-readings', uuid, params] — time-range readings per device
|
2026-06-22 13:52:33 +02:00
|
|
|
|
*
|
|
|
|
|
|
* 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']
|
2026-06-22 14:17:59 +02:00
|
|
|
|
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 {
|
2026-06-22 19:40:18 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
2026-06-22 14:17:59 +02:00
|
|
|
|
start?: string | null
|
|
|
|
|
|
end?: string | null
|
|
|
|
|
|
/** Capped server-side; default max is 1000 to avoid pulling full history. */
|
|
|
|
|
|
limit?: number
|
|
|
|
|
|
}
|
2026-06-22 13:52:33 +02:00
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-06-24 17:27:52 +02:00
|
|
|
|
export interface DeleteDeviceParams {
|
|
|
|
|
|
uuid: string
|
|
|
|
|
|
/** When true, perform a cascade delete (removes readings + expose toggles). */
|
|
|
|
|
|
cascade?: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 13:52:33 +02:00
|
|
|
|
export function useDeleteDevice() {
|
|
|
|
|
|
const qc = useQueryClient()
|
|
|
|
|
|
return useMutation({
|
2026-06-24 17:27:52 +02:00
|
|
|
|
mutationFn: ({ uuid, cascade }: DeleteDeviceParams) =>
|
2026-06-22 13:52:33 +02:00
|
|
|
|
apiClient.DELETE('/api/modbus/devices/{uuid}', {
|
2026-06-24 17:27:52 +02:00
|
|
|
|
params: {
|
|
|
|
|
|
path: { uuid },
|
|
|
|
|
|
...(cascade ? { query: { cascade: true } } : {}),
|
|
|
|
|
|
},
|
2026-06-22 13:52:33 +02:00
|
|
|
|
}),
|
|
|
|
|
|
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 } },
|
|
|
|
|
|
}),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-06-22 14:17:59 +02:00
|
|
|
|
|
2026-06-22 19:18:29 +02:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 14:17:59 +02:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Query: latest reading for a device
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-06-22 19:18:29 +02:00
|
|
|
|
export function useLatestReading(uuid: string, options?: AutoRefreshOptions) {
|
|
|
|
|
|
const refetchInterval = options?.refetchIntervalMs != null
|
|
|
|
|
|
? Math.max(2_000, options.refetchIntervalMs)
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
2026-06-22 14:17:59 +02:00
|
|
|
|
return useQuery({
|
|
|
|
|
|
queryKey: ['modbus-latest', uuid],
|
|
|
|
|
|
queryFn: async () => {
|
|
|
|
|
|
const res = await apiClient.GET('/api/modbus/devices/{uuid}/latest', {
|
|
|
|
|
|
params: { path: { uuid } },
|
|
|
|
|
|
})
|
|
|
|
|
|
return res.data
|
|
|
|
|
|
},
|
2026-06-22 19:18:29 +02:00
|
|
|
|
refetchInterval,
|
2026-06-22 14:17:59 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-23 23:32:39 +02:00
|
|
|
|
// ===========================================================================
|
|
|
|
|
|
// 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
|
2026-06-25 16:45:11 +02:00
|
|
|
|
export type MeterResponse = components['schemas']['MeterResponse']
|
|
|
|
|
|
export type MeterListResponse = components['schemas']['MeterListResponse']
|
|
|
|
|
|
export type MeterDeclareRequest = components['schemas']['MeterDeclareRequest']
|
|
|
|
|
|
export type MeterPatchRequest = components['schemas']['MeterPatchRequest']
|
|
|
|
|
|
export type MeterReason = components['schemas']['MeterReason']
|
|
|
|
|
|
|
2026-06-23 23:32:39 +02:00
|
|
|
|
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']
|
2026-08-23 14:13:56 +02:00
|
|
|
|
export type SourceProfileResponse = components['schemas']['SourceProfileResponse']
|
|
|
|
|
|
export type MeterSourceResponse = components['schemas']['MeterSourceResponse']
|
|
|
|
|
|
export type MeterSourceCreate = components['schemas']['MeterSourceCreate']
|
|
|
|
|
|
export type MeterSourcePatch = components['schemas']['MeterSourcePatch']
|
|
|
|
|
|
export type MeterSourceChannelResponse = components['schemas']['MeterSourceChannelResponse']
|
|
|
|
|
|
export type BindingResponse = components['schemas']['BindingResponse']
|
|
|
|
|
|
export type BindingCreate = components['schemas']['BindingCreate']
|
2026-06-23 23:32:39 +02:00
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-06-24 11:02:42 +02:00
|
|
|
|
export function useDsmrLatest(options?: AutoRefreshOptions) {
|
|
|
|
|
|
const refetchInterval = options?.refetchIntervalMs != null
|
|
|
|
|
|
? Math.max(2_000, options.refetchIntervalMs)
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
2026-06-23 23:32:39 +02:00
|
|
|
|
return useQuery({
|
|
|
|
|
|
queryKey: ['dsmr-latest'],
|
|
|
|
|
|
queryFn: async () => {
|
|
|
|
|
|
const res = await apiClient.GET('/api/energy/dsmr/latest')
|
|
|
|
|
|
return res.data
|
|
|
|
|
|
},
|
2026-06-24 11:02:42 +02:00
|
|
|
|
refetchInterval,
|
2026-06-23 23:32:39 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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'] })
|
2026-06-25 16:45:11 +02:00
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ===========================================================================
|
|
|
|
|
|
// Meter hooks — typed TanStack Query wrappers for /api/energy/meters.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Query-key conventions:
|
|
|
|
|
|
// ['energy-meters'] — meter list
|
|
|
|
|
|
// ===========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Query: list all meter epochs
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
export function useMeters() {
|
|
|
|
|
|
return useQuery({
|
|
|
|
|
|
queryKey: ['energy-meters'],
|
|
|
|
|
|
queryFn: async () => {
|
|
|
|
|
|
const res = await apiClient.GET('/api/energy/meters')
|
|
|
|
|
|
return res.data
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Mutation: declare a new meter epoch (swap / home move / initial)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
export function useDeclareMeter() {
|
|
|
|
|
|
const qc = useQueryClient()
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: (body: MeterDeclareRequest) =>
|
|
|
|
|
|
apiClient.POST('/api/energy/meters', { body }),
|
2026-08-23 14:13:56 +02:00
|
|
|
|
onSuccess: (_data, variables) => {
|
2026-06-25 16:45:11 +02:00
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
|
2026-08-23 14:13:56 +02:00
|
|
|
|
if (variables.source_channel_uuid) {
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-source-channels'] })
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-meter-bindings'] })
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-sources'] })
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['expose-catalog'] })
|
|
|
|
|
|
}
|
2026-06-25 16:45:11 +02:00
|
|
|
|
// Invalidate cost-related queries: a new meter may trigger recompute server-side.
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Mutation: update (PATCH) a meter epoch (label / note / started_at)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
export function useUpdateMeter() {
|
|
|
|
|
|
const qc = useQueryClient()
|
|
|
|
|
|
return useMutation({
|
|
|
|
|
|
mutationFn: ({ id, body }: { id: number; body: MeterPatchRequest }) =>
|
|
|
|
|
|
apiClient.PATCH('/api/energy/meters/{meter_id}', {
|
|
|
|
|
|
params: { path: { meter_id: id } },
|
|
|
|
|
|
body,
|
|
|
|
|
|
}),
|
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
|
|
|
|
|
|
// Retroactive started_at correction triggers recompute server-side.
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
|
2026-06-23 23:32:39 +02:00
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-23 14:13:56 +02:00
|
|
|
|
// Source → channel → binding hooks. These deliberately use the generated
|
|
|
|
|
|
// OpenAPI types; UI suggestions remain just suggestions until a user binds one.
|
|
|
|
|
|
export function useSourceProfiles() {
|
|
|
|
|
|
return useQuery({ queryKey: ['energy-source-profiles'], queryFn: async () => {
|
|
|
|
|
|
const res = await apiClient.GET('/api/energy/source-profiles'); return res.data
|
|
|
|
|
|
}, staleTime: 5 * 60 * 1000 })
|
|
|
|
|
|
}
|
|
|
|
|
|
export function useSources() {
|
|
|
|
|
|
return useQuery({ queryKey: ['energy-sources'], queryFn: async () => {
|
|
|
|
|
|
const res = await apiClient.GET('/api/energy/sources'); return res.data
|
|
|
|
|
|
} })
|
|
|
|
|
|
}
|
|
|
|
|
|
export function useSource(uuid: string | null) {
|
|
|
|
|
|
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
|
|
|
|
|
|
} })
|
|
|
|
|
|
}
|
|
|
|
|
|
function invalidateSourceQueries(qc: ReturnType<typeof useQueryClient>) {
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-sources'] }); void qc.invalidateQueries({ queryKey: ['energy-source'] });
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-source-channels'] }); void qc.invalidateQueries({ queryKey: ['energy-meters'] });
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['energy-channel-readings'] }); void qc.invalidateQueries({ queryKey: ['energy-meter-bindings'] })
|
|
|
|
|
|
void qc.invalidateQueries({ queryKey: ['expose-catalog'] })
|
|
|
|
|
|
}
|
|
|
|
|
|
export function useCreateSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: MeterSourceCreate) => apiClient.POST('/api/energy/sources', { 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 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 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 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 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) }) }
|
|
|
|
|
|
|
2026-06-22 14:17:59 +02:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
2026-06-22 19:18:29 +02:00
|
|
|
|
export function useReadings(
|
|
|
|
|
|
uuid: string,
|
|
|
|
|
|
params: ReadingsQueryParams,
|
|
|
|
|
|
options?: AutoRefreshOptions,
|
|
|
|
|
|
) {
|
2026-06-22 19:40:18 +02:00
|
|
|
|
const { spanMs, start, end, limit } = params
|
2026-06-22 14:17:59 +02:00
|
|
|
|
const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT)
|
|
|
|
|
|
|
2026-06-22 19:18:29 +02:00
|
|
|
|
const refetchInterval = options?.refetchIntervalMs != null
|
|
|
|
|
|
? Math.max(2_000, options.refetchIntervalMs)
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
2026-06-22 19:40:18 +02:00
|
|
|
|
// 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 }]
|
|
|
|
|
|
|
2026-06-22 14:17:59 +02:00
|
|
|
|
return useQuery({
|
2026-06-22 19:40:18 +02:00
|
|
|
|
queryKey,
|
2026-06-22 14:17:59 +02:00
|
|
|
|
queryFn: async () => {
|
2026-06-22 19:40:18 +02:00
|
|
|
|
let resolvedStart = start
|
|
|
|
|
|
let resolvedEnd = end
|
|
|
|
|
|
|
|
|
|
|
|
if (spanMs != null) {
|
|
|
|
|
|
const now = new Date()
|
|
|
|
|
|
resolvedEnd = now.toISOString()
|
|
|
|
|
|
resolvedStart = new Date(now.getTime() - spanMs).toISOString()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-22 14:17:59 +02:00
|
|
|
|
const res = await apiClient.GET('/api/modbus/devices/{uuid}/readings', {
|
|
|
|
|
|
params: {
|
|
|
|
|
|
path: { uuid },
|
|
|
|
|
|
query: {
|
2026-06-22 19:40:18 +02:00
|
|
|
|
...(resolvedStart ? { start: resolvedStart } : {}),
|
|
|
|
|
|
...(resolvedEnd ? { end: resolvedEnd } : {}),
|
2026-06-22 14:17:59 +02:00
|
|
|
|
limit: effectiveLimit,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
return res.data
|
|
|
|
|
|
},
|
|
|
|
|
|
// Enabled only when we have valid uuid; start/end may be null (full window).
|
|
|
|
|
|
enabled: !!uuid,
|
2026-06-22 19:18:29 +02:00
|
|
|
|
refetchInterval,
|
2026-06-22 14:17:59 +02:00
|
|
|
|
})
|
|
|
|
|
|
}
|