Adapter Pattern
How Voke translates between partner-native protocols and VCP canonical types.
Partners each speak their own command and telemetry language — field names, units, and message shapes differ between CPI Energo, CleverPower, and future integrations. VCP is the lingua franca inside Voke. An adapter is the single class that bridges the two worlds: it translates inbound partner payloads into VCP canonical types and formats outbound VCP messages back into whatever the partner expects. Voke ships a built-in adapter for each known partner; adding support for a new partner means writing exactly one new adapter class.
Where adapters live
All adapter code lives under:
apps/api/src/modules/trading/adapters/Files currently present:
| File | Description |
|---|---|
esm-adapter.interface.ts | The EsmAdapter TypeScript interface every adapter must implement |
adapter-registry.service.ts | AdapterRegistry — runtime map of orgId → EsmAdapter instance |
cpi-energo.adapter.ts | CPI Energo adapter (production-ready, VCP v1.1) |
clever-power.adapter.ts | CleverPower skeleton — pending migration to the current EsmAdapter interface |
index.ts | Barrel export |
CleverPower has not yet been migrated to the current EsmAdapter interface. AdapterRegistry.createAdapter(AdapterType.CLEVER_POWER) throws at runtime. The file contains detailed migration notes describing what the old adapter did.
The EsmAdapter interface
Every adapter must implement the EsmAdapter interface from esm-adapter.interface.ts. The full interface is reproduced verbatim below.
export interface EsmAdapter {
readonly adapterId: string;
readonly adapterName: string;
readonly supportedAssetTypes: AssetType[];
// ── Lifecycle ──
initialize(config: AdapterConfig): Promise<void>;
healthCheck(): Promise<AdapterHealthStatus>;
shutdown(): Promise<void>;
// ── Master Data ──
syncSites(): Promise<EsmSyncResult>;
getCapability(siteId: string): Promise<SiteCapability>;
// ── Command Delivery ──
deliverSiteSetpoint(setpoint: SiteSetpointPayload, siteId: string): Promise<DeliveryResult>;
deliverDeviceCommand(command: DeviceCommandPayload): Promise<DeliveryResult>;
deliverEmergency(command: EmergencyCommandPayload, siteId: string): Promise<DeliveryResult>;
deliverSchedule(schedule: ScheduleCreatePayload): Promise<DeliveryResult>;
cancelSchedule(scheduleId: string, siteId: string): Promise<DeliveryResult>;
// ── Configuration ──
getSiteConfig(siteId: string): Promise<Record<string, unknown>>;
updateSiteConfig(siteId: string, config: Record<string, unknown>): Promise<ConfigUpdateResult>;
// ── Normalization (inbound: partner → VCP) ──
normalizeTelemetry(raw: Record<string, unknown>): CanonicalTelemetry;
normalizeAlarm(raw: Record<string, unknown>): AlarmPayload | null;
// ── Formatting (outbound: VCP → partner) ──
formatTelemetry(telemetry: CanonicalTelemetry): Record<string, unknown>;
formatAlarm(alarm: AlarmPayload): Record<string, unknown>;
formatCommandAck(ack: CommandAckPayload): Record<string, unknown>;
}All types are imported from @cpi/shared.
Method reference
Identity fields
adapterId— machine-readable identifier (e.g."cpi-energo"). Used in log output andAdapterRegistrydiagnostics.adapterName— human-readable label (e.g."CPI Energo").supportedAssetTypes— whichAssetTypevalues (BESS, FVE, METER, …) this adapter handles.
Lifecycle
initialize(config)— called once when the adapter is registered for an org. ReceivesAdapterConfigcontainingorganizationIdand partner-specific credentials. Store the config; open any persistent connections here.healthCheck()— called periodically byAdapterRegistry.listHealth(). Return{ healthy: true|false, adapterId, details: {} }. Must resolve within 10 s or the registry marks the adapter unhealthy.shutdown()— called when trading is disabled for an org, or on module teardown. Close connections and release resources.
Master data
syncSites()— pull site/plant list from the partner's ESM and upsert into local state. ReturnsEsmSyncResult{ synced, created, updated, errors }.getCapability(siteId)— return the commands that the site currently supports (SiteCapability.supportedCommands).
Command delivery — each method corresponds to a VCP routing-key suffix:
| Method | Routing-key suffix |
|---|---|
deliverSiteSetpoint | command.site-setpoint |
deliverDeviceCommand | command.device |
deliverEmergency | command.emergency |
deliverSchedule | schedule.create |
cancelSchedule | schedule.cancel |
All return DeliveryResult { delivered: boolean, siteId: string }.
Normalization (inbound: partner → VCP)
normalizeTelemetry(raw)— translate a raw partner payload intoCanonicalTelemetry. Validate withCanonicalTelemetrySchema.parse(). Throw if the raw data is unrecoverable; return a best-effort struct otherwise.normalizeAlarm(raw)— translate a raw partner alarm intoAlarmPayload. Returnnullif the payload lacks required fields (alarmId, severity, code, raisedAt, message) — the caller will drop the message and log a warning.
Formatting (outbound: VCP → partner)
formatTelemetry(telemetry)— serializeCanonicalTelemetryinto the partner's expected wire format.formatAlarm(alarm)— serializeAlarmPayload.formatCommandAck(ack)— serializeCommandAckPayload.
Registration
AdapterRegistry (an @Injectable() service provided in TradingModule) is the runtime store. It holds a Map<orgId, EsmAdapter>.
TradingModule
└─ providers: [..., AdapterRegistry, ...]
└─ exports: [..., AdapterRegistry, ...]On startup, TradingPartnerConfigService iterates all orgs that have trading enabled, calls AdapterRegistry.createAdapter(adapterType) to instantiate the correct class, then calls adapter.initialize(config) and registry.register(orgId, adapter).
When trading is disabled for an org, TradingPartnerConfigService calls registry.remove(orgId), which calls adapter.shutdown() internally.
createAdapter is a factory switch on AdapterType:
createAdapter(adapterType: AdapterType): EsmAdapter {
switch (adapterType) {
case AdapterType.CPI_ENERGO:
return new CpiEnergoAdapter();
case AdapterType.CLEVER_POWER:
// TODO: CleverPower adapter needs rewrite for VCP v2 interface
throw new Error(
'CleverPower adapter has not been migrated to the VCP v2 EsmAdapter interface yet.',
);
default:
throw new Error(`Unsupported adapter type: ${adapterType}`);
}
}To add a new adapter, extend the switch and add the corresponding AdapterType enum value in @cpi/shared/types/vcp.ts.
Writing a new adapter — checklist
- Create the class in
apps/api/src/modules/trading/adapters/my-partner.adapter.ts, extending or implementingEsmAdapter. - Implement all interface methods — TypeScript will error at compile time on any missing method.
- Add
AdapterType.MY_PARTNERto theAdapterTypeenum inpackages/shared/src/types/vcp.ts(and regenerate@cpi/api-typesif the enum is surfaced in the Swagger schema). - Register in
createAdapter— add acase AdapterType.MY_PARTNERbranch. - Add unit tests under
apps/api/src/modules/trading/adapters/__tests__/. TestnormalizeTelemetrywith real partner payloads andnormalizeAlarmwith boundary cases (null return path). - Document the partner's native payload format in a JSDoc block on the class or a
README.mdnext to the adapter file. - Wire an enable path — add a UI / API flow that sets
TradingPartnerConfig.adapterType = AdapterType.MY_PARTNERfor an org and callsTradingPartnerConfigService.enable(orgId).
Skeleton
import { Injectable, Logger } from '@nestjs/common';
import type { EsmAdapter } from './esm-adapter.interface';
import type {
AdapterConfig,
AdapterHealthStatus,
CanonicalTelemetry,
AlarmPayload,
CommandAckPayload,
DeliveryResult,
EsmSyncResult,
SiteCapability,
SiteSetpointPayload,
DeviceCommandPayload,
EmergencyCommandPayload,
ScheduleCreatePayload,
ConfigUpdateResult,
} from '@cpi/shared';
import { AssetType } from '@cpi/shared/types/enums';
@Injectable()
export class MyPartnerAdapter implements EsmAdapter {
readonly adapterId = 'my-partner';
readonly adapterName = 'My Partner';
readonly supportedAssetTypes: AssetType[] = [AssetType.BESS];
private readonly logger = new Logger(MyPartnerAdapter.name);
private config: AdapterConfig | null = null;
async initialize(config: AdapterConfig): Promise<void> {
this.config = config;
// open HTTP client, etc.
}
async healthCheck(): Promise<AdapterHealthStatus> {
return { healthy: true, adapterId: this.adapterId, details: {} };
}
async shutdown(): Promise<void> {
this.config = null;
}
async syncSites(): Promise<EsmSyncResult> {
// call partner REST API, upsert sites
return { synced: 0, created: 0, updated: 0, errors: [] };
}
async getCapability(siteId: string): Promise<SiteCapability> {
return { siteId, supportedCommands: ['SITE_SETPOINT'] };
}
async deliverSiteSetpoint(setpoint: SiteSetpointPayload, siteId: string): Promise<DeliveryResult> {
// translate setpoint → partner format, POST to partner API
return { delivered: true, siteId };
}
async deliverDeviceCommand(command: DeviceCommandPayload): Promise<DeliveryResult> {
return { delivered: true, siteId: command.deviceId };
}
async deliverEmergency(command: EmergencyCommandPayload, siteId: string): Promise<DeliveryResult> {
return { delivered: true, siteId };
}
async deliverSchedule(schedule: ScheduleCreatePayload): Promise<DeliveryResult> {
return { delivered: true, siteId: schedule.scheduleId };
}
async cancelSchedule(scheduleId: string, siteId: string): Promise<DeliveryResult> {
return { delivered: true, siteId };
}
async getSiteConfig(siteId: string): Promise<Record<string, unknown>> {
return {};
}
async updateSiteConfig(siteId: string, config: Record<string, unknown>): Promise<ConfigUpdateResult> {
return { success: true, siteId };
}
normalizeTelemetry(raw: Record<string, unknown>): CanonicalTelemetry {
// map raw partner fields → CanonicalTelemetry
throw new Error('Not implemented');
}
normalizeAlarm(raw: Record<string, unknown>): AlarmPayload | null {
// map raw partner alarm → AlarmPayload or null
return null;
}
formatTelemetry(telemetry: CanonicalTelemetry): Record<string, unknown> {
return { ...telemetry };
}
formatAlarm(alarm: AlarmPayload): Record<string, unknown> {
return { ...alarm };
}
formatCommandAck(ack: CommandAckPayload): Record<string, unknown> {
return { ...ack };
}
}Relationship to Voke ESM
The voke-esm module is not a trading adapter — it is a loopback stub that implements the real ESM HTTP contract on the partner side. It is useful for understanding what an adapter needs to call out to when it contacts the partner's ESM API. See Voke ESM sandbox for the full walkthrough.