OCPP WS IOocpp-ws-io
Smart Charge Engine

Express Integration

Complete CSMS example with ocpp-ws-io, Express, multi-panel setup, REST admin API, and auto-dispatch.

ocpp-ws-io + Express Integration

A complete CSMS example with:

  • Express — REST admin API
  • ocpp-ws-io — OCPP WebSocket server
  • 2 sub-panels (Floor 1 = 80kW EQUAL_SHARE, Floor 2 = 80kW PRIORITY)
  • VIP chargers on a dedicated circuit (not load-balanced)
  • Auto-dispatch every 60 seconds

Installation

npm install ocpp-ws-io ocpp-smart-charge-engine express
npm install -D @types/express

Full Code

csms.ts
import express from "express";
import { createServer } from "node:http";
import { OCPPServer } from "ocpp-ws-io";
import { SmartChargingEngine, Strategies } from "ocpp-smart-charge-engine";
import {
  buildOcpp16Profile,
  buildOcpp201Profile,
} from "ocpp-smart-charge-engine/builders";

// ─────────────────────────────────────────
// 1. Site layout
// ─────────────────────────────────────────

const FLOOR1_CHARGERS = new Set(["CP-01", "CP-02", "CP-03", "CP-04"]);
const FLOOR2_CHARGERS = new Set(["CP-05", "CP-06", "CP-07", "CP-08"]);
// CP-09, CP-10 → dedicated VIP line, never added to any engine

// Live client references for outbound calls (SetChargingProfile)
const connectedClients = new Map<string, import("ocpp-ws-io").OCPPServerClient>();

// ─────────────────────────────────────────
// 2. OCPP server
// ─────────────────────────────────────────

const server = new OCPPServer({
  protocols: ["ocpp1.6", "ocpp2.0.1"],
});

// ─────────────────────────────────────────
// 3. Dispatcher factories
// ─────────────────────────────────────────

function makeDispatcher(siteId: string) {
  return async ({ clientId, connectorId, sessionProfile }) => {
    const client = connectedClients.get(clientId);
    if (!client) return;
    const protocol = client.protocol as "ocpp1.6" | "ocpp2.0.1";

    if (protocol === "ocpp1.6") {
      await client.call("SetChargingProfile", {
        connectorId,
        csChargingProfiles: buildOcpp16Profile(sessionProfile),
      });
    } else {
      await client.call("SetChargingProfile", {
        evseId: connectorId,
        chargingProfile: buildOcpp201Profile(sessionProfile),
      });
    }
    console.log(`[${siteId}] ✅ ${clientId} → ${sessionProfile.allocatedKw}kW`);
  };
}

function makeClearDispatcher(siteId: string) {
  return async ({ clientId, connectorId }) => {
    const client = connectedClients.get(clientId);
    if (!client) return;
    const protocol = client.protocol as "ocpp1.6" | "ocpp2.0.1";

    if (protocol === "ocpp1.6") {
      await client.call("ClearChargingProfile", {
        connectorId,
        chargingProfilePurpose: "TxProfile",
        stackLevel: 0,
      });
    } else {
      await client.call("ClearChargingProfile", {
        chargingProfileCriteria: {
          evseId: connectorId,
          chargingProfilePurpose: "TxProfile",
        },
      });
    }
    console.log(`[${siteId}] 🧹 Cleared profile for ${clientId}`);
  };
}

// ─────────────────────────────────────────
// 4. One engine per sub-panel
// ─────────────────────────────────────────

const engineFloor1 = new SmartChargingEngine({
  siteId: "FLOOR-1",
  maxGridPowerKw: 80,
  safetyMarginPct: 5,
  algorithm: Strategies.EQUAL_SHARE,
  autoClearOnRemove: true,
  dispatcher: makeDispatcher("FLOOR-1"),
  clearDispatcher: makeClearDispatcher("FLOOR-1"),
});

const engineFloor2 = new SmartChargingEngine({
  siteId: "FLOOR-2",
  maxGridPowerKw: 80,
  safetyMarginPct: 5,
  algorithm: Strategies.PRIORITY, // fleet vehicles get priority
  autoClearOnRemove: true,
  dispatcher: makeDispatcher("FLOOR-2"),
  clearDispatcher: makeClearDispatcher("FLOOR-2"),
});

function getEngine(clientId: string) {
  if (FLOOR1_CHARGERS.has(clientId)) return engineFloor1;
  if (FLOOR2_CHARGERS.has(clientId)) return engineFloor2;
  return null; // VIP / dedicated — not managed
}

// ─────────────────────────────────────────
// 5. Auto-dispatch every 60s
// ─────────────────────────────────────────

engineFloor1.startAutoDispatch(60_000);
engineFloor2.startAutoDispatch(60_000);

engineFloor1.on("dispatched", (p) =>
  console.log(`[FLOOR-1] ${p.length} profiles dispatched`),
);
engineFloor2.on("dispatched", (p) =>
  console.log(`[FLOOR-2] ${p.length} profiles dispatched`),
);
engineFloor1.on("dispatchError", (e) =>
  console.error("[FLOOR-1] Dispatch error", e),
);
engineFloor2.on("dispatchError", (e) =>
  console.error("[FLOOR-2] Dispatch error", e),
);

// ─────────────────────────────────────────
// 6. OCPP message handlers
// ─────────────────────────────────────────

server.route("/ocpp/:identity").on("client", (client) => {
  // Store client for outbound OCPP calls
  connectedClients.set(client.identity, client);
  client.once("close", () => connectedClients.delete(client.identity));

  // ── OCPP 1.6 ──────────────────────────────

  client.handle("ocpp1.6", "BootNotification", () => ({
    currentTime: new Date().toISOString(),
    interval: 30,
    status: "Accepted",
  }));

  client.handle("ocpp1.6", "Heartbeat", () => ({
    currentTime: new Date().toISOString(),
  }));

  client.handle("ocpp1.6", "StatusNotification", () => ({}));

  client.handle("ocpp1.6", "StartTransaction", async (ctx) => {
    const engine = getEngine(client.identity);

    if (engine) {
      engine.addSession({
        transactionId: ctx.payload.transactionId,
        clientId: client.identity,
        connectorId: ctx.payload.connectorId,
        maxHardwarePowerKw: getChargerMaxKw(client.identity),
        minChargeRateKw: 1.4,
        priority: getChargerPriority(client.identity),
      });
      await engine.dispatch();
    }

    return {
      idTagInfo: { status: "Accepted" },
      transactionId: ctx.payload.transactionId,
    };
  });

  client.handle("ocpp1.6", "StopTransaction", async (ctx) => {
    const engine = getEngine(client.identity);
    if (engine) {
      engine.safeRemoveSession(ctx.payload.transactionId);
      await engine.dispatch();
    }
    return { idTagInfo: { status: "Accepted" } };
  });

  // ── OCPP 2.0.1 ──────────────────────────────

  client.handle("ocpp2.0.1", "BootNotification", () => ({
    currentTime: new Date().toISOString(),
    interval: 30,
    status: "Accepted",
  }));

  client.handle("ocpp2.0.1", "Heartbeat", () => ({
    currentTime: new Date().toISOString(),
  }));

  client.handle("ocpp2.0.1", "TransactionEvent", async (ctx) => {
    const engine = getEngine(client.identity);
    const { eventType, transactionInfo, evse } = ctx.payload;

    if (eventType === "Started" && engine) {
      engine.addSession({
        transactionId: transactionInfo.transactionId,
        clientId: client.identity,
        connectorId: evse?.id ?? 1,
        maxHardwarePowerKw: getChargerMaxKw(client.identity),
        minChargeRateKw: 1.4,
      });
      await engine.dispatch();
    }

    if (eventType === "Ended" && engine) {
      engine.safeRemoveSession(transactionInfo.transactionId);
      await engine.dispatch();
    }

    return { idTokenInfo: { status: "Accepted" } };
  });
});

// ─────────────────────────────────────────
// 7. Express REST API — grid control
// ─────────────────────────────────────────

const app = express();
app.use(express.json());

// GET /api/grid/status — snapshot of all sessions and config
app.get("/api/grid/status", (_req, res) => {
  res.json({
    floor1: {
      ...engineFloor1.config,
      sessions: engineFloor1.getSessions(),
    },
    floor2: {
      ...engineFloor2.config,
      sessions: engineFloor2.getSessions(),
    },
  });
});

// POST /api/grid/limit — utility demand response
app.post("/api/grid/limit", async (req, res) => {
  const { floor, limitKw } = req.body as { floor: 1 | 2; limitKw: number };
  const engine = floor === 1 ? engineFloor1 : engineFloor2;
  engine.setGridLimit(limitKw);
  const profiles = await engine.dispatch();
  res.json({ ok: true, updatedProfiles: profiles.length });
});

// POST /api/grid/algorithm — hot-swap strategy at runtime
app.post("/api/grid/algorithm", (req, res) => {
  const { floor, algorithm } = req.body;
  (floor === 1 ? engineFloor1 : engineFloor2).setAlgorithm(algorithm);
  res.json({ ok: true });
});

// POST /api/grid/emergency — cut all panels by N%
app.post("/api/grid/emergency", async (req, res) => {
  const { reductionPct } = req.body as { reductionPct: number };
  const factor = 1 - reductionPct / 100;
  engineFloor1.setGridLimit(engineFloor1.config.gridLimitKw * factor);
  engineFloor2.setGridLimit(engineFloor2.config.gridLimitKw * factor);
  await Promise.all([engineFloor1.dispatch(), engineFloor2.dispatch()]);
  res.json({ ok: true, message: `Reduced all panels by ${reductionPct}%` });
});

// POST /api/grid/dispatch — manually trigger a rebalance
app.post("/api/grid/dispatch", async (_req, res) => {
  const [p1, p2] = await Promise.all([
    engineFloor1.dispatch(),
    engineFloor2.dispatch(),
  ]);
  res.json({ floor1: p1.length, floor2: p2.length });
});

// ─────────────────────────────────────────
// 8. Start — Express and ocpp-ws-io share the same HTTP server
// ─────────────────────────────────────────

const httpServer = createServer(app);
await server.listen(3000, "0.0.0.0", { server: httpServer });
console.log("Server running on :3000");
console.log("OCPP WebSocket: ws://your-server:3000/ocpp/<station-identity>");

// ─────────────────────────────────────────
// Helpers — replace with your DB lookups
// ─────────────────────────────────────────

function getChargerMaxKw(clientId: string): number {
  const ratings: Record<string, number> = {
    "CP-01": 22, "CP-02": 22, "CP-03": 11, "CP-04": 7.4,
    "CP-05": 50, "CP-06": 50, "CP-07": 22, "CP-08": 22,
  };
  return ratings[clientId] ?? 22;
}

function getChargerPriority(clientId: string): number {
  const priorities: Record<string, number> = {
    "CP-05": 10, "CP-06": 10,
    "CP-07": 5, "CP-08": 5,
  };
  return priorities[clientId] ?? 1;
}

What Happens at Runtime

CP-01 connects (OCPP 1.6, 22kW) → Floor 1: 1 session → allocated 76kW (capped to 22kW)
CP-02 connects (OCPP 1.6, 22kW) → Floor 1: 2 sessions → 38kW each (capped to 22kW)
CP-03 connects (OCPP 2.0.1, 11kW) → Floor 1: 3 sessions → third capped at 11kW

Utility DR signal:
POST /api/grid/limit { floor: 1, limitKw: 50 }
→ Floor 1 redispatched at 50kW effective
→ Each session gets ~16.7kW → SetChargingProfile sent to all 3

CP-01 leaves:
StopTransaction → safeRemoveSession (autoClearOnRemove → ClearChargingProfile sent)
→ engine.dispatch() → remaining 2 sessions rebalanced

Key API Points

WhatHow
Register sessionengine.addSession({ transactionId, clientId, connectorId, ... })
Remove sessionengine.safeRemoveSession(transactionId) → triggers auto-clear if configured
Send profile to chargerclient.call('SetChargingProfile', payload) (via connectedClients map)
Dynamic grid limitengine.setGridLimit(kw) then engine.dispatch()
Auto-rebalanceengine.startAutoDispatch(ms)
Manual rebalanceawait engine.dispatch()

On this page