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

598 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 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']
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']
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']
export type BindingTransferRequest = components['schemas']['BindingTransferRequest']
export type MeterCloseRequest = components['schemas']['MeterCloseRequest']
// ---------------------------------------------------------------------------
// 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'] })
},
})
}
// ===========================================================================
// 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 }),
// A meter_swap can hand a binding over even when the optional channel was
// omitted from this request, so every lifecycle write shares this boundary.
onSuccess: () => invalidateLifecycleQueries(qc),
})
}
// ---------------------------------------------------------------------------
// 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: () => invalidateLifecycleQueries(qc),
})
}
// 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
}, refetchInterval: 5_000 })
}
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
}, refetchInterval: 3_000 })
}
function invalidateLifecycleQueries(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'] })
void qc.invalidateQueries({ queryKey: ['energy-costs'] }); void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
void qc.invalidateQueries({ queryKey: ['meter-costs', 'thermal'] })
void qc.invalidateQueries({ queryKey: ['meter-cost-summary', 'thermal'] })
}
export function useCreateSource() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: MeterSourceCreate) => apiClient.POST('/api/energy/sources', { body }), onSuccess: () => invalidateLifecycleQueries(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: () => invalidateLifecycleQueries(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: () => invalidateLifecycleQueries(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: () => invalidateLifecycleQueries(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 }, 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 }, 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 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: () => invalidateLifecycleQueries(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: () => invalidateLifecycleQueries(qc) }) }
export function useCloseMeter() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, ended_at }: { id: number; ended_at: string }) => apiClient.POST('/api/energy/meters/{meter_id}/close', { params: { path: { meter_id: id } }, body: { ended_at } satisfies MeterCloseRequest }), onSuccess: () => invalidateLifecycleQueries(qc) }) }
export function useTransferBinding() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, body }: { id: number; body: BindingTransferRequest }) => apiClient.POST('/api/energy/meters/{meter_id}/bindings/transfer', { params: { path: { meter_id: id } }, body }), onSuccess: () => invalidateLifecycleQueries(qc) }) }
// ---------------------------------------------------------------------------
// 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,
})
}