One EV charging local controller, with a web interface in front of it: a C# HTTP backend built on Hermod, and a frontend of HTML, SCSS and TypeScript bundled by webpack and embedded into the assembly - so the local controller is one binary to deploy and needs nothing installed beside it.
Nothing is rendered on the server. The browser loads one bundle and talks to the controller over a JSON API and one Server-Sent Events stream.
browser ── GET / the SPA stub and the bundle
── POST /ext/login the session cookie
── GET /api/v1/auth/me what that account may do here
── GET /api/v1/configuration what the controller is made of
── GET /api/v1/logs what happened up to now
── GET /api/v1/events and everything from now on (SSE)
A local controller sits between a CSMS above it and the charging stations below, and is usually the only thing in a car park with a keyboard within reach of it. That is what the web interface is for: it is the one place where somebody can see what the box is doing without having a back end to ask.
Everything a running local controller is before it is a local controller -
its log, its configuration file, name resolution and the time, who may sign in,
and the HTTP server all of that sits behind - is not here. That is
WWCP_Node, the part a
vehicle, a charging station and a local controller share, and
LocalController is one WWCPNode with an OCPP node, a port for the charging
stations below it and a line up to the CSMS above: its sections - ocpp,
ocppServer, csms - go into the same configuration file, and its routes into
the JSON API every node has below /api - the node's NodeHTTPAPI, with the
CSMS and the charging station port on top.
| Page | What it changes | Permission |
|---|---|---|
| Configuration | nothing - it answers "what am I running" | configuration:read |
| DNS client | the name servers and how they are asked; a test lookup, of all of them or of one | dns:edit, dns:run |
| NTS client | the time servers and the rules for believing them; a synchronisation, and a test of one server step by step | nts:edit, nts:run |
| SSH server | whether the command line is served over SSH, on which port, and whether passwords open it - in effect at once; its host key, who is connected, the keys of the accounts and what it offers | ssh:edit |
| Your account (the name at the foot of the menu) | your own name, e-mail address and how to reach you; your API keys and SSH keys - added, switched off and on, removed | your own account, whatever the role |
| CSMS connection | where the controller reports to, how it dials, and what it signs in with | csms:edit |
| Certificate store | the TLS roots of the servers the controller connects to, and their certificates - certificates alone, no private key | certificates:edit |
| Identities | who the controller is as a client, each with its private key: the TLS identity it signs in to the CSMS with | certificates:edit |
| Charging stations | the server the charging stations connect to: its port, the security profiles it accepts, the names it is reachable as, what it logs | stations:edit |
| Logins and groups | which charging stations may sign in, with what, and what their group allows them | stations:edit |
| Server certificates | the keys this controller presents, and the certificates that answer them | certificates:edit |
| Accepted chains | which certificate authorities a charging station may be vouched for by | certificates:edit |
| Logs | nothing - it reads | anybody signed in |
Looking at a page takes read on the resource in its column: dns:read for
the DNS client, stations:read for the charging stations. The clock, the log
and the event stream are for anybody signed in. Which roles hold what is under
"Who may open it" below.
Everything on the DNS and NTS pages takes effect the moment it is saved, for
everything inside the controller that resolves a name or reads a clock, and is
written to configuration.json in the same breath - the file first, because a
change that was applied but not written down disappears at the next start
without anybody noticing.
What this controller says it is in OCPP - its node id, vendor, model, serial
number - is read from the ocpp section of that file at the start and is
deliberately not changeable while running: an identification is what a CSMS
knows this controller by, and changing it under a live connection would not
rename the controller, it would make it a second one nobody is talking to.
The line up to the CSMS is dialled when the controller starts, and kept up by
the WebSocket client it was dialled with. A CSMS that is not there yet, or goes
away later - restarted, deployed again - is dialled again by itself: after
reconnectInitialDelay seconds of the csms section at first, and twice as
long after every attempt that fails, up to reconnectMaxDelay. So is one that
answers "not yet" - 408, 429, or a 5xx from a proxy whose CSMS is still
starting - though not before a Retry-After it sends, five minutes at most. An
answer that means no, such as a wrong password, is not asked again before the
controller is restarted, whether it comes at the start or on the way back. The
CSMS page says which of these it is, and since when the line is up.
Security profile 3 signs in with a TLS identity of the certificate store -
looked after on the Identities page, chosen on the CSMS page, and kept as clientCertificate in the csms section
by its handle - rather than with a password; one that is not chosen, not in the
store, switched off or out of its validity is said so, and not dialled with.
Over TLS, the CSMS's certificate is judged by the node as a time server's is:
issued for the name it is dialled at, and chaining to a root this machine
trusts or to a TLS root of the store kept for every use. What it was believed
with is remembered in known-servers.json, and another certificate than before
is said. A CSMS reads who is dialling from the last segment of the address, as
OCPP has every client say it: wss://csms.example.org/ocpp/lc001.
From the repository that has this one as a submodule (LocalControllerCLI):
dotnet run --project LocalControllerCLI
At the first start there are no accounts, so the controller makes one up for
the user root, keeps it under accounts/ and prints the password once:
┌─ First start: there were no accounts, so one was made up for you ─────────
│ user root
│ password QBDD77Lc7HseB-xORuuw8RpX
│ It is shown here once and kept only as a hash. Write it down.
└───────────────────────────────────────────────────────────────────────────
Then open http://127.0.0.1:2350/ and sign in.
Recommended for the first start: bring root your own SSH key, for the
command line over SSH -
dotnet run --project LocalControllerCLI -- --authorize-ssh-key root=C:\Users\you\.ssh\id_ed25519.pub
Without it, the first start makes up a key pair for root and prints its
private key once, below the password, from -----BEGIN OPENSSH PRIVATE KEY-----
to the END line, to be saved as a file only you can read. See
LocalControllerCLI
for signing in with it.
Port 2350 and not 2348: an OpenChargingCloud charging station uses 2348 and 2349, and a controller and a station are often tried out on the same bench.
dotnet build builds the frontend too: LocalController.csproj runs
npm ci (when Frontend/node_modules is missing or older than
package.json or package-lock.json) and npm run build
(only when something changed under Frontend/src, or under
libs/WWCP_Node/Frontend/src, which holds what the web interface of every
kind of node shares and is bundled in as @node/...), then embeds every file of
Frontend/dist as a manifest resource named
cloud.charging.open.LocalController.HTTPRoot.<path> - which is what Hermod's
EmbeddedContentSource reads and MapSinglePageApplication serves.
dotnet build the whole thing
dotnet build -p:SkipFrontendBuild=true backend only, reusing the existing dist/ -
and where there is none, no web interface
at all, which is a warning and not an error
npm run watch (in Frontend/) rebuild the bundle as it is edited
npm run typecheck (in Frontend/) tsc --noEmit
While editing the frontend, start the controller with --frontend libs/LocalController/LocalController/Frontend/dist so that it serves the
directory npm run watch writes into: a reload in the browser then shows the
change, without rebuilding the C# side.
dotnet test libs/LocalController/LocalControllerTests
They start real controllers and talk to them over HTTP the way the browser does: the bundle is served, the sign-in works, a change to the name servers reaches both the shared DNS client and the file, the log filters, the event stream delivers, and a controller that is told to stop stops.
What of that every node has to answer alike is WWCP_Node's conformance suite,
NodeConformanceTests in WWCP_Node_TestKit, which
LocalControllerConformance runs against a local controller - see
WWCP_Node's README.
What the node below does without a web interface in front of it - its log,
its clock, its configuration file, its start - is tested in WWCP_Node's own
suite, once for every kind of node. The rest is what only a local controller
does: its CSMS, its station port, its OCPP identity and its section of the
file, the roles and resources that are its own, and the name its log files
go by.
Each test gets a controller of its own, on a port from the kit's TestPorts -
one the operating system has just confirmed is free, and that no other test
of the run has been handed - and with its own directory for the two files a
controller writes: so they neither fight with each other nor with a
controller somebody has running on 2350 while they work.
They never touch the network. The configuration written before each controller is built switches the time client off, which is what stops the clock check from being scheduled at all; the conformance suite's few tests that need it on hold it to a server on this machine that nothing answers on. The DNS client is only ever asked what it is configured as. A test suite that needs a name server to answer is a test suite that fails on a train.
Both are read from configuration.json, in the same two sections the vehicle,
the charging station and the energy meter use, so one file can be written once
and copied between them:
{
"dns": { "enabled": true, "servers": [ "192.168.1.1" ] },
"nts": { "groups": {
"legal": { "enabled": true,
"servers": [ "ptbtime1.ptb.de", "ptbtime2.ptb.de",
"ptbtime3.ptb.de", "ptbtime4.ptb.de" ],
"minServers": 2,
"checkEverySeconds": 900,
"legalTimeAuthority": "PTB" }
} }
}That dns block is one name server, asked over UDP on port 53. An entry of its
servers is an address or a host name, or an object saying more than that -
the form the DNS page writes the list back in:
{ "address": "192.168.1.1", "port": 53, "transport": "UDP", "queryTimeoutSeconds": 2 }udp://192.168.1.1:53 is how the log names a name server, not a form the file
takes. A file saying it is refused at the start, with the entry named.
That nts block is what a local controller asks when the file says nothing at
all: one group of time servers, legal, the PTB's four, of which two have to
answer. Further groups stand beside it under groups, each with servers of
its own; a section that names its servers outside groups - the flat form of
older files - is refused at the start, and says to move them into
nts.groups.legal. Naming them changes nothing;
it is written out here because a file that names its time servers is a file
somebody can check.
Every key of the section, and what it is when absent:
| Key | Default | |
|---|---|---|
enabled |
true |
whether to ask at all |
servers |
the four above | a list, see below |
minServers |
2, or all of them when fewer |
how many must answer for the group to have a time |
maxDeviationSeconds |
60 |
how far apart they may be before it is written down |
hostname |
- | one server instead of a list |
ntsKEPort, ntpPort |
4460, 123 |
for that one server |
timeoutSeconds |
10 |
per request of that one server |
checkEverySeconds |
900 |
how often the clock is checked |
legalTimeAuthority |
- | who the operator says stands behind it |
legalTimeToleranceSeconds |
1 |
how far off the clock may be |
legalTimeMaxAgeSeconds |
3600 |
how old the last check may be |
An entry of servers is a host name, or an object saying more than the name:
{ "hostname": "time.local", "priority": 0, "ntsKEPort": 4460, "enabled": true }Servers sharing a priority are one band and are asked together; a lower priority is asked first. The four above share priority 0, because they are peers - putting them in separate bands would say something about them that is not true.
A section naming a single hostname and no list becomes a group of one, which
is what every file written before there were groups says, and it keeps working.
A group of one is held to a quorum of one, and a section asking two of it is
refused.
A section that is absent is not a section set to nothing: it means the file has
no opinion, and what the constructor was handed stands. The same holds key by
key - a section mentioning nothing but enabled leaves the servers alone
rather than quietly reducing four to one, and one mentioning nothing but
minServers or maxDeviationSeconds holds the servers the controller already
has to it. A quorum those servers could never reach is refused: at the start,
before anything is asked, and over the API, before anything is written into
the file - as is a save that would leave the file one the next start refuses.
A host name written back into this file carries the root label -
ptbtime1.ptb.de. - because that is the absolute form it was parsed into, and
not a stray character. What the controller prints for somebody to read drops it
again.
A time server, and a name server asked over TLS or HTTPS, may be held to a
certificate or a root, and may learn one on first use - see WWCP_Node's README.
What is learned is written into the server's entry at the first key exchange or
handshake after a save, mostly with the NTS or DNS page still open. So the pages
send every server back with what they showed it held to, under pinsAsShown:
their next save keeps what was learned in between, and still takes away a pin
that was shown and removed there.
LocalController takes a TimeProvider as its last constructor parameter and
hands it to everything of its own that asks what time it is: the timestamp of
every log entry, CreatedAt and the uptime the status resource reports. The
system clock by default; an NTS-disciplined or a fake one where a test says
so. The sign-in sessions are not among them - they belong to the HTTPExt API
and run on its clock.
It is the first thing the node below assigns, before the event log is built, because the log stamps its entries with it - a clock set afterwards would leave the log reading the system one, which is a log that cannot be held against anything.
sealed class FixedClock(DateTimeOffset Start) : TimeProvider
{
public DateTimeOffset Now { get; set; } = Start;
public override DateTimeOffset GetUtcNow() => Now;
}
var clock = new FixedClock(new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero));
var localController = new LocalController(TimeProvider: clock);
var at = localController.CreatedAt; // 2000-01-01T00:00:00Z
clock.Now = clock.Now.AddHours(13);
var up = localController.TimeProvider.GetUtcNow() - at; // 13 hours of uptimeThe clock is never set from NTS. Every fifteen minutes the controller asks its time servers what time it is, measures the difference and reports it - and leaves its own clock exactly where it was. Everything below this controller reads the time from here, so a jump backwards would put two meter readings out of order in a record written somewhere else entirely, with nothing in it to say why.
GET /api/v1/clock is that measurement, and the one word it never
guesses is "legal": that needs a claim the operator wrote into
nts.legalTimeAuthority, a check against those very servers, a recent one, and a
small difference. It names the group it is checked against, its servers and how
many of them have to answer. Any of those missing and the answer says unverified and
names which one in why.
Every entry carries a timestamp, a level (debug, info, notice,
warning, error, critical) and any number of tags (http, ocpp, dns,
nts, web, auth, ...). The Logs page filters on both - the level counts as
a tag, so critical and ocpp can be picked together.
Anything in the local controller can write to it:
localController.Log.Warning("The CSMS did not answer the BootNotification.", "ocpp");What the libraries below write through Illias' DebugX lands there too,
tagged trace plus whatever the controller's own table,
LocalController.TraceTags, finds in the text: OCPP going past in both
directions, and which side a line is about - the CSMS above or a charging
station below. That works in a debug build only: Debug.WriteLine carries [Conditional("DEBUG")], so
a release build of those libraries compiles the calls away. --no-trace
switches the bridge off.
A program that reads commands on the same console hands the log a way to write around the line being typed, so that an entry arriving mid-word neither lands inside the command nor waits for it:
localController.ShareConsoleWith(cli.WriteBlock); // line off, entry whole, line backThe few things the log says on stderr about itself - a listener that failed, a log file that cannot be written - go around the line the same way.
Three places keep it, because they answer different questions. The console
shows it to whoever started the controller, at the level they chose; the Logs
page keeps the last two thousand entries; and a LogPath handed to the
constructor writes every entry, down to the debug ones, into one file per UTC
day below it - localcontroller-2026-09-24.log, appended to and flushed after
each entry. LocalControllerCLI does that below logs/ unless told
--no-log-file, since the other two are gone with the process. Nothing is
ever deleted. A file that cannot be written is said once on stderr, and the
file says how many entries it missed once it can be written again.
The entries are numbered and the number only ever grows. A browser loads a
snapshot from /api/v1/logs, which says how far it reaches, and then applies
everything newer from /api/v1/events - so a reconnect that replays a few
cached events costs bytes and nothing else.
The stream asks, before every entry it sends and at every heartbeat, whether whoever opened it would still be let in. Once the session it was opened with has ended - signed out, expired, or taken back with the account's others - the stream ends too, without the entry, and the browser's next try is answered with a 401. One opened with an API key ends the same way once the key is revoked or has run out, and one opened with Basic auth once its password has changed.
The node's store, with the kinds of TLS in it and none of ISO 15118's, which are
a vehicle's: the TLS roots a server this controller connects to may chain
to - a time server, a name server over TLS or HTTPS - beside the ones this
machine trusts; the server certificates of such servers, kept so that one
can be held to its fingerprint; and the TLS identities this controller
presents itself, each with its private key - the one it signs in to the CSMS
with under security profile 3 among them. Where the web interface is served
over HTTPS from the store - "web": { "tls": { "certificate": "store" } } in
the configuration file, or --https - it also keeps a TLS server
identity, who the web interface is. --import-certificate tlsServerIdentity=<file> puts one in with --https on the same command line;
where only the configuration file asks for it, it goes in on the Server
identities page or by copying it in. The store lives in certificates/ beside
configuration.json, one file per certificate and an index.json saying what
each is called, whether it is switched on and what it is kept for; a file
copied into the right directory by hand is taken in at the next start. One
certificate may be kept as several kinds - a TLS root and a kind made up, say.
It is looked after on two pages, and on a third where the web interface is served over HTTPS from the store. Certificate store keeps certificates alone, with no private key: the TLS roots and the server certificates - an upload there leaves a key in the box out, and says where it goes. Identities keeps who the controller is as a client, each with its key: the TLS identities, and the one the CSMS connection signs in with is marked there. Server identities, in the menu only then, keeps who the web interface is, with its key - not to be mixed up with Server certificates, what the charging station port presents, which is a store of its own. Each page has three tabs: by usage, every certificate once, and the upload, where certificates are pasted or files dropped and every one of them is kept as every kind ticked.
A TLS root and a server certificate are told what they are for - the name servers, the time servers, or every use - at the upload and later: a root kept for the name servers alone vouches for no time server, and only a root kept for every use vouches for the CSMS. A local controller names no listeners an identity could be shown on some of and not others, so the page offers it no uses of its own; any certificate may be marked with a usage made up, for a configuration or code to name later. The dialog in which a time server or a name server is pinned offers the store's certificates for its service, and a pinned fingerprint the store keeps is named by its label.
It is the node's store and every node's routes, the vehicle's among them -
GET and POST /api/v1/certificates, POST /api/v1/certificates/inspect,
POST /api/v1/certificates/reload, and GET, PATCH and
DELETE /api/v1/certificates/{id} - at certificates:read to look and
certificates:edit to change. The charging station port's own keys
and the chains it accepts from charging stations are not in it: they keep the
stores of their own they have always had, on the pages "Server certificates" and
"Accepted chains" - ocpp-server-keys/ and ocpp-client-trust/ beside
configuration.json. Like the node's store, the accounts and the logs, both
are readable by the account the controller runs under alone - 0700 on Unix, on
Windows an access list of that account, SYSTEM and the administrators - and
what an older controller left open is taken back at the next start; where the
file system cannot do that, the log says so.
"Server certificates" has three tabs as well: the keys and their signing
requests, every certificate once by name or by fingerprint with the key it is
for, and an upload that takes any number of certificates in at once - renewals
for several keys, say - each under the key it belongs to, which the controller
reads from the certificate itself (POST …/ocpp-server/certificates/inspect
and …/upload). A private key that comes along in a file is left out of the
box and its certificate refused: the key is made here and never arrives.
The private keys are kept unencrypted, as the vehicle's are, and the page says so as soon as there is one. The identity the CSMS connection signs in with is marked on the page, and is not deleted until another one is chosen - switching it off is what taking it out of service usually means.
A change that is fine in itself, and that its file cannot take, is answered 500 with why: the charging station server and the CSMS connection in the configuration file, the credentials of the line up, the logins and groups, the port's keys and certificates, and the accepted chains. What is in effect stays as it was, and a chain written before what is said of it could not be is taken away again, rather than accepted at the next start. These were answered with the status of a refusal, and a full disk was a station "not found". What was wrong with a change is answered as it was; the node's own routes answer the same way.
A key whose files could not all be written - itself, its signing request and its description - is taken away again as well, rather than read at the next start as a key nobody asked for. A certificate is written beside the one it replaces and moved over it, and so is what is said of an accepted chain when it is renamed or switched on or off: a renewal or a change the disk runs out in leaves what was there whole. Written in its place, it cut that off, and at the next start the key or the chain was not read at all.
A key or a chain is taken away whole or not at all: its files are set aside,
as *.removed, before any of them is deleted, and put back where one of them
cannot be - held open by somebody, as Windows keeps a file then. Deleted one
after the other, the files before that one were gone, and the key or the
chain was listed until the next start and gone after it (found by the
charging station). A file that cannot be put back, or deleted once all are
aside, is left over under its *.removed name, which the next start does not
read, and said in the log.
The accounts are Hermod's HTTPExtAPI, mounted at /ext and kept under
accounts/: the users, their passwords as PBKDF2-SHA256 PHC strings, the
sessions and the API keys. Signing in happens at POST /ext/login - that is
the only place that can check a password - and the cookie it sets is what this
controller's own API reads. Basic auth and API keys work just as well, because
Hermod offers all three.
What somebody may do comes from the user groups they are in. Each group is one
role, under the same name, and a role is a list of permissions, each an
operation on a resource - written dns:edit. That is the model of every node,
described in WWCP_Node under
"Who may sign in"; the operations are read, edit and run, and the
resources are the node's configuration, dns, nts and certificates and
the two a local controller adds: csms, the line up to the CSMS, and
stations, the charging stations and which of them may sign in.
| Role | May |
|---|---|
viewer |
read everything: *:read |
cpo |
that, and run the site: change the name and time servers and ask them (dns and nts, edit and run), and change the line up to the CSMS and the charging stations (csms:edit, stations:edit) |
systemadmin |
everything this controller can be told, the certificates included |
The viewer and the administrators are the node's, the CPO is the local controller's. The certificates are the one resource only the administrators may change: somebody who can add a certificate authority can let in a charging station that nobody issued a password to.
The roles section of configuration.json adds roles, or says differently what
one of them may do - and a role there that names a resource this controller
does not have stops the start, rather than quietly granting nothing:
"roles": { "support": [ "dns:read", "stations:read" ] }The groups are made at every start, so a group deleted by hand does not leave a role nobody can ever hold again. A group that is none of these, and none the file names, grants nothing - a role this controller has never heard of is a role it cannot enforce.
Membership is asked on every request rather than remembered at the sign-in, so taking somebody out of a group takes effect on their next request. The permissions travel to the browser, spelt out resource by resource, so a page can grey out what somebody may not do - a courtesy, not a lock: every request is checked again on arrival.
Behind a reverse proxy every request comes from the proxy. The web section
names the proxies that are believed when they say who the client is, and the
one header they say it in - X-Forwarded-For unless it says otherwise; an API
key bound to addresses opens the door only from them, and password guesses are
counted per client:
"web": { "trustedProxies": [ "127.0.0.1", "::1" ] }From anybody else, and without the section, the client is the TCP peer, whatever its headers say. How the header is read is described in WWCP_Node under "Who may sign in".
The sign-in's password, every API key and the session cookie travel with every
request, and beyond the loopback over plain HTTP they cross the network in the
clear; the controller then says so in its log, below its banner and above every
page to its administrators. The web section is the way out: HTTPS of the
controller's own - with the store's TLS server identity, or certbot's PEM
files, read again when they change - with HSTS and a port that redirects to it,
or the reverse proxies in front that do the HTTPS and are believed when they
say so. --https, --https-cert <file> with --https-key <file>,
--redirect-port <number> and --trusted-proxy <address> say the same and win
over the file - see
WWCP_Node's README.
This software is Open Source under the Affero GPL 3.0 license. We appreciate your participation in this ongoing project, and your help to improve it and the e-mobility ICT in general. If you find bugs, want to request a feature or send us a pull request, feel free to use the normal GitHub features to do so. For this please read the Contributor License Agreement carefully and send us a signed copy or use a similar free and open license.