Voke ESM Sandbox
An in-container stub ESM for exercising the full Voke trading flow without external infrastructure.
Voke ESM is a built-in testing sandbox for dev/staging environments without a real partner.
Production partners obtain credentials via /orgs/<orgId>/settings/connections — see the
Quick start. This page is for super-admins exercising the trading flow
against fake plants.
Voke ESM is a loopback stub baked into the Voke API container that implements the real ESM HTTP contract. It lets you exercise Voke's outbound HTTP reverse-channel to a partner's ESM without any external infrastructure — no CPI Energo test environment, no VPN, no shared secrets with a third party. The module lives at apps/api/src/modules/voke-esm/ and exposes endpoints under /api/v1/voke-esm.
Enable flow (superadmin)
- Sign in as a SuperAdmin account.
- Navigate to
/platform/voke-esmin the admin SPA. - Pick an organization from the dropdown and click Enable.
- Voke generates ESM OAuth2 client credentials (
clientId,clientSecret,esmApiUrl) and writes them encrypted into the org'sTradingPartnerConfig. - One default fake plant is auto-created for that org.
- The credentials are shown once in the UI dialog. Copy them immediately.
What these credentials do and do not do
clientId+clientSecretauthorize Voke to call the Voke ESM's HTTP API (thePOST /voke-esm/trading/token→GET /voke-esm/trading/plant/list→POST /voke-esm/trading/plant/config/updateflow).- They are not AMQP credentials. To receive VCP messages over AMQP, you still need a separately-created API key with the
vcp:connectscope. See API keys and auth. - If the credentials are lost, disable and re-enable Voke ESM for the org to rotate them.
Fake plant CRUD
Each fake plant is a row in the voke_esm_plants table. The admin endpoints all require SuperAdmin authentication and live at /api/v1/voke-esm/admin/plants.
| Action | Method + Path |
|---|---|
| List all plants | GET /api/v1/voke-esm/admin/plants |
| Get a single plant | GET /api/v1/voke-esm/admin/plants/:id |
| Create a plant | POST /api/v1/voke-esm/admin/plants |
| Update a plant | PATCH /api/v1/voke-esm/admin/plants/:id |
| Delete a plant | DELETE /api/v1/voke-esm/admin/plants/:id |
Create request body
{
"clientId": "acme-client-id",
"externalPlantId": "PLANT-001",
"name": "Acme Site A",
"description": "Battery storage + PV",
"fvePeakPowerKwp": 120.0,
"capacityKwh": 200.0,
"configEnabled": true,
"timeConfigs": [
{ "from": "00:00", "to": "07:00", "pBatMax": 10, "pBatMin": -10, "socMax": 95, "socMin": 20 },
{ "from": "07:00", "to": "23:59", "pBatMax": 50, "pBatMin": -50, "socMax": 90, "socMin": 30 }
]
}clientId must match the clientId returned when you enabled Voke ESM for the org — this is how the Voke ESM scopes plants to a trading partner.
Plant entity shape
{
id: string; // UUID
clientId: string; // links plant to the org's ESM credentials
externalPlantId: string; // unique per clientId; must match plants.external_plant_id on Voke side
name: string;
description: string | null;
fvePeakPowerKwp: number;
capacityKwh: number;
configEnabled: boolean;
plantWithLoad: boolean; // whether the site has a load measurement
esiState: string; // default "PLC_READY"; reflects ESI contract state
timeConfigs: TimeConfigBlock[];
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
}Time config block shape
{
from: string; // "HH:MM", start of time window (inclusive)
to: string; // "HH:MM", end of time window (inclusive)
pBatMax: number; // maximum battery charge power (kW, positive)
pBatMin: number; // minimum battery discharge power (kW, negative)
socMax: number; // maximum state of charge (%)
socMin: number; // minimum state of charge (%)
}Time config blocks are stored as a JSONB array (time_configs column) and are returned verbatim by GET /voke-esm/trading/plant/config.
Trading endpoints (what Voke calls)
These endpoints implement the ESM HTTP contract that EsmClientService calls. They are @Public() (bypass Voke JWT) because they use their own OAuth2 client-credentials flow, but they are also protected by LoopbackOnlyGuard: requests from non-loopback peer IPs are rejected. They are rate-limited to 10 requests per minute.
The base URL Voke uses is controlled by the VOKE_ESM_INTERNAL_URL env var (default: http://localhost:4410/api/v1/voke-esm).
In production, VOKE_ESM_ENABLED=true must be set when the module is registered, and VOKE_ESM_INTERNAL_URL must not start with http://localhost; use a loopback IP such as http://127.0.0.1:4410/api/v1/voke-esm instead.
POST /api/v1/voke-esm/trading/token
OAuth2 client-credentials token exchange.
POST /api/v1/voke-esm/trading/token
Content-Type: application/json
{ "clientId": "...", "clientSecret": "..." }Returns:
{
"accessToken": "<JWT signed with iss: 'voke-esm'>",
"tokenType": "Bearer",
"expiresIn": 3600
}The token is signed with the same JWT_SECRET used by the Voke API.
GET /api/v1/voke-esm/trading/plant/list
Returns all fake plants belonging to the authenticated client.
GET /api/v1/voke-esm/trading/plant/list
Authorization: Bearer <accessToken>Response includes plantId, name, descr, fvePeakPowerKwp, capacityKwh, esiState, configEnabled, plantWithLoad, and an empty meters array.
GET /api/v1/voke-esm/trading/plant/config
Returns the current time-config schedule for a plant.
GET /api/v1/voke-esm/trading/plant/config?plantId=PLANT-001
Authorization: Bearer <accessToken>Response:
{
"plantId": "PLANT-001",
"version": 1,
"configEnabled": true,
"esiState": "PLC_READY",
"timeConfigs": [...]
}POST /api/v1/voke-esm/trading/plant/config/update
Push a new time-config schedule to the Voke ESM. Supports validateOnly: true for a dry-run.
{
"plantId": "PLANT-001",
"configUpdateReqCode": "req-abc123",
"validateOnly": false,
"timeConfigs": [
{ "from": "00:00", "to": "23:59", "pBatMax": 50, "pBatMin": -50, "socMax": 90, "socMin": 20 }
]
}Responds with resultCode: "applied_ok" (or "cfg_accepted" for validate-only).
Linking a Voke plant to a fake plant
Set plants.external_plant_id on a Voke plant to match the fake plant's externalPlantId. This single field is the wire between the real plant and the Voke ESM's trading flow. Once linked:
- Voke's trading sync calls
GET /voke-esm/trading/plant/config?plantId=PLANT-001and stores the time configs locally. - Config updates from Voke are delivered to
POST /voke-esm/trading/plant/config/update.
Enable and disable endpoints
| Action | Method + Path |
|---|---|
| Enable for org | POST /api/v1/voke-esm/admin/enable?orgId=<uuid> |
| Disable for org | POST /api/v1/voke-esm/admin/disable?orgId=<uuid> |
| Check status | GET /api/v1/voke-esm/admin/status?orgId=<uuid> |
Full end-to-end smoke test
- Enable Voke ESM for org
acmevia the/platform/voke-esmadmin page. - Note the auto-created fake plant's
externalPlantId(visible in the plant list). - Create or update a Voke plant with
externalPlantIdset to the same value. - Start an AMQP consumer for
vcp.acme.event.telemetryfollowing the ESM quick-start. - Trigger a telemetry snapshot from a PLC (local dev) or push a test command via the admin UI.
- Observe the
event.telemetrymessage arriving in your AMQP consumer. - Verify Voke's config-update path: trigger a schedule change and confirm
POST /voke-esm/trading/plant/config/updateis called (check API logs:Voke ESM: applied config for PLANT-001).
Cross-links
- ESM quick-start — AMQP consumer setup
- Adapter pattern — what the adapter calls on the partner HTTP side
- PLC quick-start — generating live telemetry from a controller (see PLC section, coming in a future task)