This is a development version of the documentation. Content may change without notice.
Voke Documentation
ESM Partners

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:

FileDescription
esm-adapter.interface.tsThe EsmAdapter TypeScript interface every adapter must implement
adapter-registry.service.tsAdapterRegistry — runtime map of orgId → EsmAdapter instance
cpi-energo.adapter.tsCPI Energo adapter (production-ready, VCP v1.1)
clever-power.adapter.tsCleverPower skeleton — pending migration to the current EsmAdapter interface
index.tsBarrel 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 and AdapterRegistry diagnostics.
  • adapterName — human-readable label (e.g. "CPI Energo").
  • supportedAssetTypes — which AssetType values (BESS, FVE, METER, …) this adapter handles.

Lifecycle

  • initialize(config) — called once when the adapter is registered for an org. Receives AdapterConfig containing organizationId and partner-specific credentials. Store the config; open any persistent connections here.
  • healthCheck() — called periodically by AdapterRegistry.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. Returns EsmSyncResult { 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:

MethodRouting-key suffix
deliverSiteSetpointcommand.site-setpoint
deliverDeviceCommandcommand.device
deliverEmergencycommand.emergency
deliverScheduleschedule.create
cancelScheduleschedule.cancel

All return DeliveryResult { delivered: boolean, siteId: string }.

Normalization (inbound: partner → VCP)

  • normalizeTelemetry(raw) — translate a raw partner payload into CanonicalTelemetry. Validate with CanonicalTelemetrySchema.parse(). Throw if the raw data is unrecoverable; return a best-effort struct otherwise.
  • normalizeAlarm(raw) — translate a raw partner alarm into AlarmPayload. Return null if 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) — serialize CanonicalTelemetry into the partner's expected wire format.
  • formatAlarm(alarm) — serialize AlarmPayload.
  • formatCommandAck(ack) — serialize CommandAckPayload.

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

  1. Create the class in apps/api/src/modules/trading/adapters/my-partner.adapter.ts, extending or implementing EsmAdapter.
  2. Implement all interface methods — TypeScript will error at compile time on any missing method.
  3. Add AdapterType.MY_PARTNER to the AdapterType enum in packages/shared/src/types/vcp.ts (and regenerate @cpi/api-types if the enum is surfaced in the Swagger schema).
  4. Register in createAdapter — add a case AdapterType.MY_PARTNER branch.
  5. Add unit tests under apps/api/src/modules/trading/adapters/__tests__/. Test normalizeTelemetry with real partner payloads and normalizeAlarm with boundary cases (null return path).
  6. Document the partner's native payload format in a JSDoc block on the class or a README.md next to the adapter file.
  7. Wire an enable path — add a UI / API flow that sets TradingPartnerConfig.adapterType = AdapterType.MY_PARTNER for an org and calls TradingPartnerConfigService.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.

On this page