A simulated three-phase energy meter that speaks Modbus/TLS (mbaps, the registered port 802) and nothing else: there is no plaintext listener, every peer must present a client certificate, and what that peer may do is decided by the SunSpec role carried in an X.509v3 extension of its certificate.
This is the library. It is one class - ModbusTLSEnergyMeter - that a test, a
service or another program can host without starting a process:
await using var meter = new ModbusTLSEnergyMeter(
SerialNumber: "EnergyMeter01",
ServerPfxPath: "pki/server.pfx",
ServerPfxPassword: "demo",
ClientCACertPath: "pki/issuing-clients-ca.crt",
MeterMode: SunSpecMeterMode.ImportOnly,
HTTPS: true
);
await meter.StartAsync();Nothing listens until StartAsync, everything it does is said through its
ILogger and its properties, and DisposeAsync gives both listeners back.
ModbusTLSEnergyMeterCLI is the command line around this, and
is the shortest way to try any of what follows.
It is meant to stand in for the meters that ChargingStationCLI and LocalControllerCLI need for their own use cases.
Modbus/TLS is what a charging station or a local controller speaks to this meter, and what a peer may do there is decided by the role in its client certificate - a machine-to-machine decision, made per request, with no notion of a person. The HTTP side is where a person signs in to see and change what the meter is, and what they may do is decided by the groups their account is in.
Neither one's rights are expressible in the other's vocabulary, which is why there are two ports and not one.
A site with a load and a photovoltaic generator, and the meter reads whichever part of it the mode register says it is in front of:
| Mode | 40094 | Where the meter sits | What it reads |
|---|---|---|---|
net |
0 | at the grid connection point | load minus generation: signed power, both counters move |
import |
1 | in front of a load - what a charging station's meter is | power never negative, only TotWhImp moves |
export |
2 | in front of a generator | power never positive, only TotWhExp moves, and zero at night |
Sign convention, which all of that rests on: positive real power is energy
flowing into the site (imported, the meter running forwards), negative is
energy flowing out of it (exported). The currents are magnitudes and stay
positive either way, the way a real meter reports them - the direction is in the
sign of the power alone. A is the total AC current, which is the three phases
added up; PhV is their average.
Both energy counters only ever grow, as a meter's do. Whichever way power is
flowing at the moment adds to one of them, and nothing subtracts from either.
What is added is what the reading says: after a step the counter has grown by
exactly |W| × Δt, the remainder of a step carried rather than truncated -
otherwise a 1200 W load, which is a third of a watt-hour per second, would be
lost entirely to a counter that can only add whole ones.
The load and the generation follow the time of day, so a meter left running
looks like a day: quiet at night, exporting around noon. SimulatedDayLength
compresses that day for a demo - it only speeds up those two curves, never the
counters, which always count real seconds so that anything watching in real time
can check them against the power it is reading.
The mode can be changed through either door, and both end up in the log the same
way: a Modbus client writes register 40094 with the role for it, or a person
uses PUT /api/v1/meter/mode and the web page over it. A value that is not one
of the three leaves the register as it was - a Modbus write can only be answered
with "illegal address", which would be a lie, so reading the register back and
finding the old mode is the older and plainer way of being told no.
The simulation lives in Hermod's SunSpecMeterDevice, which is also where it
can be stepped by hand (Advance) rather than by its own background task.
SunSpec Common Model 1 followed by a subset of Meter Model 213, based at 40000:
| Address | Contents |
|---|---|
| 40000 - 40001 | SunS marker |
| 40002 - 40069 | Common Model 1: manufacturer, model, options, version, serial |
| 40068 | unit address - protected |
| 40070 - 40071 | Meter Model 213 header, id and length |
| 40072 - 40076 | current: total, L1, L2, L3, scale factor (-2) |
| 40077 - 40081 | voltage: average, L1, L2, L3, scale factor (-1) |
| 40082 - 40083 | frequency, scale factor (-2) |
| 40084 - 40088 | power: total, L1, L2, L3, scale factor (0) - signed |
| 40089 - 40093 | energy exported, energy imported, scale factor (0) |
| 40094 | meter mode - commanded, 0 net, 1 import only, 2 export only |
| 40095 | reset energy - commanded, write 0xCAFE to clear both counters |
| 40096 - 40097 | end-of-models marker |
Everything not marked is read-only; a write to it is refused whatever the role. Measurements move once a second.
Addresses here are absolute, as SunSpec writes them. Clients that count registers from one - Hermod's own Modbus client among them - need the usual offset of 1.
Every client certificate carries exactly one role in the extension
1.3.6.1.4.1.50316.802.1, as an ASN.1 UTF8String - the Modbus.org PEN, per
[MBTLS] §8.4 and SunSpecTCP-29..31. The meter reads it during the handshake and
decides each request against it:
| Role | read | write commanded | write protected |
|---|---|---|---|
ReadOnlySunSpec |
yes | no | no |
GridServiceSunSpec |
yes | yes | no |
NetworkAdministratorSunSpec |
yes | yes | yes |
SuperAdministratorSunSpec |
yes | yes | yes |
A certificate without a role gets Modbus exception 01 for everything, as SunSpecTCP-32 requires.
The decision is taken in the TLS frontend, once per request, before a single Modbus byte reaches the device behind it.
Beside the Modbus/TLS listener there is an HTTP server, on port 2351 by default, where a person signs in to administer the meter. It is a node's: the accounts, the sessions, the log, the name resolution and the clock are WWCP_Node's, as they are in every OpenChargingCloud program with a web interface, and the meter adds its registers, its certificates, its signed readings and its roles.
A role is a user group of the same name - systemadmin, auditor, viewer,
guest - and that is what separates who may change this meter from who may
only watch it. What a role may do is data, as on every node: operations on
resources, written meter:edit (see The JSON API). What an
account may do is asked of its groups at every request rather than remembered
at sign-in, so that taking somebody out of a group takes effect on their next
click and not at their next sign-in.
At the first start there are no accounts, so one administrator is made and its
password reported once, through GeneratedUserId and GeneratedPassword, for
the host to print. That account is root, in the group systemadmin. Accounts
live under DataPath, in UsersAPI/users.db.
Three things are served, and each answers for itself because Hermod dispatches to the most specific of them first:
/ |
the web interface |
/ext |
signing in, users, groups - the node's HTTPExtAPI |
/api/v1 |
this meter's own JSON API |
Signing in is a POST to /ext/login, form-urlencoded with login and
password - where the web interface signs in, as every node's does - or to
/ext/auth/login with {"login": ..., "password": ...}. Either answers with
the session cookie that every resource below is read with.
A meter from before it was a node kept its accounts in UsersAPI/HTTPExtAPI.db
and said what somebody may do with a role in its organization EnergyMeter.
Started on the same DataPath, it renames the file to users.db before
anything opens it, and puts every account that is in none of its groups into
the group of the strongest role that account held there - IsAdmin into
systemadmin, IsAdminReadOnly into auditor, IsMember into viewer,
IsGuest into guest - once, before anybody can sign in. An account that is in
one of the groups already is left as it is: somebody put it there. The roles in
the organization stay where they are, and grant nothing any more.
The first administrator is not meant to be the only account. Under Configuration -> Accounts an administrator makes more, gives each a role, resets a password somebody has lost, and takes an account away again; everybody else finds their own account there and nothing else.
Three of the four roles change nothing, which is the reason the page exists.
Watching what a meter is doing - on a night shift, over the phone, for an
audit - should not need the account that can also clear the energy counters or
replace the certificate. viewer sees the readings, the configuration, the
certificates and the log and touches none of them; guest sees only the
readings; auditor additionally may ask a time server whether it answers, which
sends traffic and is therefore not folded into reading.
No password is asked for when an account is made: the meter makes one, shows it
once, and keeps it nowhere it could be read back. The person it was made for
replaces it at POST /ext/auth/password, which asks for the current one
first - the one thing an administrator's reset cannot ask for, and the reason
the two are different routes.
Two rules, and only two:
- The last administrator cannot be demoted or removed. A meter with none left cannot be given another one from a browser, cannot be given a new certificate and cannot be told which CAs to accept; the only way back is a text editor on its disk. Stepping down is allowed as soon as somebody else is an administrator, which is what handing a meter over looks like.
- A password is reset for somebody else, never for yourself. That route asks for no current password, because an administrator does not know it - pointed at your own account it would be a way for whoever finds an unlocked browser to take it over.
A role that changes ends every session of that account, and so does a reset: a
browser holding the old answer of /me would go on offering buttons that now
answer 403, and a reset that left the old session alive would not have taken the
account back.
The routes every node has are the node's: signing out and who is signed in, the
status and the clock, the configuration, name resolution and the time servers,
the certificate store, the log and its event stream, and a JSON 404 for any
other path below /api. They are WWCP_Node's NodeHTTPAPI, the same routes and
the same answers on every kind of node, and the meter's API adds what only a
meter has on top.
Everything below /api/v1 needs the session cookie, and each resource names
the permission it wants: an operation - read, edit or run - on a resource,
written meter:edit, as on every node. The resources are the node's -
configuration, dns, nts, certificates - and the meter's:
| Resource | read | edit | run |
|---|---|---|---|
meter |
the readings, the registers, the signed readings, the sessions | the meter mode, clearing the energy counters | starting and stopping a session |
configuration |
what the node is made of | ||
dns |
the name servers | changing them | looking a name up |
nts |
the time servers, and the clock | changing them | asking one: sync, test |
certificates |
the store and the signing requests | a key and its request, a certificate, a CA | |
keys |
the signing keys | making one, choosing the default, throwing one away | |
log |
the log, the log book, the event stream | ||
accounts |
who may sign in, and as what | making one, giving a role, resetting a password, taking one away |
What a person may do follows from the groups their account is in:
| Role | may |
|---|---|
viewer |
read everything but the accounts |
auditor |
what a viewer may, and nts:run |
guest |
meter:read |
systemadmin |
everything: every operation on every resource |
The viewer is the meter's own and narrower than a node's, which may read
everything: who may sign in to a meter is not something everybody who may look
at it has been told. Starting a session is meter:run rather than part of
meter:edit, because it is what a station operator does every day, and
clearing the energy counters is the one thing here that destroys something.
Certificates and signing keys are each a resource of their own and not part of changing network settings, because they are bigger things than any of those: which certificate this meter shows is who it says it is, which CAs it trusts is who may talk to it at all, and which key it signs with is what a bill can be checked against. Somebody who may repoint a name server has not thereby been handed the identity of the device.
The status needs a sign-in and nothing more, as on every node. An account in
several of these groups may do what any of them allows, and one in none of them
may do nothing else at all. A refusal names the roles that would have been
allowed - "This needs the viewer or auditor or systemadmin role." - so that it
also says whom to ask, and the log says what was refused and to whom, tagged
auth.
The roles are data, so the configuration file can add one or say differently
what one of the meter's may do, under roles - here for somebody who looks
after the time and nothing else:
{ "roles": { "timekeeper": [ "meter:read", "nts:read", "nts:run" ] } }A role from the file is listed with the others, can be given to an account like
them, and is held to exactly what the file says. Every start says in the log,
tagged security, what the file added or changed. A role that names a resource
this meter does not have stops the start - a typo would otherwise be a role
that quietly grants nothing - and so does one that tries to say what
systemadmin may do.
The names in the tables are what the API speaks. What a page shows is the
readable form - "Auditor" rather than auditor, and a role from the file by its
name - and it travels with the strongest role in /api/v1/auth/me rather than being
looked up, because every page that tells somebody what they may not do here
names their role in the same sentence and none of them should need a second
request to translate one word. The names of the roles in the organization from
before - IsAdmin to IsGuest - are still taken wherever a role is given, and
mean the group they were moved into.
| Resource | |
|---|---|
POST /api/v1/auth/logout |
sign out: the session ends, and its cookie with it |
GET /api/v1/auth/me |
who is signed in, their roles - the strongest as this meter spells it and as a person would say it - and their permissions, spelled out: meter:read, nts:run, ... |
GET /api/v1/status |
the version, how long it has run, the sessions and the log - and the meter's serial and both listeners |
GET /api/v1/meter |
the readings with scale factors applied, the mode, and what the simulated site is doing |
GET /api/v1/meter/registers?start=&count= |
the raw register block |
PUT /api/v1/meter/mode |
{"mode": 0|1|2} or {"mode": "net"|"import"|"export"} |
POST /api/v1/meter/energy/reset |
clear both energy counters |
GET /api/v1/configuration |
what the node is made of: its web server, its accounts, its log and its time |
GET/PUT /api/v1/configuration/dns |
how it resolves names |
POST /api/v1/configuration/dns/query |
{"name", "recordTypes", "server"}: look one name up |
GET/PUT /api/v1/configuration/nts |
where it reads the time |
POST /api/v1/configuration/nts/sync |
check the clock now |
POST /api/v1/configuration/nts/test |
{"host": "ptbtime2.ptb.de"}: ask one time server everything, step by step |
GET /api/v1/clock |
what time it is, and what that is worth - at the path every node has it at |
GET /api/v1/configuration/certificates |
the certificate Modbus/TLS clients are shown now, the CA the meter was started with, and the SunSpec roles |
GET /api/v1/certificates |
the certificate store: every certificate by kind, and what each listener shows now and next |
POST /api/v1/certificates |
put one in: {"kind", "content" (base64), "password", "label", "usages"} |
POST /api/v1/certificates/reload |
read the store's directory again |
GET/PATCH/DELETE /api/v1/certificates/{id} |
one certificate: rename it, switch it on or off, say what it is for, or take it out - refused while it is the only one a listener could show, or the last CA Modbus/TLS clients may be issued by |
GET/POST /api/v1/certificates/requests |
the keys made here and their signing requests; make one |
GET /api/v1/certificates/requests/{id} |
that request, as a file |
PUT /api/v1/certificates/requests/{id} |
{"pem": ...}, the signed certificate coming back |
DELETE /api/v1/certificates/requests/{id} |
throw a request and its key away |
GET /api/v1/signedMeterValues?format=&key= |
one reading, signed; ocmf or alfen |
GET /api/v1/sessions |
the charging session that is running, if one is |
POST /api/v1/sessions/start |
begin one; answers with the time and the public key |
POST /api/v1/sessions/stop |
end it; answers with one OCMF document holding both readings |
GET /api/v1/keys |
the signing keys, without their private halves |
POST /api/v1/keys |
make one: {"algorithm": "Ed448"} |
PUT /api/v1/keys/{id}/default |
sign with this one from now on |
DELETE /api/v1/keys/{id} |
throw one away, with everything it could still prove |
GET /api/v1/accounts |
who may sign in, and the roles that can be given out |
POST /api/v1/accounts |
make one; leaving out the password gets one the meter made |
GET /api/v1/accounts/roles |
what each role is called and what it grants |
PUT /api/v1/accounts/{id}/role |
{"role": "viewer"} |
PUT /api/v1/accounts/{id}/password |
a new password for somebody who lost theirs |
DELETE /api/v1/accounts/{id} |
take an account away |
GET /api/v1/logs?limit=&after=&tag= |
what happened, newest last |
GET /api/v1/logs/verify |
walk the log book on disk and check every line |
GET /api/v1/events |
the log as a Server-Sent Events stream |
A PUT writes the configuration file before the change takes effect, and
answers with the section as it now stands. Unknown paths below /api answer
with a JSON 404 rather than falling through to the accounts.
curl -c jar -X POST http://127.0.0.1:2351/ext/auth/login -H 'Content-Type: application/json' -d '{"login":"root","password":"..."}'curl -b jar http://127.0.0.1:2351/api/v1/meterGET /api/v1/meter answers with the registers made readable, and beside them -
marked as belonging to no register - what the simulated site is doing:
{ "total": { "name": "total", "voltage_V": 229.5, "current_A": 19.69, "power_W": -4525 },
"energy": { "exported_Wh": 11, "imported_Wh": 18 },
"meterMode": { "value": 2, "name": "export",
"description": "in front of a generator: exporting only" },
"simulation": { "load_W": 3225, "generation_W": 4525,
"timeOfDay": "2026-09-16T08:40:12+02:00", "dayLength_s": 600 } }The load and the generation are what the mode selects between, so a page or a client that has only the result cannot say why a meter in front of a generator is reading zero at three in the morning.
State-changing requests are refused when a browser says they came from another site, and 403 is used rather than 401 where signing in again would not help.
The same frame a charging station wears - a menu on the left, a page on the right - so that somebody looking after both does not have to learn two interfaces.
It lives in ModbusTLSEnergyMeter/Frontend: HTML, SCSS and TypeScript, bundled
by webpack into Frontend/dist, which the project file embeds into the assembly
as manifest resources. A meter is still one binary to deploy and still needs
nothing installed beside it; what changed is that the page has a toolchain
rather than being one file with everything inside it.
npm ci # once
npm run build # or: npm run watch
npm run typecheckdotnet build does this by itself when anything changed below Frontend/src,
or below WWCP_Node/Frontend/src - the WWCP_Node beside this repository in
libs/, which holds what the web interface of every kind of node shares and
is bundled in as @node/... - and -p:SkipFrontendBuild=true leaves it alone.
Pages: the meter and what it is measuring, the DNS client, the NTS client with the state of the clock, the certificate store with the signing requests, the signing keys, the charging sessions, the accounts, the log as it happens, and the metrological log. On the DNS page name servers can be added and removed and timeouts changed. The NTS page is the group of time servers: each one is added, changed, switched off or deleted on its own, in a dialog, and tested on its own in another, step by step; the rules the group is held to, who stands behind legal time and the clock as it stands have a card each; Sync now asks them all. Each save writes the configuration file before the change takes effect, and sends only what its form shows - the rest of the section stays as it was. Accounts is the one page everybody signed in can reach, because everybody has a password of their own to change; what an administrator additionally sees there is everybody else's account. What somebody may not do is not offered: the controls are absent rather than disabled-and-refused, though every request is checked again on arrival, so a browser that puts them back gains nothing but a 403.
Every URL that is not one of the APIs and does not look like a file of the bundle gets the stub with status 200, which is what makes a reload on a deep link and a bookmark to one work. A URL that does look like a file and is not one gets a real 404: a mistyped script tag must not hand the browser HTML to run.
One store for every certificate the meter shows and believes - the node's, as on
every OpenChargingCloud node - under <DataPath>/certificates/, with an
index.json beside the files and each certificate addressed by a short handle,
the first 16 hex digits of its SHA-256 fingerprint, rather than by a path. It
keeps four kinds:
| kind | what it is | below certificates/ |
|---|---|---|
tlsIdentity |
what a listener of this meter shows, with its key | tls/identity/ |
clientRoot |
a CA Modbus/TLS clients are issued by | roots/clients/ |
tlsRoot |
a root a time or name server this meter asks may chain to | roots/tls/ |
tlsServer |
a time or name server's own certificate, kept to be recognised | tls/servers/ |
An identity is told which listener it is shown on, modbus or web, and what
each of them checks decides where its certificate comes from:
modbus |
web |
|
|---|---|---|
| shown to | a charging station or a controller | a browser |
| issued by | a device PKI | wherever the operator's web certificates come from |
| checked against | the CA that peer has pinned | the browser's own trust store |
| at the first start | the certificate this meter was started with | one this meter signs for itself |
They are not the same certificate: one both would accept would have to be issued
by a CA that is both pinned by the charging station and trusted by the browser,
and nothing issues such a thing. An identity never told is shown on both. A TLS
root is told the same way which servers it vouches for, dns or nts, and a
client root is for Modbus/TLS alone - the store refuses an identity "for dns" and
a root "for web" where they are typed, rather than keeping either to mean
nothing.
Private keys are kept unencrypted, readable only by the account the meter runs
as where the platform says so, and the store says so in the log at every start.
A certificate copied into its directory by hand is taken in at the next start,
or at POST /api/v1/certificates/reload; every change of what the store holds
goes into the log book, tagged security. On the page, all of it is under
Configuration -> Certificates.
A meter whose certificates were in stores of its own - certificates/modbus/,
certificates/web/ and certificates/trust/ - finds them in the node's store
at its next start: each certificate with its key as an identity for the listener
it was for, keeping its note as its label; each key that asked for one as a
signing request, answered by it; and each accepted CA as a client root, switched
off where it was. What was moved is put below certificates/moved/ rather than
deleted. What cannot be moved - a key of a kind this platform cannot hold with
its certificate, such as Ed448 - stays where it is and is named in the log at
every start.
The private key is made in the meter and never leaves it. What goes out is a PKCS#10 request; what comes back is a certificate, which is checked against the key that asked for it before it becomes an identity - a certificate this meter has no key for is no use to it, and finding that out at the next handshake would be finding it out as an outage.
POST /api/v1/certificates/requests
{"listener": "web", "subject": "CN=meter7.lan, O=Acme", "dnsNames": ["meter7.lan"]}
GET /api/v1/certificates/requests/<id> -> the .csr, as a file
PUT /api/v1/certificates/requests/<id> {"pem": "-----BEGIN CERTIFICATE-----..."}
or the same three steps as three controls on the page. A request is kept once it is answered, with its key: a certificate that runs out can be renewed for the same key by sending the same request again, and the second answer is a second identity that takes over when it becomes valid. Throwing a request away throws its key away with it; what was put into the store from it stays there.
The kinds of key come from Hermod's KeyAlgorithm, and of its list the ones the
store can keep together with their certificate and a TLS stack can present:
ecdsa-p256, ecdsa-p384, ecdsa-p521 |
ECDSA on the NIST curves, ecdsa-p256 when nobody says. ecdsa-p521 is secp521r1 - there is no secp521r2 |
rsa-2048, rsa-3072, rsa-4096 |
RSA |
Hermod can make more - Edwards curves, ML-DSA, SLH-DSA - and a CA could sign them. But a certificate for such a key cannot be held together with its key on this platform, so it could neither be kept in the store nor shown, and asking for one would only lead to a certificate that goes nowhere.
Whether a certificate can then be shown is still found out by doing it, not from a list: it depends on the operating system's TLS stack, on the runtime and on the year. Hermod does one TLS handshake against itself, once per algorithm, and an identity this meter cannot present is kept and never shown.
The old spellings the meter's own store used before Hermod had a list - ec256,
rsa3072, mldsa65 - are still read, so nothing is lost over a rename. Nothing
writes them any more.
Of the identities for a listener that are switched on and valid at this moment, the one whose validity began last. Nothing else decides it, and nothing has to be pressed:
- Both listeners ask the store at every handshake. A certificate that is valid now is shown to the next peer that connects - no restart, and existing connections are not disturbed.
- A certificate put in today that becomes valid in two days is simply not the answer until then, and is the answer from the second it is. The page says which one is next and when it takes over.
- "Newest" is by
notBeforeand not by when it was put in, because what a certificate says about itself is the thing both ends of a handshake can check. - An expired one stops being shown. Once a minute the meter looks again, so that a rollover is written down when it happens rather than whenever the next peer turns up - on a quiet meter that could be the following afternoon.
A certificate is kept with its key until it is taken out, so the one this meter was running under last month can still be pointed at. The one being shown cannot be taken out, switched off or given to the other listener while it is the only one that could be: a listener with nothing to show refuses every handshake, and doing that to oneself through a web page is not a mistake worth making possible.
The client roots are the CAs a Modbus/TLS client certificate may be issued by - more than one, on purpose. A meter in the field is reached by peers whose certificates were issued by different people, and even with one issuer, replacing it happens while both the old and the new one still have to work. A single pinned CA makes that a flag day.
A client root is usually not a root at all but the issuing CA below one that signs the clients and nothing else: the root above it signs the devices as well, and would let any of them in. The Modbus/TLS listener judges a client against the CA that issued it, so that is what is kept - and what the node's store takes as a client root, as long as it is a CA.
Client roots are asked for at every handshake as well, so adding or switching one off takes effect on the next connection. The last one that is switched on cannot be taken out or switched off: a meter that accepts none refuses every Modbus/TLS client.
This is only about Modbus/TLS. The web interface authenticates nobody by certificate; there a person signs in with an account.
A certificate signed by an issuing CA under a root is no use on its own: a peer
that holds only the root cannot build a path to it. So whatever came in the PEM
alongside the certificate - in the PEM that answers a request, or in the
PKCS#12 the meter was started with - is kept in the store with it and sent with
it - both listeners build an
SslStreamCertificateContext from the leaf and those intermediates, once per
distinct chain and with offline: true, so that building it never reaches for
the network. A server that pauses a handshake to fetch something is a server
somebody can hold still by not answering.
A root that turns up in the file is dropped rather than sent: bytes on the wire that change nothing. So is the leaf, if it appears twice.
This works on Linux and not on Windows, and the difference is not in this
code. On Linux the intermediates go out as given - measured, not assumed: the
same certificate that arrives alone from a meter on Windows arrives with its
issuing CA from one on Linux, as openssl s_client -showcerts counts them.
Windows builds the chain it sends inside SChannel, from that machine's own
certificate stores, and ignores what a program hands it; the intermediates have
to be installed in the local computer's intermediate CA store instead. A meter
started on Windows with intermediates in its store says so in its log at
startup, rather than leaving it to be discovered as a handshake that fails for
no visible reason.
A reading over the JSON API is a number this meter says it measured. A signed one is a number somebody can still check in a year, against a key that was this meter's before the reading was taken.
The documents are written here and read by ChargyCore.NET, which is the same code that verifies real charging sessions under the German calibration law. Everything below was established by producing a document and handing it to that reader.
At the first start the meter makes itself a signing key and says so:
[notice signing] No signing key yet, so this meter made itself one:
'20260916-084817-ce51ba' (ECDSA-P256), fingerprint 06acf9619045e01e.
It is the identity of this meter and does not change.
It is kept under <data>/keys, apart from the TLS certificates and deliberately
so. A TLS key says "this listener is this host" for the length of a connection
and is replaced whenever a CA issues a new certificate; this one says "this
meter measured this", and has to go on meaning that for as long as anybody may
want to check a reading.
More keys can be made, because the formats disagree about cryptography and cannot be talked out of it:
| Algorithm | |
|---|---|
ECDSA-P256 |
OCMF's own algorithm, and what a meter makes for itself |
ECDSA-P384, ECDSA-P521, ECDSA-secp256k1 |
the other curves OCMF names |
Ed25519, Ed448 |
Edwards curves; the payload is signed directly |
ML-DSA-44, ML-DSA-65, ML-DSA-87 |
lattice signatures, for a document meant to outlive a quantum computer |
ECDSA-secp192r1 |
only for Alfen, which parses no other curve |
The last one is 192 bits and nobody should choose it for anything new; it is here because the Alfen format carries a 25 byte compressed point and refuses everything else.
A key can be made, made the identity, and removed - never the last one, because a meter with no signing key can still measure and nothing it measures can be shown to have come from it.
curl -b jar "http://127.0.0.1:2351/api/v1/signedMeterValues?format=ocmf"{ "format": "OCMF",
"timestamp": "2026-09-16T08:48:17Z",
"ocmf": "OCMF|{\"FV\":\"1.0\", ...}|{\"SD\":\"3045...\",\"SA\":\"ECDSA-secp256r1-SHA256\",\"SE\":\"hex\"}",
"publicKey": { "publicKey": "3059...", "encoding": "hex", "format": "SubjectPublicKeyInfo" } }format is ocmf or alfen, and key names a key other than the identity.
OCMF calls a reading that belongs to no charging session a fiscal reading and
counts it in a sequence of its own, which is the "PG": "F12" in the payload.
The public key comes with the answer in the shape that format's reader wants it, which is not one shape: an OCMF reader hands an ECDSA key to a DER parser and expects a SubjectPublicKeyInfo, and hands an Ed25519 or ML-DSA key straight to the signature suite and expects the raw key. Getting that wrong produces a document that is signed correctly and reads as a forgery.
curl -b jar -X POST http://127.0.0.1:2351/api/v1/sessions/start -H 'Content-Type: application/json' -d '{"identification":"DEADBEEF01","identificationType":"ISO14443"}'{ "timestamp": "2026-09-16T08:48:18Z",
"sessionId": "20260916-084818-4e6b7b",
"startValue": 0.0,
"unit": "kWh",
"publicKey": { "publicKey": "3059...", "encoding": "hex", "format": "SubjectPublicKeyInfo" } }The public key is the point of answering at all: whoever gets it now can check the document that comes back at the end against a key they were given before the session began.
The start reading is kept here rather than handed out. A start reading on its own is a number saying a meter stood somewhere at some moment, which is not evidence of anything.
curl -b jar -X POST http://127.0.0.1:2351/api/v1/sessions/stop -H 'Content-Type: application/json' -d '{}'{ "timestamp": "2026-09-16T08:48:27Z",
"startValue": 0.0,
"stopValue": 0.003,
"energy_kWh": 0.003,
"ocmf": "OCMF|{...\"RD\":[{\"TX\":\"B\",\"RV\":0.0,...},{\"TX\":\"E\",\"RV\":0.003,...}]}|{...}" }One document with both readings in it, and not two documents. That is what makes it a charging session: two separately signed readings are two facts about a meter, and the energy between them is an inference somebody else has to be trusted to have drawn correctly. Here the subtraction is inside what was signed.
One session at a time, because this meter is one measuring point. Starting a second while the first runs is a 409 naming the one that is open, and the key the session started with is the key it is signed with at the end - a document whose two readings were signed by different keys is not one document.
A car left plugged in while the software is restarted is the ordinary case, not the strange one, so a session that was running is still running when the meter comes back:
[info meter] The energy counters came back where they were left: 5 imported, 0 exported (scale factor 0).
[notice sessions] The charging session '20260916-123213-dbc394' was running when this meter last
stopped and still is: started 2026-09-16 12:32:13Z at 0 kWh.
Two things have to survive for that to mean anything, and the second is the one
that is easy to miss. The session itself is kept in <data>/sessions/session.json
while it runs and taken away when it stops - a stopped session that came back
could be stopped a second time, into a second document for one charging session.
And the meter has to still be standing where it was. A real energy meter's
register is monotonic and survives losing power; most of what makes it a meter
rather than a sensor is that it does. This simulation held its counters in
memory, so every restart put them back to zero - invisible until something spans
a restart, and then a charging session that used a negative amount of energy. The
counters now live in <data>/meter-state.json and go back into the registers
before the first reading is taken.
Where that still fails, it says so rather than signing nonsense: if the counter has gone backwards past where a session began - cleared, or lost further than the last write - stopping it is refused and the session stays open until the counter passes its start reading again.
Every OCMF reading carries one letter saying how far the clock behind it can be
trusted. This meter writes S only when a time server has actually answered,
and I otherwise. Claiming a synchronised clock it does not have would be lying
about the one field of a reading that cannot be checked afterwards - see
Name resolution and the time.
Sessions sits beside the Meter rather than under Configuration: a charging session is something this meter does, not something about how it is set up. It shows whether one is running, starts and stops it, and signs a single reading on demand. The signing keys sit under Configuration next to the certificates, which is where somebody looking for "what this meter proves itself with" will look for them even though they are not certificates.
Two things are handed over on that page and never again: the public key when a session starts, and the document when it stops. Both come with a copy button, because a document that is not written down when it is shown is gone - which is the same rule the meter itself works by.
format=alfen writes the format of an Alfen charging station:
AP;0;3;APV7E5L6WT25QCJZSAMAPNF2PXMZ46UAJKZHJNXY;IKKLQAM6WIKM...====;J23QNXYVROMEN36...===;
Six fields, everything after the version base32 because the whole thing has to survive being printed on a receipt and typed back in by hand. The data set is 82 bytes, little endian throughout, with no separators: the layout is the specification.
Checked by the reader, not by the writer. A test here builds a record, and
then ChargyCore parses it back: every field comes back as it went in, the buffer
its verifier rebuilds is byte for byte the one that was signed, the signature
checks out over that buffer with ChargyCore's own curve and suite, and finally
AlfenCrypt01.VerifyMeasurement says ValidSignature about it. Five links, and
the last one is the one that counts: the four before it are this project
agreeing with itself.
That last link was missing for a while, and not because anything written here
failed it. VerifyMeasurement reaches from a reading to its measurement and
from there to the charging session, and ChargyCore's own constructors left both
of those null for anything they were handed - so it answered "Not an Alfen
measurement!", and one level up it rebuilt a buffer eight bytes different and
called a good record a forgery. Fixed there rather than worked around here.
What this is not, and the answer says so: an Alfen adapter. The format has fields for one - an adapter identification, its firmware version and that firmware's checksum - and this fills them from the meter's own serial number and version, because leaving them empty would fail the signature they are part of. A record from here is an Alfen-shaped record signed by this meter, which is what makes it useful for exercising software that reads the format and what stops it being an Alfen meter value.
Everything that happens inside this meter goes into one log: every Modbus
request, allowed or refused, every clock check, every change somebody made and
who made it, and whatever Hermod says while doing its part. The last 2000
entries are kept in memory, a host can mirror them to a console, and a browser
follows the same log over /api/v1/events.
A host that also reads commands on that 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:
meter.ShareConsoleWith(cli.WriteBlock); // line off, entry whole, line backOn disk it is two things, side by side in <DataPath>/logs:
- The log files,
meter-YYYY-MM-DD.log: everything, one line per entry, for reading. One file per day, thirty days kept (LogKeepDays;0writes no files at all and keeps the log in memory only). - The log book,
meter-YYYY-MM-DD.jsonl: the entries that are evidence - every refused Modbus request and every write, every change of the meter mode, every certificate taken into use, every start and stop, every check of the clock and every change to where it reads the time, and what the time servers' certificates were judged to be - one line of JSON each, signed and chained. It is the node's metrological log, it is kept whole, and its newest lines are read back at the next start - numbering included, so that a browser following the log is not handed entries it has already seen.
One line per entry because that is the format that survives being read by something other than this program: grep finds a line, jq takes it apart, and a file truncated by a power cut loses its last line and nothing else.
The log book goes on where the signed log of a meter from before it was a node left off: the same directory, the same file names, the same key, and a first line pointing back at that log's last. Those older files hold everything that meter wrote down, not only the evidence, and are kept whole with the rest.
The days are UTC days, as the timestamps in the files are. A file that cannot be written is said once on stderr rather than once per entry, every entry after that is tried again, and the first one that makes it is preceded by a line saying how many are missing, since when and which numbers they had - signed and chained like every other line, so that a check walks through it and a log with a hole in it no longer calls itself whole. A line the disk took only half of is taken back rather than left for the chain to trip over.
Every line carries the hash of the line before it and a signature of its own:
{ "id": 16, "timestamp": "...", "level": "warning", "tags": ["modbus","request","denied"],
"message": "(none) 0x03 @40000+98 -> refused: no role extension in client cert",
"data": { ... },
"prev": "wJ8...=", "key": "d13924a2fd92f261",
"hash": "1xx...=", "sig": "MEUCIQ...==" }The key is ECDSA over P-256, made at the first start and kept in
logs/signing-key.pem (mode 600); the public half sits beside it as
signing-key.pub.pem. Its own key rather than the meter certificate: those
answer different questions, and a certificate that is reissued would leave the
old log needing the old certificate for ever.
Checking it is one question, asked deliberately: GET /api/v1/logs/verify, the
Check the log button on the Metrological log page, or --verify-log on the
command line. Three things are checked per line, and they catch different things - the
hash catches a line that was edited, the chain catches a line that was removed,
moved or inserted, and the signature catches a line written by something that
did not have this meter's key.
What this is worth, and what it is not. The key sits next to the log it
signs, so somebody who can write that directory can also read the key and sign a
log of their own invention. Signing catches a file that was edited, truncated in
the middle, reordered, or copied from another meter; it does not catch an
attacker who took the key. Cutting the end off a log is not caught either -
every remaining line is genuine and the file cannot know how long it was meant
to be. What catches both is the head, the hash of the newest line, reported by
all three of the routes above: write it down somewhere this meter cannot reach,
and a log that no longer leads to it has been rewritten no matter how well it
signs itself.
The log files are thinned out after LogKeepDays days by the date in their
names; the log book never is. A log book from before that was thinned out
begins with a line pointing at a day that was thrown away, and a check of the
whole of it says so.
jq -r 'select(.tags | index("denied")) | "\(.timestamp) \(.data.peer) \(.data.denyReason)"' data/logs/meter-*.jsonlA request carries the whole of what was decided about it:
{ "connectionId": 3, "peer": "127.0.0.1:50665", "role": null,
"unitId": 1, "transactionId": 1, "functionCode": "0x03", "function": "ReadHoldingRegisters",
"address": 40000, "quantity": 98,
"allowed": false, "denyReason": "no role extension in client cert",
"exceptionCode": "IllegalFunction", "responseBytes": 2, "duration_ms": 0.4 }Refusals are warnings and carry the tag denied, so "show me everything that
was turned away" is one filter rather than a search through everything that was
not. A meter that recorded only what it permitted could not answer that question
afterwards at all.
Reading the log needs log:read and not merely meter:read: it holds the
addresses peers connect from and every certificate that was turned away, which
is more than somebody allowed to watch the readings was given. A node leaves its
log to anybody signed in; the meter's has more in it.
Both are read from a configuration file, in the same two sections that a LocalController uses, so one file can be written once and copied:
{
"dns": { "enabled": true, "servers": [ "192.168.1.1" ] },
"nts": { "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 }A name server written as a URL - udp://192.168.1.1:53 - is not a form the
file takes, and a meter given one does not start.
That nts block is what a meter asks when the file says nothing at all: the
PTB's four, of which two have to answer. 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 |
the single client's, and what a server's Test allows each step; the group asks with timeouts of its own |
checkEverySeconds |
900 |
how often the clock is checked |
legalTimeAuthority |
- | who the operator says stands behind it; null takes it away |
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 }A server can also be held to more than the usual checks of its TLS certificate: to the SHA-256 fingerprint of the certificate it has to show, of the root its chain has to end at, or both.
{ "hostname": "ptbtime1.ptb.de",
"certificateFingerprint": "3F9A...64 hex digits...",
"rootFingerprint": "B676...64 hex digits...",
"onMismatch": "refuse" }A fingerprint that does not match refuses the server - the key exchange does
not happen - unless onMismatch is "record", which uses it all the same and
writes the mismatch into the log book. Either way the verdict is written there,
once per change of verdict rather than once per key exchange, and the NTS page
shows it beside the server with the fingerprints it is held to; the page's
dialog for a server edits them, and shows the fingerprints its last key exchange
saw for copying. A root the machine does not know can be put into
<DataPath>/certificates/roots/tls as a PEM or DER file; the meter reads it at
its next start, and from then on a server whose chain ends there is trusted.
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. Saved over the API, such a section replaces the list - in the file as
well, so that the next start does not bring the list back.
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 meter 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. A save over the API is laid over what is in effect - the page's
forms send what they show and nothing else - and the section it leaves in the
file is read the way the next start will read it before it is written.
The whole group is asked on that interval - authenticated, and without stepping the meter's own clock - and what it reports is what the servers that answered agree on, with a line for each of them. A reading is only worth what the timestamp on it is worth, and a timestamp is worth more when four independent servers agree about it than when one was available.
One server can also be asked on its own, which is what somebody does when the
group did not answer: the Test in its row of the NTS page,
POST /api/v1/configuration/nts/test, or syncNTS <server> at the command line
of ModbusTLSEnergyMeterCLI. Every step is written down with when it happened -
the name and its addresses, the TCP connection, the TLS handshake, the key
exchange with its algorithm, its cookies and the NTP servers it names, the
authenticated request and the offset it found - so that a server which fails is
seen failing at one of them. The TLS certificate is part of it: every
certificate of the chain this machine built, the root's as much as the
server's, with both ends of its validity and the days it has left, the root's
SHA-256 fingerprint, and whether the whole held up, with the reasons in words
where it did not. A server of the group is asked on its own ports; an address
among those the single client's key exchange named is asked with that
exchange's cookies, because an address cannot have a key exchange of its own.
The test asks with a client of its own and leaves what the group last did - and
the clock - as they were.
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 meter prints for somebody to read drops it
again.
"Legal time" is not a claim this meter can make on its own. It holds only while
a check against a time source the operator has vouched for is both recent
enough and close enough; without a named authority this is an ordinary clock
that happens to be checked, and /api/v1/clock says so in as many
words.
-
A hard stop loses up to ten seconds of energy. The counters are written down every ten seconds, at both ends of a charging session, and on an orderly stop; a process that is killed comes back where the last write left it. The loss is always downwards - a counter that came back slightly high would be a meter billing for energy nobody used.
-
A certificate this machine cannot present is kept and never shown. Which ones those are is found out rather than assumed - see above - and .NET cannot hold an Ed448 or an ML-DSA private key at all, which is its own reason. Such an entry reads "not for a listener".
-
The pagination counters restart at zero if their file cannot be read. OCMF numbers every document a meter signs so that a gap is visible; a meter that lost the file leaves exactly such a gap, which is the intended behaviour and worth knowing about.
-
One meter per process. The unit identifier in the frame is not used to route, so several meters means several instances on several ports.
-
The simulated day is the same day all year: sunrise at 6, sunset at 20, one bell curve in between. Enough for "does this controller do the right thing when the site exports", not enough for a seasonal study.
-
An account is a person and the roles of the groups it is in, and that is the whole of it: there are no per-resource rights, so somebody who may write the meter mode may write all of it. Four roles are enough for a meter and would not be enough for much else.
-
Nothing expires. An account stays until somebody takes it away, and there is no lockout after repeated wrong passwords beyond the rate limiting Hermod already does on signing in.
-
A refused request is written down twice: once by the Modbus/TLS frontend in Hermod, which says
RBAC DENY ...as it always did, and once by this meter as the audit record above. The second is the one with the data attached; the first is not ours to remove. -
Nothing carries the head of the log anywhere else by itself. Until something does - a syslog sink, a witness, a line in somebody's notes - the signing proves the files were not edited, and not that they are all the files there were. See the caveat above.
This software is partly sponsored by the NLnet Zero Commons Fund as part of the EVQI project and within this tested against the Apache PLC4x project.
It is also part of the security extensions for the Open Charge Point Protocol.
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.