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

ocpp-protocol-proxy is a standalone package that 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.

This package 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-protocol-proxy
bash pnpm add ocpp-protocol-proxy
bash yarn add ocpp-protocol-proxy
bash bun add ocpp-protocol-proxy

Peer Dependencies

ocpp-protocol-proxy uses ocpp-ws-io for the built-in WebSocket transport adapter. If you only use the core translation engine with a custom transport, ocpp-ws-io is not required at runtime.

npm install ocpp-ws-io ocpp-protocol-proxy

Requirements

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

Getting Started

Create the Proxy Instance

proxy.ts
import { OCPPProtocolProxy } from "ocpp-protocol-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
upstreamEndpointstringWebSocket URL of the target CSMS
upstreamProtocolstringOCPP version the CSMS expects (e.g., "ocpp2.1")
sessionStoreISessionStoreCustom session store (defaults to InMemorySessionStore)
middlewaresProxyMiddleware[]Array of middleware functions

Load Translation Presets

proxy.ts
import { presets } from "ocpp-protocol-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, StartTransactionTransactionEvent, StopTransaction, MeterValues, StatusNotification, Heartbeat, and more
  • Downstream (CSMS→EVSE): RemoteStartTransactionRequestStartTransaction, RemoteStopTransactionRequestStopTransaction, ChangeAvailability, Reset, UnlockConnector, TriggerMessage, SetChargingProfile, and more
  • Responses: Maps 2.1 response payloads back to 1.6 format
  • Errors: Maps 2.1 error codes to 1.6 equivalents

Attach a Transport Adapter

proxy.ts
import { OcppWsIoAdapter } from "ocpp-protocol-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-protocol-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-protocol-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, StartTransactionTransactionEvent, StopTransactionTransactionEvent, MeterValuesTransactionEventRemoteStartTransactionRequestStartTransaction, RemoteStopTransactionRequestStopTransaction, ChangeAvailability, Reset, UnlockConnector, TriggerMessage
smartChargingPresetSmart ChargingSetChargingProfile, ClearChargingProfile, GetCompositeSchedule
firmwarePresetFirmware MgmtFirmwareStatusNotification, DiagnosticsStatusNotificationLogStatusNotificationUpdateFirmware, GetLogGetDiagnostics
reservationPresetReservationReserveNow, CancelReservation
localAuthPresetLocal Auth ListGetLocalListVersion, SendLocalList

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

Every mapper function receives two arguments:

ArgumentTypeDescription
paramsanyThe source message payload (e.g., OCPP 1.6 StartTransaction fields)
ctxTranslationContextContext with identity, sourceProtocol, targetProtocol, and session

And returns a TranslationResult:

type TranslationResult = {
  action?: string; // Target action name (optional — keeps original if omitted)
  payload: any;    // Translated payload
};

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-protocol-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

The package includes a TelemetryMiddleware that tracks translation latency via the session store:

import { TelemetryMiddleware } from "ocpp-protocol-proxy";

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

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: any): Promise<void>;
  get<T = any>(identity: string, key: string): Promise<T | undefined>;
  delete(identity: string, key: string): Promise<void>;
  clear(identity: string): Promise<void>;
}

Default: InMemorySessionStore

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

import { InMemorySessionStore } from "ocpp-protocol-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 } from "ocpp-protocol-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: any): Promise<void> {
    await this.redis.set(this.key(identity, key), JSON.stringify(value));
  }

  async get<T = any>(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-protocol-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

ocpp-protocol-proxy/
├── src/
│   ├── 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
└── test/
    ├── 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