OCPP WS IOocpp-ws-io
Protocol Proxy

Protocol Proxy

Transport-agnostic OCPP version translation proxy — bridge legacy 1.6 chargers to modern 2.1 central systems with zero firmware changes.

Protocol Proxy

The protocol proxy (ocpp-ws-io/proxy) translates OCPP messages between any two protocol versions — 1.6, 2.0.1, and 2.1 — in both directions. It sits between your charging station (EVSE) and central system (CSMS), rewriting message payloads, action names, and data types on the fly.

Now part of ocpp-ws-io. The proxy ships in ocpp-ws-io from 3.1.0, as ocpp-ws-io/proxy, ocpp-ws-io/proxy/presets and ocpp-ws-io/proxy/adapters. Until 3.1.0 is released it is on npm's beta tag, so install ocpp-ws-io@beta. The separate ocpp-protocol-proxy package is deprecated: its last release (0.3.0) only re-exports this one. See Moving from ocpp-protocol-proxy.

The proxy is transport-agnostic. The core translation engine has zero dependency on WebSockets, HTTP, or Node.js. You bring the transport — we handle the protocol semantics.


How It Works

The proxy intercepts every OCPP message and runs it through a translation pipeline:

  1. Pre-middleware — inspect or mutate the raw message before translation
  2. Translation engine — rewrites action names, payload fields, data types, and error codes using your registered mappings
  3. Session store — maintains stateful correlation (e.g., mapping 1.6 integer transactionId to 2.1 UUID strings)
  4. Post-middleware — inspect or mutate the translated message before forwarding

Installation

bash npm install ocpp-ws-io@beta

The proxy is part of ocpp-ws-io from 3.1.0, which is on npm's beta tag until it is released. Its WebSocket adapter uses ocpp-ws-io's own server and client, so there is nothing else to install.

Moving from ocpp-protocol-proxy

npm uninstall ocpp-protocol-proxy
npm install ocpp-ws-io@beta
BeforeNow
from "ocpp-protocol-proxy"from "ocpp-ws-io/proxy"
from "ocpp-protocol-proxy/presets"from "ocpp-ws-io/proxy/presets"
from "ocpp-protocol-proxy/adapters"from "ocpp-ws-io/proxy/adapters"

The API is the same, with stricter types: a translation map is keyed by protocol:Action, so a misspelt key or a wrong field is a compile error (see Writing a Translation Mapper), and the session store holds ProxyValues. Two presets changed: remote start and stop from a 2.x CSMS (RequestStartTransaction, RequestStopTransaction) are now translated, and the firmware preset reads only 2.x fields. A response mapper for a charger's answer to a CSMS call is keyed by the charger's protocol and the action it was sent ("ocpp1.6:RemoteStartTransactionResponse"), so it is typed as that answer. One keyed by the CSMS's protocol and action ("ocpp2.1:RequestStartTransactionResponse") still works when there is none, and emits a DeprecationWarning (code OCPP_PROXY_RESPONSE_KEY) once.

Requirements

  • Node.js ≥ 20.0.0
  • TypeScript ≥ 5.0 (recommended)

Getting Started

Create the Proxy Instance

proxy.ts
import { OCPPProtocolProxy } from "ocpp-ws-io/proxy";

const proxy = new OCPPProtocolProxy({
  // The CSMS WebSocket endpoint you're forwarding to
  upstreamEndpoint: "ws://your-csms.example.com:9000/ocpp",

  // The OCPP version your CSMS speaks
  upstreamProtocol: "ocpp2.1",
});

The OCPPProtocolProxy is an EventEmitter. It manages the lifecycle of connections, translation, and middleware execution.

OptionTypeRequiredDescription
upstreamEndpointstring✅WebSocket URL of the target CSMS
upstreamProtocolstring✅OCPP version the CSMS expects (e.g., "ocpp2.1")
sessionStoreISessionStore❌Custom session store (defaults to InMemorySessionStore)
middlewaresProxyMiddleware[]❌Array of middleware functions

Load Translation Presets

proxy.ts
import { presets } from "ocpp-ws-io/proxy";

// Load all OCPP 1.6 → 2.1 mappings (all 28 messages, all 6 profiles)
proxy.translate(presets.ocpp16_to_ocpp21);

The presets.ocpp16_to_ocpp21 object includes translation mappings for:

  • Upstream (EVSE→CSMS): BootNotification, Authorize, StartTransaction→TransactionEvent, StopTransaction, MeterValues, StatusNotification, Heartbeat, and more
  • Downstream (CSMS→EVSE): RemoteStartTransaction←RequestStartTransaction, RemoteStopTransaction←RequestStopTransaction, ChangeAvailability, Reset, UnlockConnector, TriggerMessage, SetChargingProfile, and more
  • Responses: Maps the CSMS's 2.1 answers back to 1.6, and a charger's 1.6 answers (SetChargingProfile, GetCompositeSchedule) on to 2.1
  • Errors: Maps a CSMS's 2.1 error codes to 1.6 ("ocpp2.1:Error"), and a charger's 1.6 codes to 2.x ("ocpp1.6:Error")

Attach a Transport Adapter

proxy.ts
import { OcppWsIoAdapter } from "ocpp-ws-io/proxy";

const adapter = new OcppWsIoAdapter({
  port: 9001,
  protocols: ["ocpp1.6"],
});

await proxy.listenOnAdapter(adapter);

console.log("Proxy listening on port 9001");
// 1.6 chargers connect to ws://proxy-host:9001
// Proxy translates and forwards to CSMS at ws://your-csms:9000

Listen for Events

proxy.ts
proxy.on("connection", (identity, protocol) => {
  console.log(`${identity} connected via ${protocol}`);
});

proxy.on("disconnect", (identity) => {
  console.log(`${identity} disconnected`);
});

proxy.on("translationError", (err, message, context) => {
  console.error(`Translation failed for ${context.identity}:`, err.message);
});

proxy.on("middlewareError", (err, message, context) => {
  console.error(`Middleware error for ${context.identity}:`, err.message);
});

Complete Example

Here's a full, copy-pasteable example that sets up a proxy translating 1.6 chargers to a 2.1 CSMS:

main.ts
import {
  OCPPProtocolProxy,
  OcppWsIoAdapter,
  presets,
} from "ocpp-ws-io/proxy";

async function main() {
  // 1. Create the proxy targeting your 2.1 CSMS
  const proxy = new OCPPProtocolProxy({
    upstreamEndpoint: "ws://csms.example.com:9000/ocpp",
    upstreamProtocol: "ocpp2.1",
  });

  // 2. Load all built-in translation presets
  proxy.translate(presets.ocpp16_to_ocpp21);

  // 3. Create a transport adapter that accepts 1.6 chargers
  const adapter = new OcppWsIoAdapter({
    port: 9001,
    protocols: ["ocpp1.6"],
  });

  // 4. Wire up event listeners
  proxy.on("connection", (identity, protocol) => {
    console.log(`✅ ${identity} connected (${protocol})`);
  });

  proxy.on("disconnect", (identity) => {
    console.log(`❌ ${identity} disconnected`);
  });

  proxy.on("translationError", (err, _msg, ctx) => {
    console.error(`⚠️ Translation error [${ctx.identity}]:`, err.message);
  });

  // 5. Start listening
  await proxy.listenOnAdapter(adapter);
  console.log("🔌 OCPP Protocol Proxy running on port 9001");
}

main().catch(console.error);

Run it:

npx tsx main.ts

Selective Presets

Don't need all 6 OCPP profiles? Import only what you need for tree-shaking:

import {
  corePreset,         // Core profile (mandatory) — 16 messages
  smartChargingPreset, // Smart Charging — 3 messages
  firmwarePreset,      // Firmware Management — 4 messages
  reservationPreset,   // Reservation — 2 messages
  localAuthPreset,     // Local Auth List — 2 messages
} from "ocpp-ws-io/proxy";

// Register only the profiles you need
proxy.translate(corePreset);
proxy.translate(smartChargingPreset);

Preset Coverage Table

PresetOCPP ProfileUpstream Messages (1.6→2.1)Downstream Messages (2.1→1.6)
corePresetCore (mandatory)BootNotification, Heartbeat, Authorize, StatusNotification, StartTransaction→TransactionEvent, StopTransaction→TransactionEvent, MeterValues→TransactionEventRemoteStartTransaction←RequestStartTransaction, RemoteStopTransaction←RequestStopTransaction, ChangeAvailability, Reset, UnlockConnector, TriggerMessage
smartChargingPresetSmart Charging—SetChargingProfile, ClearChargingProfile, GetCompositeSchedule
firmwarePresetFirmware MgmtFirmwareStatusNotification, DiagnosticsStatusNotification→LogStatusNotificationUpdateFirmware, GetLog→GetDiagnostics
reservationPresetReservation—ReserveNow, CancelReservation
localAuthPresetLocal Auth List—GetLocalListVersion, SendLocalList

Charging profiles

smartChargingPreset turns a 2.x chargingProfile into 1.6's csChargingProfiles: id becomes chargingProfileId, ChargingStationMaxProfile becomes ChargePointMaxProfile, the 2.x transaction id becomes the 1.6 id the core preset gave it, and the one schedule becomes an object. Fields 1.6 has no place for are left out: a schedule's id and salesTariff, a period's phaseToUse, and the fields 2.1 added. ClearChargingProfile renames the purpose the same way.

What 1.6 cannot hold is refused with a NotSupported CALLERROR instead of being sent: a Dynamic profile, the purposes ChargingStationExternalConstraints, PriorityCharging and LocalGeneration, more than one schedule, a period without a limit, and a transaction the proxy has no 1.6 id for. A charger's 1.6 NotSupported answer reaches the CSMS as Rejected with statusInfo.reasonCode NotSupported, since 2.x has no such status.

A 1.6 GetCompositeSchedule answer reaches the CSMS as 2.x's schedule: connectorId becomes evseId, and scheduleStart, the schedule's duration, chargingRateUnit and periods are kept. What 1.6 leaves out comes from the request: the evseId, the duration, and as scheduleStart the time the proxy sent it, since 1.6 counts the schedule from when the charger received the request. minChargingRate has no place in 2.x's composite schedule and is left out.


Custom Translation Overrides

Use presets as a base and override specific action mappings with your own business logic:

proxy.translate({
  // Spread all preset mappings
  ...presets.ocpp16_to_ocpp21,

  // Override specific upstream actions
  upstream: {
    ...presets.ocpp16_to_ocpp21.upstream,

    // Custom StartTransaction handler with business logic
    "ocpp1.6:StartTransaction": async (params, ctx) => {
      // Access the session store for stateful logic
      const customData = await ctx.session.get(ctx.identity, "myCustomKey");

      console.log(`Custom StartTx for ${ctx.identity}`, params);

      return {
        action: "TransactionEvent",
        payload: {
          eventType: "Started",
          timestamp: new Date().toISOString(),
          triggerReason: "Authorized",
          seqNo: 0,
          transactionInfo: {
            transactionId: crypto.randomUUID(),
          },
          // ... your custom field mapping
        },
      };
    },
  },
});

Writing a Translation Mapper

A key names a protocol and an action the types know, such as "ocpp1.6:StartTransaction" (a custom protocol counts once it is declared on OCPPMethodMap), so a misspelt key is a compile error. Every mapper function receives two arguments:

ArgumentTypeDescription
paramsthe request of the action the key namesThe source message payload, typed: OCPPRequestType<"ocpp1.6", "StartTransaction"> for "ocpp1.6:StartTransaction"; a response mapper gets the answer its key names: "ocpp2.1:TransactionEventResponse" the CSMS's answer to an upstream call, "ocpp1.6:SetChargingProfileResponse" a charger's answer to a downstream call (its protocol and the action it was sent)
ctxTranslationContextContext with identity, sourceProtocol, targetProtocol, and session

And returns a TranslationResult:

type TranslationResult = {
  action?: string; // Target action name (optional — keeps original if omitted)
  payload: object; // Translated payload, sent as JSON
};

Mappers can be synchronous or async — use async when you need to look up state from the session store.


Middleware

Middleware functions intercept messages at 4 lifecycle points. Use them for logging, validation, rate limiting, metrics, or custom mutations.

Middleware Lifecycle

PhaseDirectionWhen It Runs
preupstreamBefore translating EVSE→CSMS calls
postupstreamAfter translating, before forwarding to CSMS
preresponseBefore translating CSMS→EVSE responses
postresponseAfter translating, before returning to EVSE
predownstreamBefore translating CSMS→EVSE calls
postdownstreamAfter translating, before forwarding to EVSE
preerrorBefore translating error responses
posterrorAfter translating error responses

Example: Logging Middleware

import type { ProxyMiddleware } from "ocpp-ws-io/proxy";

const loggingMiddleware: ProxyMiddleware = async (
  message,
  context,
  direction,
  phase,
) => {
  console.log(
    `[${new Date().toISOString()}] [${phase}] [${direction}] ` +
    `[${context.identity}] ` +
    `${context.sourceProtocol} → ${context.targetProtocol}`,
    JSON.stringify(message, null, 2),
  );

  // Return undefined to pass the message through unchanged
  return undefined;
};

const proxy = new OCPPProtocolProxy({
  upstreamEndpoint: "ws://csms:9000",
  upstreamProtocol: "ocpp2.1",
  middlewares: [loggingMiddleware],
});

Example: Validation Middleware

const validationMiddleware: ProxyMiddleware = async (
  message,
  context,
  direction,
  phase,
) => {
  if (phase !== "post") return undefined; // only validate after translation

  // Reject messages missing required fields
  if (message.type === 2 && !message.action) {
    throw new Error(`Translated message for ${context.identity} has no action`);
  }

  return undefined;
};

Built-in Telemetry Middleware

createTelemetryMiddleware measures each translation step, from its pre middleware to its post, and passes the latency to onLatency: the action of a call (undefined for an answer or an error), the milliseconds, the context and the direction. The start of each step is kept in the session store.

import { createTelemetryMiddleware } from "ocpp-ws-io/proxy";

const proxy = new OCPPProtocolProxy({
  upstreamEndpoint: "ws://csms:9000",
  upstreamProtocol: "ocpp2.1",
  middlewares: [
    createTelemetryMiddleware({
      onLatency: (action, ms, ctx, direction) =>
        metrics.histogram("proxy.translation_ms", ms, { action, direction }),
    }),
  ],
});

TelemetryMiddleware, the same without onLatency, is still exported; it measures but reports nothing.


Session Store

The proxy uses an ISessionStore to maintain state across correlated messages. This is critical for:

  • Transaction ID mapping — OCPP 1.6 uses auto-incrementing integers, OCPP 2.1 uses UUID strings
  • Pending call correlation — Linking StartTransaction to its TransactionEventResponse
  • Custom state — Store anything you need for your translation logic

Interface

interface ISessionStore {
  set(identity: string, key: string, value: ProxyValue): Promise<void>;
  get<T extends ProxyValue = ProxyValue>(identity: string, key: string): Promise<T | undefined>;
  delete(identity: string, key: string): Promise<void>;
  clear(identity: string): Promise<void>;
}

ProxyValue is a JSON value, or undefined for a field left out.

Default: InMemorySessionStore

Works out of the box for single-instance deployments. Data is lost on restart.

import { InMemorySessionStore } from "ocpp-ws-io/proxy";

// This is the default — you don't need to pass it explicitly
const proxy = new OCPPProtocolProxy({
  upstreamEndpoint: "ws://csms:9000",
  upstreamProtocol: "ocpp2.1",
  sessionStore: new InMemorySessionStore(),
});

Custom: Redis Session Store

For clustered/multi-instance deployments, implement ISessionStore with Redis:

import type { ISessionStore, ProxyValue } from "ocpp-ws-io/proxy";
import Redis from "ioredis";

class RedisSessionStore implements ISessionStore {
  private redis = new Redis();

  private key(identity: string, key: string) {
    return `ocpp-proxy:${identity}:${key}`;
  }

  async set(identity: string, key: string, value: ProxyValue): Promise<void> {
    await this.redis.set(this.key(identity, key), JSON.stringify(value));
  }

  async get<T extends ProxyValue = ProxyValue>(identity: string, key: string): Promise<T | undefined> {
    const raw = await this.redis.get(this.key(identity, key));
    return raw ? JSON.parse(raw) as T : undefined;
  }

  async delete(identity: string, key: string): Promise<void> {
    await this.redis.del(this.key(identity, key));
  }

  async clear(identity: string): Promise<void> {
    const keys = await this.redis.keys(`ocpp-proxy:${identity}:*`);
    if (keys.length) await this.redis.del(...keys);
  }
}

Custom Transport Adapter

The proxy doesn't care how messages arrive. Implement ITransportAdapter and IConnection to use any transport:

import type { ITransportAdapter, IConnection, OCPPMessage } from "ocpp-ws-io/proxy";

class MyCustomAdapter implements ITransportAdapter {
  async listen(onConnection: (connection: IConnection) => void): Promise<void> {
    // Set up your transport (MQTT, HTTP polling, gRPC, etc.)
    // When a new client connects, create an IConnection and call:
    onConnection({
      identity: "CP-001",
      protocol: "ocpp1.6",
      async send(message: OCPPMessage) {
        // Send the translated message to the client
        return undefined;
      },
      onMessage(handler) {
        // Register the handler for incoming messages
      },
      onClose(handler) {
        // Register the handler for disconnections
      },
    });
  }

  async close(): Promise<void> {
    // Clean up transport resources
  }
}

Events Reference

OCPPProtocolProxy extends EventEmitter with these typed events:

EventCallback SignatureWhen
connection(identity: string, protocol: string) => voidNew EVSE connects
disconnect(identity: string) => voidEVSE disconnects
translationError(error: Error, message: OCPPMessage, context: TranslationContext) => voidTranslation mapper threw
middlewareError(error: Error, message: OCPPMessage, context: TranslationContext) => voidMiddleware threw

Unlike generic Node.js EventEmitter, the proxy uses specific event names (translationError, middlewareError) instead of the generic error event. This prevents unhandled exception crashes when no listener is registered.


Graceful Shutdown

process.on("SIGTERM", async () => {
  console.log("Shutting down proxy...");

  // Disconnects all clients and closes all transport adapters
  await proxy.close();

  console.log("Proxy shut down cleanly.");
  process.exit(0);
});

The close() method:

  1. Disconnects all connected EVSE clients
  2. Closes all registered transport adapters
  3. Releases session store resources

Architecture

In ocpp-ws-io:

ocpp-ws-io/
├── src/proxy/
│   ├── core/
│   │   ├── types.ts          # OCPPMessage, TranslationMap, ITransportAdapter, IConnection
│   │   ├── translator.ts     # Pure translation engine (no side effects)
│   │   └── session.ts        # ISessionStore interface + InMemorySessionStore
│   ├── presets/
│   │   ├── index.ts          # mergePresets() + combined presets.ocpp16_to_ocpp21
│   │   ├── core.ts           # Core profile — 16 messages
│   │   ├── smart-charging.ts # Smart Charging — 3 messages
│   │   ├── firmware.ts       # Firmware Management — 4 messages
│   │   ├── reservation.ts    # Reservation — 2 messages
│   │   ├── local-auth.ts     # Local Auth List — 2 messages
│   │   └── status-enums.ts   # StatusNotification enum mapping tables
│   ├── adapters/
│   │   └── ocpp-ws-io.adapter.ts  # WebSocket adapter via ocpp-ws-io
│   ├── middlewares/
│   │   └── telemetry.ts      # Latency tracking middleware
│   ├── proxy.ts              # OCPPProtocolProxy orchestrator
│   └── index.ts              # Public API exports (ocpp-ws-io/proxy)
└── test/proxy/
    ├── proxy.test.ts         # Integration tests
    └── presets/              # Unit tests for every preset module

OCPP Version Differences

Understanding what the proxy translates:

ConceptOCPP 1.6OCPP 2.1Proxy Handles?
Transaction startStartTransaction (CP→CS)TransactionEvent (eventType: "Started")✅
Transaction stopStopTransaction (CP→CS)TransactionEvent (eventType: "Ended")✅
Meter readingsMeterValues (CP→CS)TransactionEvent (eventType: "Updated")✅
Transaction IDInteger (assigned by CSMS)UUID string (assigned by CP)✅ Bidirectional mapping
Connector modelFlat connectorIdHierarchical evseId + connectorId✅
Status values9 values (Available, Charging, etc.)5 values (Available, Occupied, etc.)✅ Enum mapping
User identityString idTagObject {idToken, type}✅
Error codes10 codes7 codes (different names)✅ Full mapping
Config managementFlat key-value3-tier Device Model❌ Not yet
Security profilesBasicCertificate-based❌ Transport layer

What's Next?

On this page