Files
home-automation/frontend/src/energy/hooks.ts
T

493 lines
18 KiB
TypeScript
Raw Normal View History

/**
* 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 interface DeleteDeviceParams {
uuid: string
/** When true, perform a cascade delete (removes readings + expose toggles). */
cascade?: boolean
}
export function useDeleteDevice() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ uuid, cascade }: DeleteDeviceParams) =>
apiClient.DELETE('/api/modbus/devices/{uuid}', {
params: {
path: { uuid },
...(cascade ? { query: { cascade: true } } : {}),
},
}),
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-refreshcapable 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(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,
})
}
// ---------------------------------------------------------------------------
// 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,
})
}