Use cases
Call Integration Hub through the portal proxy with executeUseCase.
executeUseCase is how a block talks to external systems wired up through
Integration Hub: device control, energy data, vendor lookups, anything your
integration exposes. It's the most-used SDK primitive in a HEMS: every
device, energy, and tariff call goes through it.
The request travels Block → Customer Portal API → Integration API proxy. The portal bridges your PortalAuth token to an internal token, so your block never holds integration credentials.
executeUseCase(params)
import { executeUseCase } from '@epilot/spark-sdk'
const result = await executeUseCase({
integration_id: 'abc-123',
use_case_slug: 'list_devices',
})
if (result.success) {
console.log(result.data)
}ExecuteUseCaseParams
| Field | Type | Description |
|---|---|---|
integration_id | string (required) | The Integration Hub installation id, usually session.config.integration_id. |
use_case_slug | string | Identify the use case by slug… |
use_case_name | string | …or by name… |
use_case_id | string | …or by id. Provide exactly one of slug / name / id. |
payload | Record<string, unknown> | The request body forwarded to the integration. |
context | Record<string, unknown> | Extra context for the execution. |
ExecuteUseCaseResult
interface ExecuteUseCaseResult {
success: boolean
data?: Record<string, unknown>
entity_updated?: boolean
entity_created?: boolean
created_entity_id?: string
error?: {
code?: string
message?: string
details?: Record<string, unknown>
}
}
success: falsemeans the use case ran but the integration returned an error; inspectresult.error. A rejected promise means the call itself failed (network, timeout, not initialized).
The query pattern
Wrap each use case in a TanStack Query hook. The integration_id comes from the
block config, and queries
stay disabled until it's available (and while in mock mode). From
use-devices-query.ts:
import { executeUseCase } from '@epilot/spark-sdk'
import { useQuery } from '@tanstack/react-query'
import { unwrapProxyResponse } from '@/utils/unwrap-proxy-response'
export function useDevicesQuery() {
const { integrationId, isMockedMode } = useBlockContext()
return useQuery<Device[]>({
queryKey: deviceKeys.list(integrationId ?? ''),
queryFn: async () => {
if (!integrationId) throw new Error('No integration configured')
const raw = await executeUseCase({
integration_id: integrationId,
use_case_slug: 'list_devices',
})
const result = unwrapProxyResponse(raw)
if (!result.success) {
throw new Error(result.error?.message ?? 'Failed to load devices')
}
const data = result.data as Record<string, unknown> | undefined
return (data?.devices ?? data?.data ?? []) as Device[]
},
enabled: Boolean(integrationId) && !isMockedMode,
})
}Sending a payload looks the same. Here a daily energy read from
use-daily-energy-query.ts:
const raw = await executeUseCase({
integration_id: integrationId,
use_case_slug: 'get_daily_energy_data',
payload: { date }, // yyyy-MM-dd
})Two patterns worth copying
A HEMS standardizes two small helpers around the raw result. They aren't part
of the SDK, but they're the conventions that make executeUseCase pleasant to use,
so reach for the same shape in your block.
Unwrap the proxy envelope
When the use case has no response_mapping configured, the real payload is nested
under data.response. unwrap-proxy-response.ts flattens it so the rest of your
code sees a consistent shape:
import type { ExecuteUseCaseResult } from '@epilot/spark-sdk'
/** Unwrap the proxy envelope when no response_mapping is configured. */
export function unwrapProxyResponse(
result: ExecuteUseCaseResult,
): ExecuteUseCaseResult {
if (!result.success || !result.data) return result
const data = result.data as Record<string, unknown>
if (!data.response || typeof data.response !== 'object') return result
const originalPayload = data.payload ?? data.original_request
return {
...result,
data: {
...(data.response as Record<string, unknown>),
...(originalPayload && typeof originalPayload === 'object'
? { _payload: originalPayload }
: {}),
},
}
}Parse structured errors
Integration errors often carry an RFC-7807-style problem document. A HEMS
parses it into a typed ConnectApiError (status code, error code, trace id) so the
UI can branch on it, for example retrying a delete with force: true on a 409:
import { parseConnectApiError } from '@/utils/connect-api-error'
const result = unwrapProxyResponse(
await executeUseCase({
integration_id: integrationId,
use_case_slug: 'delete_device',
payload: { deviceId, force: false },
}),
)
if (!result.success) {
const err = parseConnectApiError(result, 'Failed to remove device.')
const isConflict = err.statusCode === 409 || err.problemType === 'conflict'
// …decide whether to retry with force, or surface err.message
}Mutations & optimistic updates
For control actions, pair executeUseCase with a mutation and optimistic cache
updates so the UI responds instantly. The skeleton from
use-execute-capability-mutation.ts:
return useMutation({
mutationFn: async ({ deviceId, capabilityName, data, useCaseSlug }) => {
// In mock mode (block editor preview) never reach the Integration Hub;
// the optimistic patch already reflects the change locally.
if (isMockedMode) return null
if (!integrationId) throw new Error('No integration configured')
const result = unwrapProxyResponse(
await executeUseCase({
integration_id: integrationId,
use_case_slug: useCaseSlug ?? 'execute_capability',
payload: { deviceId, capability: capabilityName, data },
}),
)
if (!result.success) {
throw parseConnectApiError(result, 'Capability update failed')
}
return result.data
},
onMutate: async (vars) => {
/* cancel queries, snapshot, write optimistic value */
},
onError: (_e, _vars, ctx) => {
/* roll back from snapshot */
},
onSettled: () => {
/* invalidate to reconcile with the server */
},
})Mock mode. Guarding every call with
isMockedModefrom the block config lets your block render and animate in the portal's block editor without firing real integration commands.