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

Commands

Publish VCP commands to Voke plants and track ACK + execution lifecycle via AMQP.

Overview

ESM partners send commands by publishing a VCP envelope to the vcp exchange with a routing key of the form {slug}.command.{subtype}. Voke's per-org consumer picks messages up from vcp.{slug}.command, validates the envelope and payload via VcpCommandListener, and dispatches the resulting action to the target plant over MQTT. Voke then publishes the lifecycle back to the partner on two queues: an immediate acknowledgement on vcp.{slug}.event.status (routing key {slug}.event.command.ack) and an execution status update on vcp.{slug}.event.execution (routing key {slug}.event.execution) once the plant reports back.


Command types

Site setpoint — {slug}.command.site-setpoint

Adjusts the plant-level power or energy setpoint for a given time window.

interface SiteSetpointPayload {
  type: 'POWER' | 'ENERGY';
  targetValueKw?: number;      // Required when type = POWER
  targetValueKwh?: number;     // Required when type = ENERGY
  intervalMinutes?: number;    // Required when type = ENERGY
  direction: 'IMPORT' | 'EXPORT';
  includeConsumption: boolean;
  priority: 'NORMAL' | 'HIGH' | 'EMERGENCY';  // SchedulePriority
  validFrom: string;          // ISO 8601 UTC — earliest activation time
  validUntil?: string;        // ISO 8601 UTCexpiry; omit for indefinite
}
FieldRequiredDescription
typeYesPOWER dispatches a power-tracking setpoint; ENERGY dispatches an energy-quantum setpoint.
targetValueKwConditionalTarget in kW. Required when type = POWER.
targetValueKwhConditionalTarget in kWh. Required when type = ENERGY.
intervalMinutesConditionalRequired when type = ENERGY; optional for POWER.
directionYesWhether the target applies to IMPORT or EXPORT.
includeConsumptionYesWhether local consumption is included in site-level target calculation.
priorityYesNORMAL follows the schedule stack; HIGH pre-empts lower-priority plans; EMERGENCY bypasses scheduling logic entirely.
validFromYesEarliest time Voke may activate the setpoint.
validUntilNoSetpoint expires at this time even if not explicitly cancelled.

Device command — {slug}.command.device

Targets one or more sub-devices (BESS, FVE, etc.) by operator-assigned externalId. VCP v1.1 uses a batch payload; wrap a single command in a one-item commands array. Voke validates each command against the device template's actions[] list before forwarding to the PLC.

interface BatchDeviceCommandPayload {
  commands: DeviceCommand[]; // 1-32 commands
}

interface DeviceCommand {
  deviceId: string;           // Sub-device externalId (e.g. 'B1', 'S2')
  assetType: AssetType;       // Asset class of the target device
  command: DeviceCommandType; // The action to execute
  params?: DeviceCommandParams;
}

interface DeviceCommandParams {
  powerKw?: number;           // Power level for charge/discharge commands (kW)
  percent?: number;           // Percent reduction for FVE curtailment commands
  respectLimits?: boolean;    // Whether to honour configured SOC and power limits
}

AssetType and DeviceCommandType values:

enum AssetType {
  BESS       = 'BESS',
  FVE        = 'FVE',
  METER      = 'METER',
  HEAT_PUMP  = 'HEAT_PUMP',
  EV_CHARGER = 'EV_CHARGER',
  THERMOSTAT = 'THERMOSTAT',
  INVERTER   = 'INVERTER',
  GENERIC    = 'GENERIC',
}

enum DeviceCommandType {
  FVE_PRODUCE_MAX        = 'FVE_PRODUCE_MAX',
  FVE_REDUCE_PERCENT     = 'FVE_REDUCE_PERCENT',
  FVE_REDUCE_POWER       = 'FVE_REDUCE_POWER',
  FVE_STOP               = 'FVE_STOP',
  BESS_CHARGE            = 'BESS_CHARGE',
  BESS_DISCHARGE         = 'BESS_DISCHARGE',
  BESS_STOP              = 'BESS_STOP',
  BESS_CHARGE_ONLY       = 'BESS_CHARGE_ONLY',
  BESS_DISCHARGE_ONLY    = 'BESS_DISCHARGE_ONLY',
  BESS_CONTINUOUS_CHARGE = 'BESS_CONTINUOUS_CHARGE',
}

deviceId in this payload maps to the sub-device's externalId on the Voke plant — a short, operator-assigned string such as B1 or M2. It does not use the Voke UUID. All other command types address the whole site through the envelope's siteId.


Emergency command — {slug}.command.emergency

Instructs all controllable assets to stop or hold immediately. Priority is highest — bypasses the dispatch schedule.

interface EmergencyCommandPayload {
  type: 'STOP' | 'HOLD';  // STOP shuts assets down; HOLD freezes current output
  reason?: string;          // Human-readable reason (max 500 chars), logged in CommandLog
}
FieldRequiredDescription
typeYesSTOP commands all assets to deactivate. HOLD freezes them at their current output level.
reasonNoLogged to VcpCommandLog for operator visibility.

Operating mode — {slug}.command.mode

Switches the site's control strategy.

interface OperatingModePayload {
  mode: OperatingMode;  // Target operating mode
  reason?: string;       // Human-readable reason (max 500 chars)
  validUntil?: string;    // Optional override expiry
}

enum OperatingMode {
  STANDARD           = 'STANDARD',
  ZERO_EXPORT        = 'ZERO_EXPORT',
  MAX_EXPORT         = 'MAX_EXPORT',
  PEAK_SHAVING       = 'PEAK_SHAVING',
  LOCAL_OPTIMIZATION = 'LOCAL_OPTIMIZATION',
  GRID_TARGET        = 'GRID_TARGET',
  LDS_SUPPORT        = 'LDS_SUPPORT',
}
FieldRequiredDescription
modeYesTarget control strategy for the site.
reasonNoLogged for auditing.
validUntilNoOptional expiry for the override.

Routing and envelope

All four command types share the same VCP envelope structure. The routing key suffix determines which payload schema Voke validates.

{
  "version": "1.1",
  "messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "correlationId": "batch-2026-04-19-01",
  "timestamp": "2026-04-19T14:00:00.000Z",
  "source": "cpi-energo",
  "siteId": "PLANT-42",
  "payload": {
    "type": "POWER",
    "targetValueKw": 50,
    "direction": "EXPORT",
    "includeConsumption": true,
    "priority": "HIGH",
    "validFrom": "2026-04-19T14:00:00.000Z",
    "validUntil": "2026-04-19T14:15:00.000Z"
  }
}

Using the publish-command.ts example helper:

import { publishCommand } from './examples/esm/publish-command';

await publishCommand(creds, {
  commandType: 'site-setpoint',         // routing key suffix
  siteId: 'PLANT-42',                   // plant's externalPlantId
  source: 'cpi-energo',                 // your partner identifier
  correlationId: 'batch-2026-04-19-01', // echoed back in event.status
  payload: {
    type: 'POWER',
    targetValueKw: 50,
    direction: 'EXPORT',
    includeConsumption: true,
    priority: 'HIGH',
    validFrom: '2026-04-19T14:00:00.000Z',
    validUntil: '2026-04-19T14:15:00.000Z',
  },
});

The full routing key published to the vcp exchange becomes {orgSlug}.command.site-setpoint. Voke's consumer is bound to {slug}.command.# and will receive all subtypes.

Routing key suffixes by command type:

Command typeRouting key suffix
Site setpointcommand.site-setpoint
Device commandcommand.device
Emergencycommand.emergency
Operating modecommand.mode

command.device, command.mode, and schedule.* envelopes must be HMAC-signed with the partner key's signingKey. Site setpoints are scope-gated by vcp:write:setpoint but do not currently require HMAC. See VCP message integrity.


ACK / status lifecycle

After Voke consumes a command, the following sequence occurs:

  1. Envelope validation. Voke parses the raw message and validates the VcpMessageEnvelopeSchema. If the envelope is malformed (e.g. missing version, unparseable JSON), the message is nacked with requeue = false and dead-lettered. No ACK is published; the partner should deduplicate on messageId and not resend automatically.

  2. Payload validation. Voke validates the payload against the command-type schema (e.g. SiteSetpointPayloadSchema). On failure, Voke publishes a REJECTED ACK with rejectionCode: 'INVALID_PAYLOAD' to vcp.{slug}.event.status and the command is logged.

  3. Immediate ACK on vcp.{slug}.event.status (routing key {slug}.event.command.ack):

interface CommandAckPayload {
  status: 'ACCEPTED' | 'REJECTED' | 'PARTIAL' | 'QUEUED'; // Outcome of command receipt
  commandType: string;                          // Echoes the command type
  message?: string;                             // Human-readable explanation
  rejectionCode?: RejectionCode;                // Structured reason on rejection
  results?: CommandResult[];                    // Required when status = PARTIAL
}

The correlationId from the original envelope is echoed in the outbound VCP envelope wrapping this payload — use it to tie the ACK to your outbound command.

  1. Execution status on vcp.{slug}.event.execution (routing key {slug}.event.execution) once the plant responds over MQTT:
interface CommandStatusPayload {
  commandType: string;
  status: 'EXECUTING' | 'COMPLETED' | 'DEVIATED' | 'FAILED';
  actualValueKw?: number;  // Observed output
  targetValueKw?: number;  // Requested value
  deviationKw?: number;    // Difference between actual and target
  reason?: string;         // Reason for deviation or failure
}

Again, the wrapping envelope carries the original correlationId.


Target sub-device addressing

Device command items require setting deviceId to the sub-device's externalId. Before forwarding to MQTT, Voke:

  1. Looks up the sub-device by externalId on the target plant.
  2. Verifies the command value is listed in the matched device template's actions[].
  3. If either check fails, publishes a REJECTED ACK.

Validation failures use these rejection codes:

enum RejectionCode {
  CONSTRAINT_VIOLATION     = 'CONSTRAINT_VIOLATION',   // setpoint exceeds site constraints
  INVALID_COMMAND          = 'INVALID_COMMAND',         // command not in template actions
  INVALID_PAYLOAD          = 'INVALID_PAYLOAD',         // envelope / schema parse failure
  DEVICE_OFFLINE           = 'DEVICE_OFFLINE',          // sub-device not reachable
  DEVICE_FAULT             = 'DEVICE_FAULT',            // sub-device in fault state
  UNSUPPORTED_FOR_TOPOLOGY = 'UNSUPPORTED_FOR_TOPOLOGY',// command unsupported at this site
}

Failure modes

ScenarioWhat Voke doesWhat the partner sees
Malformed envelopenack, no requeue, no ACK publishedSilence — dedupe on messageId, do not auto-retry
Payload schema invalidPublishes REJECTED with INVALID_PAYLOAD to event.statusACK on event.status with status: 'REJECTED'
deviceId not found on plantPublishes REJECTED with INVALID_COMMANDACK on event.status
Command not in device template actions[]Publishes REJECTED with INVALID_COMMANDACK on event.status
Plant offline at dispatch timeCommand logged as pending in CommandLogExecution status fires later on event.execution when plant reconnects and confirms
Plant returns fault on MQTTPublishes FAILED to event.executionStatus on event.execution with status: 'FAILED'

On this page