Skip to content

Repository files navigation

CSMS Test Tool — Charger Simulator

A tester-facing tool that spins up multiple simulated EV chargers, each speaking OCPP 1.6 to a real CSMS, so non-developers can drive charger scenarios (boot, fault, charging session, go offline) against the staging CSMS — no physical hardware needed.

Extracted from the standalone demo charger in occp-test; the bundled mini-CMS and its UI were dropped. Only the protocol engine + a multi-charger control panel remain.

web/index.html      tester UI (buttons per charger, live status, OCPP log)
control_server.py   FastAPI: charger registry + REST/WS API
simulator/
  charge_point.py   OCPP 1.6 charge-point engine (from demo_charger.py)

What testers can do

Per charger: Plug in → Swipe RFID (auto-starts a transaction with meter values) → Stop; trigger a Fault with any OCPP error code (→ creates a FaultLog in the CSMS → test the fault→ticket flow); Clear the fault; and Disconnect to simulate going offline.

Target CMS: the CMS base URL field in the header (top-left, next to "→ CMS") is editable — point the simulator at any OCPP endpoint (ws:// or wss://) without redeploying. It's prefilled from the server's CMS_WS_URL default and remembered per-browser; each charger connects to <base>/<charger id>.

Charger profile: the dropdown next to "Connect charger" selects a device profile so the charger behaves like a specific class of hardware — AC vs DC, per-vendor error codes, electrical model + charging curve, measurands, capabilities, and DataTransfer dialect. default reproduces the original behaviour. Add your own vendor profiles as JSON — see PROFILES.md.

Run locally

cd test-tool
CMS_WS_URL=ws://localhost:9220 ./run.sh      # or: pip install -r requirements.txt && uvicorn control_server:app --port 8001

Open http://localhost:8001. "Quick add 5" connects SIM-1 … SIM-5.

Prerequisite: each simulated charger id must already exist in the CSMS — the OCPP server authenticates the charger on connect, so an unknown id is rejected. Seed a handful of test chargers (e.g. SIM-1 … SIM-5) in the staging DB first.

Run on the staging EC2

The CSMS OCPP server is on the same box, so chargers dial ws://localhost:9220.

cd ~/lb-cms-test-tool        # wherever you place it (pm2 list shows the app name)
git pull
./.venv/bin/pip install -r requirements.txt   # new deps: SQLAlchemy, Alembic, Authlib
./.venv/bin/alembic upgrade head              # REQUIRED — creates/updates the schema

# The old `sim` process was started without these env vars, and `pm2 restart` does
# NOT re-read env — so delete and start fresh to pick them up.
# SIM_ALLOW_PRIVATE_EGRESS=1 because staging's OCPP server is on localhost.
# SIM_SESSION_SECRET: a fixed value keeps sign-ins alive across restarts.
pm2 delete sim 2>/dev/null || true
CMS_WS_URL=ws://localhost:9220 SIM_ALLOW_PRIVATE_EGRESS=1 SIM_SESSION_SECRET="$(openssl rand -hex 32)" \
  pm2 start control_server.py --name sim --interpreter ./.venv/bin/python
pm2 save
pm2 logs sim --lines 20     # watch for "Application startup complete"

**On every later deploy: alembic upgrade head, and if env changed, pm2 delete

  • start (not restart).** Staging keeps SIM_ENV=dev — dev sign-in works with no OAuth setup, and localhost is reachable. See Before exposing this publicly for what a public deployment additionally needs.

Smoke test after it's up: open the ngrok/subdomain URL → Dev sign-in → add a charger whose id exists in the staging CMSStartPlugSwipe, and confirm a real session appears in the OCPP log and on the CMS side. This is the first run against a live central system — everything before it used a fake CMS.

Expose it on its own subdomain (cleanest — the UI uses root-relative paths): sim-staging.savekar.comlocalhost:8001 via Nginx, e.g.

server {
    server_name sim-staging.savekar.com;
    location / {
        proxy_pass http://localhost:8001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;       # for the /ui_ws live socket
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
    }
}

…then certbot --nginx -d sim-staging.savekar.com. (Quick alternative for a trusted tester: open port 8001 in the security group and use http://<EC2-IP>:8001.)

Tests

pip install -r requirements.txt   # includes pytest
pytest                            # ~76 tests, a few seconds

tests/ covers the SSRF egress guard (the security-critical set), tenancy and caps, encryption at rest, the profile engine + capture importer, the HTTP API (auth gating, ownership, cap enforcement), the sign-in flow, and a full OCPP session against an in-process CMS. It runs offline — DNS is stubbed and the test database is a throwaway SQLite file. No live CMS or network required.

Two pages

  • / — the multi-charger simulator panel (connect chargers, plug/swipe/stop, faults).
  • /flow — the WhatsApp charging-flow test runbook: a guided stepper with Pass/Fail checkpoints and one-click edge-case injection wired to the simulator (offline, fault, stop, resume). Results save in the browser; Export copies a report. See TEST_PLAN.md for the full checkpoint + edge-case matrix.

OCPP 1.6 coverage (server commands)

The simulated charge point handles every standard OCPP 1.6 command the CMS can send, so the CMS's OCPP-Ops UI works fully against it:

RemoteStartTransaction · RemoteStopTransaction · Reset · ChangeAvailability · Get/ChangeConfiguration · TriggerMessage · UnlockConnector · ReserveNow · CancelReservation · Set/ClearChargingProfile · GetCompositeSchedule · SendLocalList · GetLocalListVersion · ClearCache · UpdateFirmware · GetDiagnostics · DataTransfer

UpdateFirmware/GetDiagnostics reply immediately, then emit a simulated FirmwareStatusNotification / DiagnosticsStatusNotification progression.

Fidelity (Tier 2): MeterValues carry a realistic multi-measurand set (Energy, Power, Current, Voltage, Temperature, SoC — ramped while charging); StopTransaction includes transactionData (begin/end energy + SoC); and the charger can send an outgoing DataTransfer (vendor message, e.g. VehicleID / QrCodeMac / ConnectorUnplugged) via the per-charger DataTransfer → control.

Config

Env Default Meaning
CMS_WS_URL ws://localhost:9220 base OCPP endpoint; a charger connects to <CMS_WS_URL>/<id>
CMS_HTTP_URL `` (unset) CSMS web base (HTTPS) — only powers the flow page's QR-redirector / admin convenience links, e.g. https://cms-staging.savekar.com
PORT 8001 control-panel port
SIM_DATABASE_URL sqlite:///./simulator.db SQLite for dev, postgresql+psycopg://… for prod
SIM_SECRET_KEY `` (unset) Fernet key encrypting stored CMS URLs. Required when SIM_ENV=prod — unset means plaintext, dev only
SIM_SESSION_SECRET falls back to SIM_SECRET_KEY signs the session cookie
SIM_ENV dev prod refuses to start without a secret key and an OAuth provider
SIM_GITHUB_CLIENT_ID / _SECRET `` GitHub sign-in
SIM_GOOGLE_CLIENT_ID / _SECRET `` Google sign-in
SIM_ADMIN_EMAILS `` comma-separated; these users get is_admin (profile authoring)
SIM_ALLOW_PRIVATE_EGRESS dev: allow, prod: block whether chargers may dial private/loopback addresses — see below

Outbound connections (SSRF guard)

Chargers dial a user-supplied URL, so egress.py vets it: only ws/wss, no embedded credentials, every resolved address checked, the connection pinned to the validated IP (DNS rebinding), and redirects refused. Starts are rate-limited to 20/min per user.

Private and loopback destinations are allowed in development — the default target is ws://localhost:9220, and on the staging box the OCPP server is local. They are blocked when SIM_ENV=prod, which is what a publicly reachable deployment must run. Use SIM_ALLOW_PRIVATE_EGRESS=1 only for a deployment that is not public (e.g. staging).

Sign-in

Identity is delegated to GitHub / Google — no passwords are stored. Register these callback URLs with the provider:

<base>/auth/github/callback
<base>/auth/google/callback

With no provider configured a one-click dev sign-in appears on /login, so local work isn't blocked. It disappears as soon as a real provider is configured, and the server refuses to start with SIM_ENV=prod without one — otherwise every visitor would share an account and there would be no tenancy.

Profile authoring at /profiles requires is_admin (see SIM_ADMIN_EMAILS); the catalog itself is readable by any signed-in user.

Database

Saved chargers persist in a database (MULTI_USER_PLAN.md). Schema changes are Alembic migrations, so run them after pulling:

alembic upgrade head            # creates/updates the schema
# generate a key before deploying — stored CMS URLs are other people's endpoints:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

The DB file is gitignored; it holds user records and encrypted CMS URLs.

Notes

  • Multi-charger: every charger runs as its own asyncio task + OCPP connection; the panel shows each independently. Heartbeats keep each socket alive.
  • Multi-user: sign-in is required, chargers are saved per user, and events are scoped to their owner. Caps: 25 saved / 5 running per user.

Before exposing this publicly

The server refuses to start with SIM_ENV=prod unless it is configured safely, so work with that rather than around it. Check:

  • SIM_ENV=prod — blocks private/loopback egress and marks the session cookie Secure
  • SIM_SECRET_KEY — encrypts stored CMS URLs (they are other people's endpoints)
  • SIM_SESSION_SECRET — signs session cookies
  • an OAuth provider (SIM_GITHUB_* and/or SIM_GOOGLE_*); without one every visitor would share a single account
  • TLS in front, and SIM_ADMIN_EMAILS set to just your own admins

Storing another company's CMS URL makes you custodian of their endpoint — say so in your terms (MULTI_USER_PLAN.md §5.3).

About

A Testing environment for Savekar EV CMS

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages