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)
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.
cd test-tool
CMS_WS_URL=ws://localhost:9220 ./run.sh # or: pip install -r requirements.txt && uvicorn control_server:app --port 8001Open 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.
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 keepsSIM_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 CMS → Start → Plug → Swipe, 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.com → localhost: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.)
pip install -r requirements.txt # includes pytest
pytest # ~76 tests, a few secondstests/ 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.
/— 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. SeeTEST_PLAN.mdfor the full checkpoint + edge-case matrix.
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.
| 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 |
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).
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.
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.
- 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.
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 SecureSIM_SECRET_KEY— encrypts stored CMS URLs (they are other people's endpoints)SIM_SESSION_SECRET— signs session cookies- an OAuth provider (
SIM_GITHUB_*and/orSIM_GOOGLE_*); without one every visitor would share a single account - TLS in front, and
SIM_ADMIN_EMAILSset 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).