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:
- Pre-middleware — inspect or mutate the raw message before translation
- Translation engine — rewrites action names, payload fields, data types, and error codes using your registered mappings
- Session store — maintains stateful correlation (e.g., mapping 1.6 integer
transactionIdto 2.1 UUID strings) - 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| Before | Now |
|---|---|
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
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.
| Option | Type | Required | Description |
|---|---|---|---|
upstreamEndpoint | string | ✅ | WebSocket URL of the target CSMS |
upstreamProtocol | string | ✅ | OCPP version the CSMS expects (e.g., "ocpp2.1") |
sessionStore | ISessionStore | ❌ | Custom session store (defaults to InMemorySessionStore) |
middlewares | ProxyMiddleware[] | ❌ | Array of middleware functions |
Load Translation Presets
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
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:9000Listen for Events
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:
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.tsSelective 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
| Preset | OCPP Profile | Upstream Messages (1.6→2.1) | Downstream Messages (2.1→1.6) |
|---|---|---|---|
corePreset | Core (mandatory) | BootNotification, Heartbeat, Authorize, StatusNotification, StartTransaction→TransactionEvent, StopTransaction→TransactionEvent, MeterValues→TransactionEvent | RemoteStartTransaction←RequestStartTransaction, RemoteStopTransaction←RequestStopTransaction, ChangeAvailability, Reset, UnlockConnector, TriggerMessage |
smartChargingPreset | Smart Charging | — | SetChargingProfile, ClearChargingProfile, GetCompositeSchedule |
firmwarePreset | Firmware Mgmt | FirmwareStatusNotification, DiagnosticsStatusNotification→LogStatusNotification | UpdateFirmware, GetLog→GetDiagnostics |
reservationPreset | Reservation | — | ReserveNow, CancelReservation |
localAuthPreset | Local 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:
| Argument | Type | Description |
|---|---|---|
params | the request of the action the key names | The 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) |
ctx | TranslationContext | Context 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
| Phase | Direction | When It Runs |
|---|---|---|
pre | upstream | Before translating EVSE→CSMS calls |
post | upstream | After translating, before forwarding to CSMS |
pre | response | Before translating CSMS→EVSE responses |
post | response | After translating, before returning to EVSE |
pre | downstream | Before translating CSMS→EVSE calls |
post | downstream | After translating, before forwarding to EVSE |
pre | error | Before translating error responses |
post | error | After 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
StartTransactionto itsTransactionEventResponse - 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:
| Event | Callback Signature | When |
|---|---|---|
connection | (identity: string, protocol: string) => void | New EVSE connects |
disconnect | (identity: string) => void | EVSE disconnects |
translationError | (error: Error, message: OCPPMessage, context: TranslationContext) => void | Translation mapper threw |
middlewareError | (error: Error, message: OCPPMessage, context: TranslationContext) => void | Middleware 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:
- Disconnects all connected EVSE clients
- Closes all registered transport adapters
- 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 moduleOCPP Version Differences
Understanding what the proxy translates:
| Concept | OCPP 1.6 | OCPP 2.1 | Proxy Handles? |
|---|---|---|---|
| Transaction start | StartTransaction (CP→CS) | TransactionEvent (eventType: "Started") | ✅ |
| Transaction stop | StopTransaction (CP→CS) | TransactionEvent (eventType: "Ended") | ✅ |
| Meter readings | MeterValues (CP→CS) | TransactionEvent (eventType: "Updated") | ✅ |
| Transaction ID | Integer (assigned by CSMS) | UUID string (assigned by CP) | ✅ Bidirectional mapping |
| Connector model | Flat connectorId | Hierarchical evseId + connectorId | ✅ |
| Status values | 9 values (Available, Charging, etc.) | 5 values (Available, Occupied, etc.) | ✅ Enum mapping |
| User identity | String idTag | Object {idToken, type} | ✅ |
| Error codes | 10 codes | 7 codes (different names) | ✅ Full mapping |
| Config management | Flat key-value | 3-tier Device Model | ❌ Not yet |
| Security profiles | Basic | Certificate-based | ❌ Transport layer |
What's Next?
Quick Start Guide
Set up your first OCPP client and server in under 5 minutes.
System Design
Understand the full architecture of the ocpp-ws-io ecosystem.
Middleware
Learn about the middleware pipeline in the core library.
Clustering
Scale with Redis-based multi-instance deployments.
Security Profiles
Configure TLS, Basic Auth, and Mutual TLS for production.
API Reference
Complete API documentation for all classes and methods.