WWCP/POI is the "git of EV infrastructure information sharing". It synchronizes points of interest data within e-mobility as immutable, versioned data sets between servers and servers and (smartphone) clients.
- Static data is immutable. A roaming network with everything it embeds is one version, named by its ETags. A change set leads from one version to the next; nothing is changed in place.
- Dynamic data is kept strictly apart. Admin status, status and energy status are not content: they have no ETags, change without a new version and are shared by all versions of their entity.
- Signed by the owners. Every change set is signed by the owners of what it changes, with keys the data names: a charging station operator changes only its own data. Everyone can check every commit.
- No leader. Every server commits; the history is a graph of commits, and tips that do not conflict are merged deterministically, so that all servers make the same merge commits.
- WebSocket first. Peers subscribe to commits and status updates over one WebSocket and push their own commits, for fast, low-latency sync; HTTP serves the same reads.
- JSON and CBOR. Every representation exists in both, with the same keys and values; ETags exist for canonical JSON and canonical CBOR.
flowchart TD
RoamingNetwork --> ChargingStationOperator
RoamingNetwork --> ChargingStationManufacturer
RoamingNetwork --> EMobilityProvider
RoamingNetwork --> GridOperator
RoamingNetwork --> ParkingOperator
RoamingNetwork --> TransparencySoftware
RoamingNetwork --> TransparencySoftwareCertificate
RoamingNetwork --> EnergyMeterManufacturer
ParkingOperator --> ParkingGarage
ParkingOperator --> ParkingSpace
ParkingOperator --> ParkingSensor
ParkingOperator --> ParkingSpaceGroup
ParkingOperator --> ParkingProduct
ChargingStationOperator --> ChargingTariff
ChargingStationOperator --> Groups["EVSEGroup, ChargingStationGroup,<br/>ChargingPoolGroup, ChargingTariffGroup"]
ChargingStationOperator --> ChargingPool
ChargingStationOperator --> Brand
ChargingPool --> Brand
ChargingStation --> Brand
EVSE --> Brand
ChargingPool --> ChargingStation
GridOperator --> GridConnectionPoint
ChargingStation --> EVSE
EVSE --> ChargingConnector
ChargingPool --> EnergyMeter
GridConnectionPoint --> EnergyMeter
ChargingStation --> EnergyMeter
EVSE --> EnergyMeter
ChargingStation -. manufacturerId .-> ChargingStationManufacturer
ChargingPool -. gridConnectionPointId .-> GridConnectionPoint
GridConnectionPoint -. maintainedBy .-> ChargingStationOperator
ParkingSpace -. parkingGarageId .-> ParkingGarage
ParkingSpace -. parkingSensorIds .-> ParkingSensor
ParkingGarage -. chargingStationIds .-> ChargingStation
ParkingSpace -. parkingProductIds .-> ParkingProduct
ChargingStation -. parkingSpaceIds .-> ParkingSpace
EVSE -. chargingTariffIds .-> ChargingTariff
EnergyMeter -. manufacturerId .-> EnergyMeterManufacturer
EnergyMeter -. transparencySoftwareStatus .-> TransparencySoftware
TransparencySoftwareCertificate -. verified/compatible .-> TransparencySoftware
RoamingNetwork -. dataLicenses .-> DataLicense
ChargingStationOperator -. dataLicenses .-> DataLicense
ChargingPool -. dataLicenses .-> DataLicense
Solid lines embed completely (chargingStationOperators, chargingStationManufacturers, eMobilityProviders,
gridOperators, parkingOperators, energyMeterManufacturers, transparencySoftware, transparencySoftwareCertificates, chargingTariffs,
EVSEGroups, chargingStationGroups, chargingPoolGroups, chargingTariffGroups, chargingPools, chargingStations,
EVSEs, chargingConnectors, energyMeters, gridConnectionPoints, parkingGarages, parkingSpaces, parkingSensors,
parkingSpaceGroups, parkingProducts, brands), each 0..n
in the order of their ids; a grid connection point and an EVSE each have 0..1 energyMeter.
Dotted lines are references, which must exist in the network.
A pool, station, EVSE, tariff or group belongs to the operator its id names; ids are unique within the operator.
Energy meter, grid connection point, parking garage, space, sensor, space group and product ids are unique within the network;
parking product ids compare case-sensitively.
- Content: everything in the JSON and CBOR representations, completely, in a fixed order:
children and licenses embedded,
created,lastChangeandcustomDataincluded, timestamps to the millisecond. CBOR uses the keys and values of JSON. - Charging pool:
address,geoLocation,timeZone(e.g.Europe/Berlin),parkingType,accessibility,entranceAddress/entranceLocation,exitAddress/exitLocation,arrivalInstructions,chargingWhenClosed,facilities,paymentOptions,uiFeatures,featuresandparkingRestrictions(sets of names, in their order),locationLanguages(in their given order),relatedLocations({geoLocation, name?}, in their given order) andauthenticationModes({type, cardTypes?, brands?, number?, stationCode?}with the types of WWCP, e.g.RFID,CreditCard,SMS; a type only once, in the order of the types). RFID may name card types and embed the brands whose cards it takes; SMS and PhoneCall need theirnumber. Their accessibility, as the OCPI Accessibility Extension:services(e.g.ACCESSIBLE_CHARGING,ASSISTANCE) andstandards(e.g.PAS 1899:2022), sets of names, andassistanceServiceDetails. The NUTS regions it is in:nutsCodes, e.g.DEG. - Charging station:
addressandgeoLocationwhen they differ from its pool's,accessibility,vehicleTypes,euVehicleCategories(e.g.M1) andfeatures(sets of names),calibrationInfo/certificationInfo(URLs),maxPower(W) it delivers at once, at most that of its EVSEs together,manufacturerId/modelId, and theparkingSpaceIdsof the network it charges at. - EVSE:
currentTypes,chargingModes(arrays of names),maxVoltage(V),maxCurrent(A),maxPower(W),geoLocationwhen it differs from its station's,maxReservationDuration(s),physicalReferenceandchargingConnectors({@id, type, lockable?, format?, cableLength?, cableWeight?, cableManagementSystem?, standards?, images?},formatSOCKETorCABLE, a cable in m and kg, none for a socket). Their accessibility:reachDistance(m),operationTimeout(s),extendedOperationTimeoutandstandards. - Grid connection point: a description without status, embedded in its grid operator; pools name it by
gridConnectionPointId:voltageLevel,connectionType,nominalVoltage(V),nominalFrequency(Hz),contractedImport/ExportPower(W),contractedImport/ExportApparentPower(VA),connectionAgreementId,networkLocationId,marketLocationIds,meteringLocationIds, andmaintainedBy({entityType, entityId}, a charging station operator or parking operator; absent: the grid operator). Missing values are unknown; active power must not exceed apparent power. - Energy meter:
role(e.g.grid,pv,battery),manufacturerId,modelId,modelURL,serialNumber,hardwareVersion,firmwareVersion; with admin status and status. Its manufacturer is an energy meter manufacturer of the network (name,description,address,homepage,hotline, timestamps, without status). - Parking: a parking operator owns its garages, spaces and sensors directly. A space may name a
parkingGarageIdandparkingSensorIdsof the same operator, and who (parkingRestrictions, e.g.DISABLED) and which vehicles (euVehicleCategories) may park there; garages, spaces and sensors may namechargingStationIdsof the network. All have admin status and status. Parking products (minDuration,stopParkingAfterTimein s, without status and timestamps) are named byparkingProductIdsof garages, spaces and space groups of the same operator; they offer, they do not calculate prices. A space also tells what an OCPI 2.3Parkingdoes:vehicleTypes, maximum vehicle measures (kg, m), itslength/width,evsePosition,direction,surface,slope,timeLimit,roofed,illuminatedand further flags,standardsandimages. Where no parking operator defines the spaces of a charging station, its charging station operator can, asparkingSpacesof its own. - Charging tariff: as in OCPI:
currency(ISO 4217),tariffType(AD_HOC_PAYMENT,PROFILE_CHEAP,PROFILE_FAST,PROFILE_GREEN,REGULAR),tariffURL,minPrice/maxPrice({excludingVAT, includingVAT?}),paymentMeans(e.g.CREDIT_CARD,QR_CODE) andpaymentBrands(e.g.VISA), sets of names,validFrom/validUntil(timestamps) andelements, each withpriceComponents({type, price, vat?, stepSize},typeone ofENERGY,FLAT,MAX_CURRENT,MIN_CURRENT,PARKING_TIME,TIME;stepSizein Wh, A or s, positive, for every type butFLAT, which has none) and optionalrestrictions(startTime/endTimeasHH:mm,startDate/endDateasyyyy-MM-dd,min/maxEnergy(Wh),min/maxCurrent(A),min/maxPower(W),min/maxDuration(s),daysOfWeek,reservation). EVSEs namechargingTariffIdsof their operator. - Brands:
brands({@id, name, description?, logo?, homepage?, customData?}, without status and timestamps), embedded completely in what they are a brand of, meaning something of their own on every level: a charging station operator is a white-label solution for them, a charging pool, station or EVSE also appears under them, an RFID authentication mode takes their cards. The levels are never mixed, and the network has no registry of brands; see docs/Brand.md. - Opening times:
openingTimesof charging pools, charging stations and parking garages, as Styx'sOpeningTimeswrite them:24/7,regularOpenings([{ "monday": [{ "begin": "07:00", "end": "21:00" }] }]),exceptionalOpenings/exceptionalClosings(["begin -> end"]),freeText. Without regular openings it is 24/7. - Energy mix:
energyMixof charging pools and charging tariffs:energySourcesandenvironmentalImpacts([]means unknown),supplierName,productName,additionalRemarks. - Images:
imagesof charging pools, stations, EVSEs and parking garages, in their given order:url,category(e.g.LOCATION,CHARGER),type,width,height,thumbnail; a URL only once. - Groups: EVSE, charging station, charging pool and charging tariff groups name members of their
operator (
EVSEIds,chargingStationIds,chargingPoolIds,chargingTariffIds), the first three optionally achargingTariffId; parking space groups nameparkingSpaceIds. Groups may overlap and have admin status and status, and apriority(0 unless given): where groups of a member give it different values, the higher decides, and the same priority must not disagree. - Effective charging stations: a charging station group may give its members any property of a charging
station but their identification, name, description, EVSEs and energy meters, e.g. the model and the hotline of
all stations of a model, given once. The stations take them at runtime:
chargingStationOperator. TryGetEffectiveChargingStation(id, out var station),EffectiveChargingPools. A single value is the station's own, else the one of its group with the highest priority; arrays are united. POI_API serves and searches them, the OCPI and DATEX II publishers publish them; the network, its ETags and change sets keep the properties where they are given. See docs/. - Transparency software: releases (
TransparencySoftware, one per version) and their certificates are embedded in the network, without status and timestamps. A certificate names the releases it verifies or finds compatible, and optionally a manufacturer. An energy meter'stransparencySoftwareStatusassigns releases with theirlegalStatus, at most one entry per release; a givencertificateIdmust cover it. - ETags: SHA-256, SHA-384 or SHA-512 over canonical JSON or canonical CBOR.
They identify a version;
CalcETags()andValidateETags(…). Which bytes are hashed and signed is specified, with examples, in SPECIFICATION.md. - Runtime: admin status and status are not content and have no ETags. Each entity keeps them typed in its status schedules (history and planned status), shared by all its versions: a change set takes them along, with the internal data. New entities are operational and available since their creation.
- Energy status: EVSEs and charging stations also have an energy status,
{used, available, storedEnergy?}in W, W and Wh, for EVSEs alsocurrentVoltage?in V andremainingChargingTime?in s, kept the same way; there is none until one is reported. Its schedule keeps the last ten. - Keys: the roaming network (for its operator, the RNO), charging station operators, e-mobility providers,
grid operators and parking operators each name 1..n
signingKeys(a grid operator not taking part none at all), short keys signing their change sets (e.g. secp256r1), and 1..nidentityKeys, long-term keys signing changes of their identity keys (e.g. secp521r1):{@id, key, notBefore?, notAfter?}, the public COSE key as Base64 of its deterministic CBOR, identified by its SHA-256 COSE key thumbprint [RFC 9679] in lowercase hex. A key is not both.
Every entity derives from AImmutableEMobilityEntity: an identification, a name, a description, a data source,
timestamps and custom data as content, and an admin status and a status as runtime status, shared by all its
versions. docs/ explains this base class and documents every entity with all the properties
a change set can manage:
| Entity | Description |
|---|---|
| RoamingNetwork | The root: one version of a roaming network with everything it embeds, its keys and its ETags. |
| ChargingStationOperator | A charge point operator with its charging pools, tariffs and groups. |
| ChargingPool | A charging location: address, opening times, facilities, grid connection point and charging stations. |
| ChargingStation | A charger with its EVSEs, manufacturer and model, energy meters and energy status. |
| EVSE | One charging point: power, voltage, current, connectors, tariffs and energy status. |
| ChargingTariff | A price as in OCPI: currency, elements with price components and restrictions. |
| EVSEGroup | EVSEs of one operator, optionally with a common tariff. |
| ChargingStationGroup | Charging stations of one operator, optionally with a common tariff and properties they take at runtime. |
| ChargingPoolGroup | Charging pools of one operator, optionally with a common tariff. |
| ChargingTariffGroup | Charging tariffs of one operator. |
| EnergyMeter | A meter of a pool, grid connection point, station or EVSE, with its transparency software. |
| ChargingStationManufacturer | A maker of charging stations, named by the stations it built. |
| EMobilityProvider | An e-mobility service provider with its keys. |
| GridOperator | A distribution system operator with its grid connection points, each signed by its maintainer. |
| ParkingOperator | Runs parking garages, spaces and sensors and offers parking products. |
| ParkingGarage | A car park with opening times, images and parking products. |
| ParkingSpace | One parking space, possibly in a garage, with its sensors and charging stations. |
| ParkingSensor | A sensor detecting whether parking spaces are occupied, named by the spaces it watches. |
| ParkingSpaceGroup | Parking spaces of one operator, e.g. a level or a zone. |
A change set is an ordered list of changes against one version, named by its before ETags, leading to the version named by its after ETags.
// The public halves of the private keys of the parties.
var gef = new ChargingStationOperator(ChargingStationOperator_Id.Parse("DE*GEF"),
I18NString.Create(Languages.en, "GraphDefined"),
SigningKeys: [ POIPublicKey.From(gefSigningKey) ], // e.g. secp256r1
IdentityKeys: [ POIPublicKey.From(gefIdentityKey) ]); // e.g. secp521r1
var rn1 = new RoamingNetwork(RoamingNetwork_Id.Parse("rn1"),
DataSource: "v1",
ChargingStationOperators: [ gef ],
SigningKeys: [ POIPublicKey.From(rnoSigningKey) ],
IdentityKeys: [ POIPublicKey.From(rnoIdentityKey) ]);
var newOperator = new ChargingStationOperator(ChargingStationOperator_Id.Parse("DE*NEW"),
SigningKeys: [ POIPublicKey.From(newSigningKey) ],
IdentityKeys: [ POIPublicKey.From(newIdentityKey) ]);
var cs = rn1.CreateChangeSet([
RoamingNetworkChange.UpdateProperty(rn1, "dataSource", null, "v2"), // the network
RoamingNetworkChange.Add (newOperator), // a new operator
RoamingNetworkChange.UpdateProperty(gef, "telephone", null, "+49 30 7654321") // the operator DE*GEF
]).
Sign(rnoSigningKey, POIPublicKey.From(rnoSigningKey).Id, COSEAlgorithm.ES256). // owns the network and adds operators
Sign(gefSigningKey, POIPublicKey.From(gefSigningKey).Id, COSEAlgorithm.ES256); // owns DE*GEF
var rn2 = rn1.ApplyChangeSet(cs); // rn1 stays as it was- Changes:
UpdateProperty/RemovePropertyof every entity's own properties, not of its children;Add/Removeof operators, manufacturers, energy meter manufacturers, e-mobility providers, grid operators, parking operators, transparency software releases and certificates, pools, tariffs, groups, stations (Add(station, poolId)), EVSEs (Add(evse, stationId)), parking garages, spaces, sensors and space groups (Add(space, parkingOperatorId)) and grid connection points (Add(point, gridOperatorId)). Energy meters are properties of what embeds them (energyMeters,energyMeter). Every reference must exist; a referenced entity can not be removed. The overload by the entity's type names it: a change is data,{entityType, entityId, propertyName, oldValue, newValue}, applied to the entity with that id in the version it is applied to. - Old values:
CreateChangeSetgives every change the value it finds; JSONnullmeans "expected absent". A change finding another value conflicts. - Preconditions: a change set may name the ETags entities must still have, e.g. the HTTP
ETaga client read a charging pool with:CreateChangeSet(changes, Preconditions: [ version.Precondition("ChargingPool", poolId) ]). It applies, and is rebased, only while they hold;TryGetEntityETag(entityType, entityId, …)tells the ETag of an entity, as POI_API serves it. - Element changes:
AddElement,RemoveElement,ReplaceElement,UpdateElementPropertyandRemoveElementPropertychange one element of a property, named by an element path, e.g. a member of a group,[{"propertyName": "EVSEIds", "elementId": "DE*GEF*E1*2"}], a brand of a pool,[{"propertyName": "brands", "elementId": "blue-charge"}], or a property of a connector,[{"propertyName": "chargingConnectors", "elementId": "1"}]withpropertyName: "lockable". Elements of an array are named by their@id, texts by themselves; other arrays, e.g. theelementsof a tariff, and the keys of a party are changed as a whole. An element change is checked as a change of the property it is part of, but its old value is the element: concurrent changes of other elements do not conflict. Merges, and so the differences the importers commit, write changes element by element (POIElements.Diff). See docs/. - Timestamps: a change set sets
lastChangeto itsCreatedAt, so its after ETags are deterministic: of the roaming network always, of an entity when it is added or its own properties change, not when only what it embeds changes. Whether anything below an entity changed shows its ETag, what changed the history. - Owners: every change set must be signed by the owner of every change it makes, with a key the owner has
in the version it is applied to, valid at its
createdAt(TryCheckOwnership); every signature must be one of a key of a party. Nobody else's signature counts, there is no external trust configuration.- The RNO owns the roaming network itself, manufacturers, energy meter manufacturers and
transparency software, and adds and removes parties: with their keys (a grid operator not taking part
without, then the RNO signs in its place until it gives it keys), without their children:
RoamingNetworkChange.Add(party)leaves them out,AddChildren(party)gives the changes the party then signs to add them. The RNO decides who is a member: removing a party removes all its data with it, without the party's signature; the history keeps the versions before. - A charging station operator owns its properties, brands, pools, stations, EVSEs, tariffs and groups, named by their ids, and the brands embedded in them; a parking operator its parking data; e-mobility providers and grid operators their properties.
- A grid connection point is owned by its maintainer: its grid operator, or the charging station operator
or parking operator its
maintainedBynames, e.g. while the grid operator does not take part. The grid operator takes it over or hands it on by changingmaintainedBy; the maintainer can hand it on; an identity key of the RNO reassigns it. - Data is signed with a signing key. The
signingKeysof a party are rotated with one of its current signing keys or, when none of them can sign any more, one of its identity keys; itsidentityKeysonly with one of its identity keys. For recovery, an identity key of the RNO changes both kinds of keys of every other party. A party keeps at least one key of each kind. - The RNO's identity keys are the root of trust: nobody can replace them once they are lost or expired.
An RNO should therefore name at least two identity keys with different
notAfter, one of them kept offline, and replace an identity key well before it expires.
- The RNO owns the roaming network itself, manufacturers, energy meter manufacturers and
transparency software, and adds and removes parties: with their keys (a grid operator not taking part
without, then the RNO signs in its place until it gives it keys), without their children:
- Rebase:
rn2.TryRebase(cs, …)prepares a change set of an older version again, unless it conflicts; the rebased change set is unsigned, its owners sign it again. - Signatures:
Sign(…)andVerifySignature(…)over canonical JSON (COSE algorithms of Styx); an optionalVerifyChangeSetSignaturechecks them in addition. - Wire: JSON and CBOR;
RoamingNetworkChangeSetLimitsbound bytes, depth, changes and signatures.
There is no leader: every server commits change sets signed by their owners, takes the commits of the others, and merges the tips that do not conflict, deterministically, so that all servers make the same merge commits.
var server1 = RoamingNetworkHistory.Start(rn1);
RoamingNetworkHistory.TryFromSnapshot(server1.ToSnapshotJSON(), out var server2, out _);
var result = server1.Commit(cs); // Committed, AlreadyCommitted, Refused
server1.TryGetMissingCommits(server2!.HaveIds(), out var commits, out _);
server2.TryAppend(commits!, out _, out _); // and merged with the tips of server 2- Commits: a change set, its
parentIds, its revision and the ETags of the result. The id is the SHA-256 of the commit's canonical JSON without signatures; it is checked when read. The revision is one more than the highest one of the parents. - Commit:
Commit(cs)commits a change set to the version it was made for, named by its before ETags: the head, another tip, or an older commit, which then starts a branch. Signed change sets are not rebased: they are committed where they fit, and merged. - Tips and merges: the tips are the commits no other one follows, ordered by revision, then by id;
the head is the first one, its version is
Head.RoamingNetwork. The first two tips that do not conflict are merged into a merge commit with both asparentIds: a three-way merge with their best common ancestor, entity by entity, found by their identifications as their types compare them (DE-ABCandDE*ABCare the same e-mobility provider), and property by property, written as an unsigned change set from the first one; everyone can calculate it, andTryAppendchecks it. Conflicting tips stay, with theirConflicts({kind, entityType, entityId, propertyName?, base, left, right}), until the owner commits a change set to one of them that resolves the conflict, e.g. taking the value of the other one. - Several best common ancestors, e.g. after a conflict was resolved on two servers, are merged into
a virtual merge base, as git's recursive strategy does. Where they conflict, it has a conflict marker
(
{"@conflict": {base, left, right}}, also thebaseof a conflict there): the tips merge if they resolved it alike, and conflict otherwise. - Exchange:
HaveIds()names the tips and a sample of older commits,TryGetMissingCommits(have)returns what is missing there, parents first, andTryAppend(commits)takes them and merges afterwards. - Commit signatures: several parties sign a commit, each on its own and in any order, with a
role(leader,roamingNetworkOperator,chargingStationOperator, …), an optionalsignerandsignedAt: a server throughStart(network, Signers: […])orhistory.Signers, a roaming network operator that checked the data of a charging station operator later throughcommit.CreateSignature(…)andTryAddSignatures(…). A signature covers the commit and the signatures of its change set; adding one does not change the commit id.TryAppendverifies every signature (VerifyCommitSignature) and may require roles (RequiredSignatureRoles);OnCommitSignaturestells of new ones. - Appending: a commit's parents must be known; one with one parent must be signed by its owners and lead to its ETags, a merge commit must be the merge of its parents.
- Snapshot: now and complete, for the first sync:
{timestamp, commit, roamingNetwork, runtimeStatus}.TryFromSnapshotchecks the commit id, recomputes the ETags of the network and takes the runtime status as the server's; changes follow as commits since.runtimeStatus=falseleaves the status out. - Announcements:
OnCommitAnnouncementtells of every commit a history takes, in order: timestamp, commit id, parent ids, revision, description. - Runtime status:
history.RuntimeStatussets and reads the status of the head's entities by name,TryUpdate(kind, entityType, entityId, value, timestamp, …): an older timestamp isOutdated, the same valueUnchanged, a later one than nowScheduled.OnStatusUpdatetells of every change, also of one set at the entity, and of a planned status when it is due. A status update of another server,TryUpdate(statusUpdate, …), is taken with its timestamp. - Group status: the status of a group is set manually, or calculated by a delegate from the status of its
members, e.g.
history.RuntimeStatus.CalculateStatus(groupId, POIGroupStatus.OfEVSEs); then the delegate has the say, and every other status of the group is refused. See docs/.
A history keeps itself in a store, an IRoamingNetworkHistoryStore, and is loaded from it after a restart:
using var store = new RoamingNetworkHistoryFileStore("data/rn1"); // one directory per roaming network
var history = RoamingNetworkHistory.TryLoad(store, out var loaded, out _)
? loaded!
: RoamingNetworkHistory.Start(rn1, Store: store,
Retention: new RoamingNetworkHistoryRetention { RetainCommits = 1000 });- Content: a base, the snapshot of the root with its runtime status, and a log since, one entry per line:
every commit, signatures added later, and every change of the runtime status. Commits and signatures
are written, and synced to the disk, before the history shows them: a store failing refuses the commit.
Status updates are synced in batches (
StatusFlushInterval, 1 s). - Files:
CURRENTnames a generationgen-NNNNNNwithbase.jsonandlog.jsonl. A last line written only partly by a crash is dropped when loading; compacting writes a new generation completely beforeCURRENTnames it. The fileLOCKkeeps a second store from the directory. - Numbers: written as canonical JSON, as the API's responses and the WebSocket messages are, so every
number is read back as it was: a decimal with exactly its decimal places (
150000, not150000.0, which canonical JSON and CBOR, and so the ETags, would tell apart) and a coordinate as the double it was. - Loading:
TryLoadchecks every commit again (owners' signatures, merges calculated again, ETags) and merges the tips afterwards, e.g. when a crash came before a merge commit was written. - Retention: a history keeps the versions of its root, its tips, every
VersionCheckpointInterval-th revision and itsRecentVersionslatest commits, and calculates others again when needed.Compact(), or the history itself once the log hasCompactAfterLogEntriesentries, drops the commits before a new root, the latest commit all tips follow at leastRetainCommitsrevisions behind the head, and folds the runtime status into the new base. A server knowing only older commits starts from a snapshot then, and a branch older than the root can no longer be appended. - Peers keep a history started from a snapshot in
RoamingNetworkPeerOptions.Store, with itsRetention.RoamingNetworkHistoryMemoryStorekeeps the same content in memory, e.g. for tests.
POI_API serves a history in JSON, or in CBOR for Accept: application/cbor (also as request body):
| Request | Returns |
|---|---|
GET ~/RNs/{id}/history/head |
metadata of the head commit |
GET ~/RNs/{id}/history/tips |
metadata of the head and all tips, and the conflicts keeping them apart |
GET ~/RNs/{id}/history/haveIds |
the tips and a sample of older commits held here |
GET ~/RNs/{id}/history/snapshot?runtimeStatus=false |
head commit, complete network and its runtime status |
GET ~/RNs/{id}/history/commitInfos?since={commitId}&max={n} |
metadata of the commits since |
GET ~/RNs/{id}/history/commits?since={commitId}&max={n} |
complete commits since |
GET ~/RNs/{id}/history/commits?have={commitId,…}&max={n} |
complete commits missing at who holds these, parents first |
POST ~/RNs/{id}/history/commits {commits: […]} |
200 appended (how many, head, tips, conflicts), 422 one refused, 403 |
GET ~/RNs/{id}/history/commits/{commitId} |
one commit |
COMMIT ~/RNs/{id}/history/changeSets |
201 committed, 200 already committed (the commit, the head, the conflicts left), 422 refused |
POST ~/RNs/{id}/history/commits/{commitId}/signatures {signatures: […]} |
201 added, 200 known, 403, 404, 422 invalid |
It also serves the roaming networks and their entities, readable from every origin (CORS *), each in canonical
JSON or CBOR with the POI ETag of its representation as HTTP ETag (e.g. "json:sha256:hex:…"); a known
If-None-Match is answered with 304. HEAD and OPTIONS are answered for every one:
| Request | Returns |
|---|---|
GET ~/RNs |
the roaming networks, as {@id, name, description} |
GET ~/RNs/{RN_Id} |
the roaming network, the head of its history |
GET ~/RNs/{RN_Id}/{CSOs|GOs|EMPs|CSMs|POs} |
its charging station operators, grid operators, e-mobility providers, charging station manufacturers or parking operators, as {@id, name, description} |
GET ~/RNs/{RN_Id}/CSOs/{CSO_Id}[/CPs/{CP_Id}[/CSs/{CS_Id}[/EVSEs/{EVSE_Id}]]] |
an operator, pool, station or EVSE as its parent embeds it; a pool with ?expand=gridConnectionPoint |
GET ~/RNs/{RN_Id}/CSOs/{CSO_Id}/CPs/{CP_Id}/CSs/{CS_Id}/PSs[/{PS_Id}] |
the parking spaces a charging station names, or one of them: of parking operators or of its charging station operator, each as its operator embeds it |
GET ~/RNs/{RN_Id}/CSOs/{CSO_Id}[/CPs/{CP_Id}[/CSs/{CS_Id}[/EVSEs/{EVSE_Id}]]]/Brands[/{Brand_Id}] |
the brands of an operator, pool, station or EVSE, its own, or one of them |
GET ~/RNs/{RN_Id}/GOs/{GO_Id}[/GCPs/{GCP_Id}] |
a grid operator, or one of its grid connection points |
GET ~/RNs/{RN_Id}/{EMPs|CSMs|POs}/{Id} |
an e-mobility provider, charging station manufacturer or parking operator |
Versions are immutable and their runtime status is no part of these representations, so each one is calculated once per version and format and kept as long as the version is referenced; an entity a commit does not change is the same object in the new version and keeps its representations. A 304 costs a lookup. Only an expanded view is calculated for every request.
The lists are the QUERY interface, beside the synchronization above: GET ~/RNs/{RN_Id}/CPs lists the charging
pools of all charging station operators of a roaming network, each as its operator embeds it, with
chargingStationOperator: {@id, name}; GET ~/RNs/{RN_Id}/CSOs/{CSO_Id}?without=chargingPools is the operator
without them, so that a large one is not read whole to show what it says of itself. So is
GET ~/RNs/{RN_Id}/GOs/{GO_Id}?without=gridConnectionPoints, and GET ~/RNs/{RN_Id}/GOs/{GO_Id}/GCPs lists them as
the grid operator embeds them; a charging station operator without any of chargingTariffs, chargingTariffGroups,
chargingPoolGroups, chargingStationGroups and EVSEGroups as well, listed by
~/RNs/{RN_Id}/CSOs/{CSO_Id}/{CTs|CTGs|CPGs|CSGs|EVSEGs}, each of them below it, e.g. …/CTs/{CT_Id}; and a
parking operator without any of parkingGarages, parkingSpaces,
parkingSpaceGroups, parkingSensors and parkingProducts, e.g. ?without=parkingSpaces,parkingSensors, listed by
~/RNs/{RN_Id}/POs/{PO_Id}/{PGs|PSs|PSGs|PSNs|PPs}, each of them below it, e.g. …/PSs/{PS_Id}. The parking spaces a
charging station names, whoever defines them, are listed by …/CSs/{CS_Id}/PSs. A charging station, a charging
pool and CPs have the charging stations with the properties their groups give them, the effective ones; a
charging pool, and what it embeds, with ?view=stored as its operator embeds it, with the ETag a precondition names. The brands of a charging station
operator, pool, station or EVSE, its own, are listed by …/Brands below it, e.g.
~/RNs/{RN_Id}/CSOs/{CSO_Id}/CPs/{CP_Id}/Brands, each of them below it, …/Brands/{Brand_Id}. Every list, ~/RNs,
~/RNs/{RN_Id}/{CSOs|GOs|EMPs|CSMs|POs} and
the lists of what an operator embeds as well, takes:
| Parameter | |
|---|---|
match |
words separated by spaces or commas, each contained in a text of an entry, case-insensitively; a word starting with ! must not be; given more than once, all of them |
operatorId |
CPs only: the charging pools of one charging station operator |
offset, limit |
the part of the matching entries; all of them without limit |
Entries are in the ordinal order of their identifications. X-Total-Count says how many entries the list has,
X-Filtered-Count how many match, and Link names the next and the prev page; the ETag is the one of the
returned content. A list without any of them is kept with its version, as above.
new POI_API(HTTPServer, RootPath: "/poi", HTMLView: …) serves all of it below a path of its own, e.g. beside a web
interface. With an HTMLView, a request for a list or an entity that prefers text/html to JSON and CBOR - a browser opening
its URL - is answered by it, e.g. with the page of a web frontend that asks the same URL for the data; every other
request, also one without an Accept header field or with */*, gets JSON or CBOR. The RoamingHub serves its
roaming networks so, below /poi.
Submitting is refused unless AuthorizeChangeSetSubmission decides who may submit;
what a change set may change, its owners' signatures decide. Pushing commits is refused unless
AuthorizeCommitPush decides who may push, e.g. other servers; they are checked as every commit.
Adding commit signatures needs
AuthorizeCommitSignature (e.g. whether the sender has the role it signs in) and VerifyCommitSignature.
GET ~/RNs/{id}/history/ws upgrades to a WebSocket with OCPP-J frames,
JSON in text frames, CBOR in binary frames; every request is answered in its own format.
[2, "42", "commitInfos", { "since": "9f3c…", "max": 10 }] request
[3, "42", { "commitInfos": [ … ] }] response
[4, "42", "NotFound", "The commit … is unknown here!", {}] request error
- Actions:
head,tips,haveIds,snapshot,commitInfos,commits {since? | have?, max?},commit {commitId},pushCommits {commits}→{appended, head, tips, conflicts?, description?},pushStatusUpdates(below),commitChangeSet {changeSet}→{status, …, head, conflicts?},runtimeStatus,signCommit {commitId, signatures}→{commitId, signatures, added}. - Subscriptions:
topicslists them,subscribe/unsubscribe {topics: […]}change them:commitAnnouncements,commits(complete; aboveMaxPushedCommitSizeonly announced),commitSignatures({commitId, signatures}added later),adminStatusUpdates,statusUpdates,energyStatusUpdates. Pushes are sends,[6, "p1", action, payload], in the format of the lastsubscribe. They end with the connection; a client that falls more thanMaxQueuedPushespushes orMaxQueuedPushBytesbehind is disconnected and catches up after reconnecting. - Async: requests are processed concurrently, up to
MaxConcurrentRequestsper connection; answers come when ready and are matched by their id. A connection's requests are cancelled when it closes. - setStatus
{kind, entityType, entityId, value, timestamp?, dataSource?}→{result, description?, current?}, allowed asAuthorizeStatusUpdatedecides (refused without it).kindisadminStatus,statusorenergyStatus, whosevalueis an object{used, available, storedEnergy?, currentVoltage?, remainingChargingTime?}. Status updates come often, e.g. within a minute as the AFIR asks: send them as one batch. - pushStatusUpdates
{statusUpdates: […]}→{results: [{result, description?}, …]}: status updates of another server, as the topics push them, up toMaxBatchSize, each allowed asAuthorizeStatusPushdecides (refused without it), e.g. only for the operators that server hosts. They are taken with their timestamps: the latest status of an entity wins, wherever it was set. - Batches: messages of the same action aggregate. A request
{batch: [p1, p2, …]}runs per payload, in order, and is answered with{batch: [r1, {error: {code, description, details}}, …]}, up toMaxBatchSize. Pushes of the same action that wait together go out as one{batch: […]}, up to themaxBatchSizeofsubscribe(1 turns it off);PushCoalescingDelaylets more gather. - Errors: the codes of OCPP-J plus
NotFound; an unreadable frame is answered on id"-1". - Access: open like the HTTP reads, unless
AuthenticateWebSocketClientis set;commitChangeSetasksAuthorizeChangeSetSubmissionwith the upgrade request.
RoamingNetworkPeer keeps a history in sync with another server over its WebSocket. There is no leader:
both commit, push their commits to each other, and merge alike.
await using var peer = new RoamingNetworkPeer(new Uri("wss://server1/RNs/rn1/history/ws"),
Options: new RoamingNetworkPeerOptions {
TrustedRoamingNetworkOperatorKeyIds = [ rnoIdentityKeyId ]
});
await peer.StartAsync(); // returns once caught up for the first time
var current = peer.History!.Head.RoamingNetwork; // the current version
peer.History.Commit(current.CreateChangeSet([ // committed here, pushed to server 1, merged there
RoamingNetworkChange.UpdateProperty(current, "dataSource", null, "v3")
]).Sign(rnoSigningKey, rnoSigningKeyId, COSEAlgorithm.ES256));- Sync: subscribe first, then a snapshot (when it has no history yet), the commits it misses, naming
those it holds (
have), the runtime status, and then it pushes the commits the other server misses. Pushed commits whose parents it holds are appended, others make it fetch; new commits of its own history, made here or taken from elsewhere, are pushed as they come (PushCommits). - Status: the status of the other server is taken (
FollowStatus); the status set here, e.g. by an operator's backend, or taken from elsewhere, goes there (PushStatus,pushStatusUpdates): after connecting the status it has differently, e.g. set while not connected, then every change, in order with the commits, so that a new EVSE arrives before its status. Nothing that came from the other server goes back. - Trust:
TrustedRoamingNetworkOperatorKeyIdsnames keys of the roaming network operator a snapshot must have; afterwards, every change set is checked by the keys in the data. - Reconnect: a broken connection is opened again with growing delays, and it catches up.
None of its commits known there starts it again from a snapshot (
OnHistoryReplaced). - Options: complete commits or announcements, following status or not, pushing commits and status or not, credentials via
ConfigureWebSocket, an additional verifier of change set signatures, a verifier of commit signatures, the roles every commit must be signed in, limits. - Signing:
SignCommitAsync(commitId, signatures)sends the signatures of, e.g., a roaming network operator to the other server; every peer takes them from thecommitSignaturestopic.
| Directory | What it is |
|---|---|
WWCP_POI2 |
Shared lower layer: identifications, status values, predefined strings, energy information, transparency software |
WWCP_POI3 |
Roaming networks with their operators, pools, stations, EVSEs, tariffs, groups, manufacturers, e-mobility providers, grid operators, energy meters, transparency software and parking; change sets, history, sync |
old |
The earlier implementation, for reference only; not developed further |
- Finer subscription filters
- Further entities: as the use cases need them
dotnet test WWCP_POI3_Tests/WWCP_POI3_Tests.csprojWhy this section exists: RATIONALE.md.
POI3 is built by one maintainer working with an AI coding agent, Claude Code
(model Claude Opus 5.5), since 2026-10-10. Every commit the agent wrote part of
names it in a Co-Authored-By trailer.
The maintainer decides what is built and how. The requirements come from the maintainer's work on e-mobility roaming: which data a roaming network needs, which standards it has to map to (OCPI, DATEX II under AFIR, the OCPP-J framing of the WebSocket API), and which parties own which data. Where several designs would work, the agent lays out the alternatives and what each would break, and the maintainer chooses. The choices so far are listed below.
The agent writes most of the text. From these decisions, the agent
implements POI3 in short iterations: entities, change sets, signatures,
history, sync, the HTTP and WebSocket APIs, their tests, docs/ and
SPECIFICATION.md. Each iteration ends with a commit the maintainer
reviews and pushes.
Tests and the specification are the check, not the agent's word.
Behaviour is pinned by the tests in WWCP_POI3_Tests and by
SPECIFICATION.md, and the adapters to OCPI 2.3.0 and DATEX II 3.7 have
their own tests in both directions.
Without AI: the data structures of WWCP_POI2 were carried over from
WWCP_Core, where they were
written over many years. [Confirm: written without AI tools?]
Decisions taken by the maintainer between alternatives the agent laid out:
- 2026-10-13 – POI3 has no leader. Every server commits; the history is a
graph of commits with deterministic merges, instead of one server ordering
every commit (built first, in
5bfd15c). [Grounds needed, see "The one rule for every why" in RATIONALE.md] - 2026-10-13 – Owners sign their own data. A change set is valid only when signed by the owner of everything it changes, with the owners' keys in the network data itself, not in an external trust configuration.
- 2026-10-14 – Grid connection points have exactly one maintainer, not shared ownership. Distribution system operators are the actual source of contracted grid capacity and MaLo/MeLo identifiers, but few will take part at first, so charging station operators must keep publishing these data.
- 2026-10-14 – DATEX II: the existing model migrated to 3.7, not a second model next to it, because AFIR requires DATEX II from 2026-04-14 and the German NAP takes the Mobilithek profile 01-00-00 only as JSON.
- 2026-10-16 – Tariff price limits as net price plus optional gross price, so that OCPI's price limits are taken without rounding.
- [to be filled in: what the agent suggested, why it was rejected, what that rests on]
Affero GPL 3.0, see LICENSE. Contributions under the Contributor License Agreement.txt.
This Open Source project is partially funded by the NGI Zero Commons Fund as part of our EVQI project.
We also appreciate any additional funding and long-term support for the Chargy family, for example via GitHub Sponsors, as it helps us keep the project sustainable, independent and useful for the entire e-mobility community.