One application for everything around a coraine broker: build it, configure its bridges, watch it live, and learn it - including how to write a plugin for it.
| Part | What it does |
|---|---|
| Compile | Build a broker for a deployment: the features compiled in or out, profile training (PGO) and tuning on a workload, the target hardware - the result as a binary, a container image or a Debian package. Drives coraine-build from the coraine-dev package. |
| Operate | Start and stop brokers - natively or in a container - and the services they need: MongoDB, PostgreSQL/TimescaleDB, Mosquitto; the brokers themselves, several at once: live logs, log and trace levels changed on a running broker, metrics as charts, health. |
| Explore | A live connection to a running broker: entities as boxes, Relationships as arrows, zoom, pan, entities dragged into place; the entities with a GeoProperty on a map; values as they change. |
| Documentation | All of coraine's documentation in one place, written to be read: the guides - installation, administration, the API, building, deploying, performance, the transports, the bridges, authorization, the internals - a guide and an API reference per cor-lib, with search, a guide's sections and the next one, offline; every component's repository. The same pages are the website seamware.github.io/docs (make site). Planned: a skeleton repository for each kind of plugin. |
Built today: the c3 command, its backend and its configuration file; Compile - a broker built by
coraine-build with the features, bridges, HTTP server and executable name chosen, its output followed
live, the builds kept; Operate - brokers started and stopped natively or in a container,
with MongoDB, PostgreSQL/TimescaleDB and Mosquitto as containers, their logs, configuration, metrics and
log levels; Explore - discovery, the types picked, tenants, an entity's menu (linked entities,
neighbourhood), editing, the entities on a map (online, or offline from downloaded areas) and live
values over the broker's WebSocket transport or HTTP notifications. doc/design.md is
the plan, doc/areas.md the parts of the work, design/ the screen designs.
Demos - seamware.github.io/c3-demos: AI Assist on video, and the factory floor, Boston's bike stations and Madrid to run on your machine (SEAMWARE/c3-demos).
What it needs (Debian / Ubuntu): gcc, make, OpenSSL (libssl-dev, for corRest's client)
and zlib (zlib1g-dev, for the map files); a browser. To connect to a broker: a coraine
(installation - apt install coraine from https://seamware.github.io/apt, or Docker). For Compile: the coraine-dev package (its
coraine-build and source tarball) - or, without it, the git clones c³ itself is built against: coraine
beside the cor-libs. For Operate in containers: Docker or Podman.
The backend builds against the cor-libs as coraine does - sibling checkouts, each built with make in it
(COR_LIBS=<dir> for checkouts elsewhere):
mkdir seamware && cd seamware
for r in corBase corLog corAlloc corArgs corTree corJson corHttp corRest c3; do
git clone https://github.com/SEAMWARE/$r
done
for r in corBase corLog corAlloc corArgs corTree corJson corHttp corRest; do make -C $r; done
cd c3Then:
make # ./c3
coraine -db corDB -p 1090 --transports ws & # a broker, in RAM, on a port of its own, with its WebSocket transport
examples/factory.sh http://localhost:1090 # 25 entities with Relationships, locations, areas and routes
examples/live.sh http://localhost:1090 & # ... and its robots at work: the values change every 0.5 s
examples/factory.sh http://localhost:1090 300 # ... and 300 sensors more, to page through
examples/ckan-bikes.sh http://localhost:1090 # real open data: 572 Boston bike stations + 13 districts (areas) from a CKAN portal
./c3 # opens the application; Ctrl-C stops itIn Explore, enter http://localhost:1090 and press Connect. With the factory alone (25 entities)
everything is loaded at once; with the bike stations too (610), tick the types to see - District,
then BikeStation - and press Load. Right-click a Robot for its linked entities (Dock, Floor,
Building) or its neighbourhood. The next start connects to the last broker by itself, with the types
picked last time.
With examples/live.sh running, the robots' boxes show their speed and battery as they change, and on
the map they drive their Missions' routes; every 8 s a Mission appears or disappears. Live ·
WebSocket at the top says how the changes arrive (below).
Map (top left of the graph) puts the same entities on a map. With the bike stations: colour by
inDistrict → gives each district its colour - its stations and its area; click a station for its
attributes and the line to its district. Download this area shows the size of the area in view
down to the level chosen, then downloads it: from then on it is drawn from the file, also offline.
make run ARGS="--port 7901 --no-browser" builds and runs from the checkout; make install
(PREFIX=/usr/local, DESTDIR for packaging) installs bin/c3 and share/c3/www; make clean.
A c³ started while one runs on its port opens that one in the browser and ends. When the running one is
an earlier build of the same file - started from bin/c3, the file replaced by an install since
(/proc/<pid>/exe reads <path> (deleted); GET /api/status says its pid) - the new one stops it
(SIGTERM, the port free within 10 s) and starts in its place, as a stop by hand would: the brokers and
services it started keep running and the new one attaches to them, a build that runs is cancelled.
c3 [--port <port>] [--config <file>] [--no-browser] [--www <directory>] [--planet <url>]
[--coraine-build <path>] [--source <tarball>] [--token-file <file>]
| Option | Default | |
|---|---|---|
--port, -p |
7900 | the backend's TCP port |
--config, -c |
$XDG_CONFIG_HOME/c3/c3.json, else ~/.config/c3/c3.json |
the configuration file |
--no-browser, -nb |
serve only, and print the URL | |
--www, -w |
<binary's dir>/www, else <binary's dir>/../share/c3/www |
the pages |
--planet, -pl |
the newest daily build at build.protomaps.com |
the Protomaps planet (.pmtiles URL) map areas are extracted from |
--coraine-build, -cb |
coraine-build in $PATH (the coraine-dev package); else the coraine clone's, <clones>/coraine/packaging/deb/coraine-build |
the script Compile runs |
--source, -src |
coraine-build's own, /usr/share/coraine/src/coraine-src.tar.xz; else <clones> |
the coraine source Compile builds from (passed on as --source): a tarball, or a directory of clones |
--token-file, -tf |
$C3_TOKEN |
a file holding the access token sent to every broker that has none of its own (Explore, below) - read again whenever it changes |
Every request c³ sends - to a broker, an OpenID provider, a verifier - goes through the proxies of the
environment: https_proxy, http_proxy, no_proxy (or their upper-case names). A broker on this machine
wants no_proxy=127.0.0.1,localhost. A certificate of a private CA (a local data space's): SSL_CERT_FILE, a
bundle with it.
Every option can also be given in the environment: C3_ and the option's name, upper case, - as _
(C3_CORAINE_BUILD, C3_SOURCE).
Opening the application:
- Chrome or Chromium in
$PATH(google-chrome,google-chrome-stable,chromium,chromium-browser; on macOS also in/Applications): an application window of its own,--app=<url> --start-fullscreen, with a profile of its own in$XDG_STATE_HOME/c3/chrome(~/.local/state/c3/chrome). A Chrome that is already running with the normal profile would take the request over and open an ordinary window, ignoring both flags. - Otherwise the default browser:
xdg-open,openon macOS. - No screen (Linux, no
$DISPLAYand no$WAYLAND_DISPLAY): the URL is printed, with thessh -Lline to reach it from another machine.
The backend answers this machine only: a connection from another address gets 403, and so does a
request whose Host is not localhost, 127.0.0.1 or [::1] (another site's page, through DNS
rebinding, cannot reach the proxy). From another machine: ssh -L 7900:localhost:7900 <host>.
A second c3 on a port where one is running opens the running one and exits; any other program on
the port is an error.
~/.config/c3/c3.json, written by the backend as the user works - never by the page. It holds the
brokers connected to, with Explore's choices for each (the types, graph or map, the map's view, what the
markers are coloured by), the downloaded map areas, and the last page:
{
"version": 1,
"brokers": [
{
"url": "http://localhost:1090",
"name": "localhost:1090",
"lastUsed": "2026-10-09T17:35:43Z",
"tenant": "",
"tenants": ["city"],
"connect": {
"types": ["BikeStation", "District"],
"tenantTypes": { "city": ["Street"] },
"view": "map",
"colourBy": "inDistrict",
"relationships": "selected",
"map": { "center": [-71.135, 42.3836], "zoom": 13.3 },
"alerts": [
{ "id": "station-empty", "title": "Station empty", "severity": "warning", "type": "BikeStation", "q": "availableBikes==0" }
]
}
}
],
"demos": {
"harbour": { "url": "http://127.0.0.1:7995/c3.json", "hint": "start it with make demo" }
},
"maps": {
"areas": [
{ "id": "area-20261009-191900", "name": "Boston", "bbox": [-71.19, 42.3, -70.99, 42.4],
"maxzoom": 15, "bytes": 13929458, "created": "2026-10-09T19:19:00Z", "build": "20261009" }
]
},
"compile": {
"choice": { "mode": "release", "features": { "REGISTRATIONS": false, "MONGOC": false } },
"presets": [ { "name": "edge node", "mode": "release", "features": { "REGISTRATIONS": false, "SUBSCRIPTIONS": false, "MONGOC": false } } ]
},
"run": {
"brokers": [
{ "name": "mt", "from": "installed", "build": null, "image": null, "where": "native", "set": "mongoc-timescale",
"store": "mongoc", "dbDir": false, "history": "timescale", "port": 1592, "healthPort": 1593,
"transports": ["ws"], "bridges": [], "bridgeConfig": null, "admin": true, "distributed": true, "verbose": true, "args": "",
"grace": 30, "lastStarted": "2026-10-10T08:00:00Z" }
]
},
"ui": { "lastPage": "connect" }
}- A broker is added, or moved to the front with a new
lastUsed, when it has answered the discovery; the 50 most recent are kept. A broker already in the list keeps its other members. tenant: the tenant Explore works on at this broker ("": the default one);tenants: the ones used, most recent first, 20 at most.connect.types: the types loaded with Load in the default tenant, sorted;connect.tenantTypes: the same per tenant.[]is a choice too (nothing loaded); a broker with notypeswas never picked for.connect.view(graphormap),connect.colourBy(type, or a Relationship's name) andconnect.map(center[lon, lat],zoom,bearing,pitch): kept as they change - the map's view when the map is moved by hand.connect.relationships:allorselected(the map's relationships).connect.alerts: the broker's alert rules (Alerts, below) -id(letters, digits,-,_,.; at most 64),title,severity(critical,warning,info),type,q; at most 32.demos: named demos (Named demos, below) - a name and its descriptor'surl, with ahintfor when it does not answer (or just the URL as a string).CDMADis built in.maps.areas: the areas downloaded, one.pmtilesfile each in the data directory. The directory is the truth: a finished download is added, an area whose file is gone is dropped, a file with no entry is added under its id (its bounds and levels read from the file's header).- Written whole to
c3.json.tmp, then renamed overc3.json(mode 600, the directory 700). compile.choice: Compile's choice as it is (mode, thefeaturesset by hand,workload), kept as it changes;compile.presets: the choices saved under a name. The builds themselves are not listed here: their directory is the truth (Compile, below).run.brokers: the brokers Operate started, with their options (Operate, below) - kept when one is started, removed with Forget. What runs is not in the file: the processes and containers are the truth.- No access token is ever in it, nor a broker's own headers: remembered, they are in
secrets.jsonbeside it (Explore, below). - Members this version does not know are kept. A file that is not a JSON object is moved to
c3.json.brokenand c³ starts from an empty one; a file with aversionabove 1 is read and never written.
-
The tenant. The field beside the URL: the tenant (
NGSILD-Tenant) everything is read from - the discovery, the entities, the live values; empty, the default tenant. It is kept per broker, and offered again with the ones used before and, from coraine with its admin plugin,GET /admin/tenants. Another tenant is a new connection: its own types, its own picks. -
Local only. The tick beside the tenant: off (the default), the broker is read as it answers - with the entities of its Context Source Registrations. On, every query carries
local=true: the broker's own entities only, its types and attributes the same. Kept per broker ("local"in itsconnect). -
The access token. Token beside Connect - its dot is green when a token is sent. Every request to the broker carries
Authorization: Bearer <token>- through the proxy, the live values' subscription over HTTP, and each message over the broker's WebSocket (in itsmetadata, as coraine reads it). The broker decides what it allows: a 401 shows the broker's reason with a Token… button, a 403 says the token was understood and the request not allowed. A delete on a broker c³ sends no token to - and that answered without one - asks first: anyone who can reach that broker can delete. A broker's token, in this order:- a login to the broker's OpenID provider (Keycloak, Auth0, Entra ID, ...) - in Token, or
Log in… on a 401 when the broker says where its tokens come from (coraine's
/.well-known/oauth-protected-resource, RFC 9728 - the issuer is then filled in): the provider's issuer, a client id, a scope (kept per broker inc3.jsonas"login"; no secret), then Log in. The provider's login page opens in a window of its own, and sends the browser back tohttp://<c³>/api/oidc/callback- the provider's client must be a public one that allows that redirect URI. The authorization code flow with PKCE (S256). The tokens stay in c³'s backend, in memory - never in the page, never on disk: after a restart, log in again. c³ renews the access token before it expires, until the provider ends the session; then the login is over. Log out forgets the tokens. - a credential login - the Data Space Connector (coraine's
fdsc.so, or the connector itself) takes the tokens of its verifier (VCVerifier), for a presented Verifiable Credential: With a Verifiable Credential in the login, the verifier's discovery document, the credential type (Keycloak'scredential_configuration_id) and the verifier's scope - filled in from the broker's metadata where it says. The login is at the consumer's Keycloak; c³ then gets the credential there (OID4VCI, Keycloak's pre-authorized offer), presents it signed by a holder key of its own (P-256,did:key, in memory - as the connector's DID Helper makes one), and sends the verifier's access token. Renewed: presented again before the token expires; a new credential (the consumer's login refreshed) before the credential does. The consumer's Keycloak client c³ logs in as: public, the redirect URI above, and OID4VCI enabled for it (oid4vci.enabled, "OpenID for Verifiable Credentials" in its Advanced settings) - without it Keycloak answers "Client not enabled for OID4VCI". - its own, typed in Token: kept in memory, and with Remember it on this machine in
secrets.jsonbesidec3.json(mode 600 - a file others can read is not used); --token-file <file>: the default for every broker, read again whenever the file changes - so a script outside c³ can keep a short-lived token (the Data Space Connector's last about 30 minutes) fresh;$C3_TOKEN, when there is no--token-file.
A token is never on the command line, never in
c3.json, and never sent to the page: the page learns where it comes from and its last four characters (GET /api/brokers/tokens). - a login to the broker's OpenID provider (Keycloak, Auth0, Entra ID, ...) - in Token, or
Log in… on a 401 when the broker says where its tokens come from (coraine's
-
Headers of its own - in Token, below the token: up to 16 headers per broker, each a name, a value and secret - an API key, a Basic
Authorization, whatever a gateway in front of the broker asks for. They go with every request to that broker, as they are: through the proxy (reads and writes), the live values' HTTP requests, the WebSocket's upgrade request and the metadata of each message on it. AnAuthorizationamong them takes the place of the token. Kept in memory, and with Remember them on this machine insecrets.json(mode 600); a secret header's value is never sent to the page - it shows its last four characters (more than 12 characters long) or its length, and a secret header saved with its value field empty keeps the value it has. Refused: the headers c³ sets itself -Host,Content-Length,Content-Type,Transfer-Encoding,Connection,Upgrade,Accept,Keep-Alive,TE,Trailer,Sec-WebSocket-*,X-C3-Broker,NGSILD-Tenant(the tenant is chosen in Explore). A value has no line break nor other control character and is 8 KB at most. -
Discovery - what a broker holds, before any entity is loaded:
GET /ngsi-ld/v1/types?details=true- the types and their attribute names (a broker that does not answer it is asked forGET /types);GET /ngsi-ld/v1/types/{type}per type, six at a time - its entity count (entityCount) and its attributes with their kinds (attributeDetails[].attributeTypes: Property, Relationship, GeoProperty, ...); a broker that does not answer it is asked forGET /entities?type=T&limit=0&count=true(the count only);GET /ngsi-ld/v1/attributes?details=true- the types each attribute is used in (typeNames).
The panel on the left lists the types, sorted, with their counts; ▸ opens a type's attributes, each with its kinds and the other types that have it. Relationships lists Relationship name, from type and to type, with how many: NGSI-LD's discovery gives an attribute's kind but not the type of the entity a Relationship points to, so these come from the loaded entities, and the panel says so until some are loaded. A broker with no
/typesat all is loaded whole, as in milestone 1. -
Choosing what to see. Each type has a tick: Load loads the types ticked - one query,
GET /entities?type=A,B&limit=100&count=true(with Local only,local=truetoo - without it if the broker refuses it), one per type when the list is longer than 2000 characters - everyrel="next"page, up to 2000 entities in all; past that, a notice says how many there are. The choice is kept inc3.jsonunder the broker. On a first connect a broker of 300 entities or fewer is loaded whole; a larger one shows its types and waits for the pick. Clicking a type shows or hides its entities. -
The graph. An entity is a box (type, the end of its id, its first two Properties); a Relationship (or ListRelationship) to another loaded entity is an arrow, labelled with its name from 55 % zoom up, and always when one of its ends is selected. A type's colour comes from its place in the broker's sorted list of types: the same broker, the same colours, whatever is picked.
-
The layout (www/js/layout.js) is deterministic: the same entities and Relationships give the same picture, whatever order the broker sent them in, so the same picks give the same picture. Layers along the Relationships (every arrow points up: a Robot below its Dock, the Dock below its Floor), the order in a layer from the neighbours' positions (barycentres), ties by type and id; a wide layer wraps, grouped by type; entities with no Relationship either way go in a grid below.
-
Moving around. The wheel zooms at the cursor; the buttons and
+−zoom at the centre, Fit and0show everything; drag the background or the arrow keys to move the view; drag a box to move it. The view stays fitted - also when the window changes size - until it is moved by hand. -
An entity. A click (or Tab to its box) shows its attributes, with their kinds, values,
unitCode,observedAt,datasetIdand sub-attributes, and its JSON. A Relationship's target is a link: it selects the target and centres the view on it (switching its type on if it was off); a target that is not loaded opens its neighbourhood. -
An entity's menu - right-click its box, or the context-menu key / Shift+F10 on a focused box. Arrows, Home/End and a letter move in it, Enter or Space chooses, Right/Left open and close a submenu, Esc closes it and gives the focus back to the box.
- With linked entities › Level 1, 2, 3 -
GET /entities/{id}?join=inline&joinLevel=N: the box grows into one box holding the entities its Relationships point to - each with its type, the end of its id and up to three Properties - and inside them theirs, level by level (entityof a Relationship,entityListof a ListRelationship). A Relationship not embedded says why: deeper than the level asked for, or not embedded by the broker. × or Esc closes it. - Neighbourhood - only this entity and the entities one Relationship away, out or in: its targets
(the loaded ones, the others by
GET /entities?id=a,b,...) and the entities pointing at it (the loaded ones, and the broker's:q=<r1>=="<id>"|<r2>=="<id>"|...over every Relationship name of the discovery, the first 300). Laid out on its own; Back to the picked types returns to them. - Follow › one item per Relationship target: selects and centres it, or opens its neighbourhood when it is not loaded.
- Hide type , Copy id, Show JSON (the entity as the broker returned it, in a panel with Copy; Esc closes it).
- Edit… - the editor (below), also the Edit… button of the attribute panel; Replace… - the
same rows, Save sends the whole entity (
PUT /entities/{id}). - Delete entity… › Delete - at the broker:
DELETE /entities/{id}.
- With linked entities › Level 1, 2, 3 -
-
An attribute's menu - right-click it in the attribute panel: Edit … (the editor, that attribute opened), Delete … (
DELETE /entities/{id}/attrs/{name}, withdatasetIdfor an instance of its own) and, for an attribute with several instances, Delete every instance (deleteAll=true). -
Creating an entity - right-click where there is no entity: in the graph Create entity…, on the map Create entity here… (its
locationthat point). The editor, with the id and the type typed in - the types of the broker offered, and + 's attributes: the attributes its loaded entities have, each empty of its kind (a Property with the JSON type of a loaded value). Save sendsPOST /entities; an entity of a type not loaded is created and not shown, and a toast says so. -
The editor - the entity as rows, over the graph's right side (the graph stays usable):
- each attribute instance is a row: ▸ opens its sub-attributes, 🗑 marks it deleted (struck
through until Save; ↺ restores it); a
datasetIdshows as a badge; - the row's input is its kind's: a Property's value with its JSON type (number, text, true/false,
JSON), a Relationship's target - typed, or pick in graph (the next entity clicked, in the graph
or on the map) -, a Point's longitude and latitude - typed, or pick on map, the next point clicked -, a LanguageProperty's languages, a list's items,
a JsonProperty's JSON;
observedAtwith now; - + sub-attribute at the end of a row's sub-attributes, + attribute at the bottom - its name
(the names of the loaded entities' types offered), its kind, and a
datasetIdfor another instance of an attribute; an existing attribute keeps its kind; - changed rows are marked, and an input that does not parse is marked and stops Save;
- Save as (an edit's): how the changes are sent - kept from one edit to the next:
- Merge (the default) -
PATCH /entities/{id}, TS 104 175 § 10.2.9: what changed,"urn:ngsi-ld:null"for what was deleted - an attribute, a sub-attribute, a key inside an object value, one instance (with itsdatasetId). A changed attribute always carries its main member (value,object, ...) and a GeoProperty its whole value: the merge needs neither, some brokers refuse a fragment without them; - Update attributes -
PATCH /entities/{id}/attrs: each new or changed instance whole,"urn:ngsi-ld:null"for a deleted attribute or instance; - Append attributes -
POST /entities/{id}/attrs: each new or changed instance whole; a deletion is not sent (an append cannot delete) and the request says how many are left out; - Append, no overwrite -
POST /entities/{id}/attrs?options=noOverwrite: the same, and the broker leaves an existing attribute as it is - its 207 names them; - Partial attribute update -
PATCH /entities/{id}/attrs/{attrId}, one request per changed instance, one after the other: its changed members whole (the broker replaces them, it does not merge into them),"urn:ngsi-ld:null"for a deleted sub-attribute, itstype,datasetIdand main member always. A new attribute or instance and a deleted one are not sent (left out, and counted). The first request that fails ends it; those before it stay done;
- Merge (the default) -
- The request at the bottom: what Save sends - the method, the path and the body (each of them, for a partial update);
- after Save the entity is read again and drawn; the broker's error (a 401, a 403, a 400's detail, a 207's attributes not updated) shows in the editor, which stays open; Esc closes it (twice, with unsaved changes).
- each attribute instance is a row: ▸ opens its sub-attributes, 🗑 marks it deleted (struck
through until Save; ↺ restores it); a
-
States. A splash screen while the backend gets ready - each step a real request (the backend, the configuration, the last broker's
/version, at most 3 s); discovering, with progress over the types; loading, with progress over the pages; connected; an error card when the broker cannot be reached or answers with an error, with Retry.
- Graph / Map switches the view, kept per broker. On the map: every loaded entity with a GeoProperty
(
locationfirst, then any other -route,observationSpace, ...), each geometry drawn: a Point as a marker, a LineString as a line, a Polygon as an area (and the Multi- forms), in the type's colour - the same colours as the graph. An area's or a line's name is written at its centre. - Relationships between two entities on the map are lines from one to the other, with an arrowhead at the target (from zoom 15). A selected entity's are drawn in magenta with their names, as in the graph. An entity's Relationships start at its first Point, else the middle of its first line or the centre of its first area.
- Click selects (the attribute panel); right-click opens the entity's menu, with Show in the
graph - and the graph's menu has Show on the map. On the map's canvas the menu key / Shift+F10
opens the selected entity's menu, Esc unselects,
0fits. With linked entities opens in the graph. Hiding a type hides it on the map too. - Fit zooms to the entities shown;
on the map: N · not on the map: Mcounts them (the title names the types of those without a location). - Colour by:
type, or any Relationship of the loaded entities (inDistrict →): each target gets its colour, and the entities pointing at it and the target itself are drawn in it, with a legend. Kept per broker. - relationships:
alldraws every Relationship;selectedonly the selected entity's (a route with dozens of stops). Kept per broker. - Icons: a Point of a kind c³ knows by its type's name is an icon instead of a dot - a bus (
Bus,Tram, a vehicle), a stop (BusStop,...Stop), a streetlight (Streetlight,Streetlamp), an air quality station (AirQuality...), a weather station (Weather...), a traffic sensor (Traffic...), a person (Person,Driver); any other type a dot. The entity's state colours it over its type's colour: astatusthat says broken (broken,faulty,outOfOrder, ...) orcongestedtruered, anairQualityLevelon its scale (goodgreen toveryPoorred), apowerStateonwarm yellow,offdim grey. - Movement: a Point that a live change moves glides to its new place over the time between its last
two changes (0.2 - 5 s; 1 s the first time), instead of jumping; its Relationships' lines follow. Not
with
prefers-reduced-motion. The entities that moved in the last 20 s are drawn from a source of their own, set again every frame; the others' source is set again only when what it draws changed. Measured with 30 buses moving among 1 464 other features (Chrome, the map's own render included): 2.4 ms a frame (median), 4.0 ms (95th percentile). - The first time a broker's entities are drawn, the map takes the view kept for it, else fits them.
The base map. MapLibre GL JS, styled dark in c³'s colours (land #111419, sea #0B1620, roads in
the panel greys - the entities' colours are what stands out), with no POI icons or road shields, so no
sprite is needed. Two sources of OpenStreetMap data, one look:
- online - OpenFreeMap's vector tiles (
tiles.openfreemap.org/planet, OpenMapTiles schema, no key), drawn with c³'s own style layers; - downloaded areas - Protomaps basemap tiles from the
.pmtilesfiles, drawn with the layers of@protomaps/basemapsin the same colours; the online tiles that lie wholly inside an area are not asked for, a mask in the sea colour covers the area's box, and the online labels are kept out of it. - Labels: the Noto Sans glyphs - from the data directory once an area is downloaded, else from
protomaps.github.io/basemaps-assetsonline. - "© OpenStreetMap contributors", OpenFreeMap / OpenMapTiles and Protomaps are credited in the corner.
When the online tiles cannot be reached, a notice says so:
online map not reachable - downloaded areas only.
Download this area (on the map's bar):
- The size first. The area in view, the level of detail chosen (10, 12, 13, 14 or 15 - each level adds about four times the tiles), and its size: the backend reads the planet's directories for the tiles of that box and adds up their lengths - exact, before anything is downloaded. "Plus the fonts, about 14 MB, once" the first time.
- The extract.
POST /api/maps/download: the backend extracts the tiles from the newest Protomaps planet build (build.protomaps.com/YYYYMMDD.pmtiles, ~139 GB, read by HTTP Range requests - grouped into ranges of up to 4 MiB, 6 at a time) into one PMTiles v3 file,<data>/c3/maps/<id>.pmtiles($XDG_DATA_HOME, else~/.local/share); written as<id>.pmtiles.partand renamed when complete; cancelled or failed, the part file is deleted. Then, the first time, the glyphs: 3 font stacks × 256 ranges into<data>/c3/maps/fonts/. Progress and Cancel in the panel; one download at a time. An area of more than 1 000 000 tiles is refused (a smaller area or a lower level). - Drawn from the file. The page reads it with the pmtiles library through
GET /api/maps/areas/<id>.pmtiles, which the backend serves with HTTP Range (206,Content-Range,ETag;416past the end). Inside the area the file is used down to its level, then magnified. - The list: name, size, level, date and planet build of each area, Show (zooms to it) and Delete (press twice). The areas' boxes are outlined on the map, dashed.
Offline means: the tiles and labels of the downloaded areas. Outside them, offline, the map is empty (the low-zoom tiles of an area cover more than its box, so its surroundings show coarsely). Making an area needs the network once: the planet is read online.
Why the backend extracts, in C. OpenFreeMap's terms forbid collecting its data "in automated ways
without permission", so its tiles cannot be bulk-downloaded; Protomaps publishes its planet builds for
exactly this, with pmtiles extract (the Go CLI, BSD) as the reference. c³ does what that command does,
in src/maps.c, rather than running the CLI:
- one thing to install fewer - the CLI is a separate binary in no distribution's packages, and c³ is meant to be a package itself;
- the size before the download (the CLI prints it only with
--dry-run, as text), progress and Cancel through the API, the part file cleaned up; - the same result: for the same box and level (central Boston, level 15) both read the same 377 tiles,
byte for byte, and
pmtiles verifyaccepts c³'s file.
The cost: the tiles of the online map (OpenMapTiles schema) and of the downloaded areas (Protomaps schema) need two sets of style layers, kept in the same colours.
When an attribute of a loaded entity changes in the broker, its box and its marker change within about a second - flashed for a moment, the rows that changed in amber; an entity of the loaded types created in the broker appears (in green), one deleted disappears; a changed Relationship moves its arrow. The attribute panel of the selected entity follows. Nothing is reloaded.
-
One subscription per page, for the types loaded, watching every attribute and every kind of change:
notificationTriggerentityCreated,entityDeleted,attributeCreated,attributeUpdated,attributeDeleted;formatnormalized; namedc3-live:<port>. Created after a load, replaced when the picks change (the new one first, then the old one deleted - no change is missed in between), deleted when the page closes (the window, or the page left - it is closed onpagehide, so a page kept in the browser's back/forward cache keeps none) and when the backend stops. -
How the notifications arrive - the backend holds the broker side; the page has one WebSocket to the backend,
GET /api/live:- (a) the broker's WebSocket transport (coraine's doc/websocket.md,
coraine --transports ws): the backend opensGET /ngsi-ld/v1/wson the broker, creates the subscription over it (POST /ngsi-ld/v1/subscriptionsin a message'smetadata, the endpoint the connection's name,urn:ngsi-ld:WebSocket:<n>) and the notifications come back on the same socket. The broker needs to reach nothing, and it deletes the subscription itself when the socket closes - also when c³ is killed. Tried first. - (b) an HTTP subscription whose endpoint is the backend,
http://127.0.0.1:<port>/api/notify/<session>(a random 128-bit session id): any NGSI-LD broker, as long as it can reach the backend - a broker on the same machine. Used when the broker does not upgradeGET /ngsi-ld/v1/ws(a broker without the transport answers 404).
Live · WebSocket / Live · HTTP next to the counts says which; its tooltip gives the subscription and, for HTTP, why not the WebSocket. Not live with the broker's answer when the subscription is refused.
- (a) the broker's WebSocket transport (coraine's doc/websocket.md,
-
No orphans. A subscription made over the WebSocket dies with the socket. An HTTP one of a backend that was killed (
kill -9, a crash) is deleted by the next start on that port: the brokers of the configuration (the 10 most recent) and, before its first subscription, any broker connected to, are asked for their subscriptions, and those namedc3-livethat notify this port's/api/notify/for a session that is not running are deleted. -
In batches. The changes are gathered and applied every 250 ms - one entity notified several times in that time is applied once - not one redraw per notification. A changed value rewrites the box's rows and moves the marker; an entity created or deleted lays the graph out again (keeping the view, the selection and the boxes moved by hand); a changed Relationship redraws the arrows only.
-
When the broker goes away (stopped, restarted): Live · reconnecting, and the page watches again every few seconds until it is back; when the backend goes away, the page reconnects to it.
-
In a neighbourhood, the values of its boxes change; created and deleted entities show on Back.
-
A broker with no discovery (milestone 1's "every entity") is watched for the types of the entities loaded.
The page gets the changes over a WebSocket to the backend - the page sends on it too (what it
watches) - and corHttp's corHttpUpgrade hands the backend the socket - the same contract coraine's
WebSocket transport is built on. If WebSocket between the backend and the brokers turns out too slow,
cor:// in the backend is the plan; not before measuring.
examples/live.sh [broker-url] [seconds-per-step] [steps] - the factory at work:
the robots of the three running Missions drive their Mission's route (route, a LineString) to the end
and back - location, speed and batteryLevel change, the Mission's progress follows; the robots at
a charger charge; every 16 steps a Mission (urn:ngsi-ld:Mission:m-live) is created, 16 steps later
deleted (CHURN=0 leaves that out). Ctrl-C deletes it if it is there.
Not yet: a rectangle drawn on the map as a georel=within query, saved views.
A name in the broker field instead of a URL - CDMAD, Explore - is a demo: c³ asks the backend for
it (GET /api/demos/<name>), the backend looks the name up in the configuration's demos (any case;
CDMAD, c3-demo-madrid, is built in as
http://127.0.0.1:7990/c3.json) and fetches the demo's descriptor from it:
{
"name": "Madrid",
"broker": "http://127.0.0.1:9026",
"tenant": null,
"view": { "mode": "map", "center": [-3.693, 40.43], "zoom": 12.6, "relationships": "selected" },
"watch": ["Bus", "BusStop", "Streetlight"],
"alerts": [ { "id": "streetlight-out", "title": "Streetlight out", "severity": "critical", "type": "Streetlight", "q": "status==\"broken\"" } ]
}Explore then connects to broker (in tenant). What the broker has not been given in c³ yet comes from
the descriptor: the view (mode, center, zoom, relationships), the types loaded (watch), the
alert rules (alerts); what is changed in c³ is kept for the broker as for any other, and wins the next
time. A demo that does not answer is said so: CDMAD is not running at http://127.0.0.1:7990 - start it,
then Start broker + populate; its broker not answering: start it there (Start broker + populate). A
name the backend does not know is a host name (localhost is http://localhost).
Alarms (next to Live) holds the broker's alert rules and the alarms they raise.
- A rule: a title, a severity (
critical,warning,info), an entity type and aq. Each rule is a subscription of its own that the backend makes on the broker, with the live values' (the same endpoint: the broker's WebSocket, or/api/notify/<session>; namedc3-live:<port>; deleted with them). A rule the broker refuses (aqit does not take) says why under Rules…. - An alarm: a rule's notification raises one for each entity in it - a toast (a click shows the entity), a row in the list, and the entity ringed on the map in the severity's colour, pulsing until acknowledged (Ack). The bell's count is the alarms not acknowledged. An alarm already raised is not raised again while it lasts. The loaded entities that already match a rule are alarms from the start, without a toast.
- Cleared when the live values show the entity no longer matching - c³ evaluates the simple forms of
qitself: terms joined by;, each<attribute><op><value>with==,!=,>,<,>=,<=and a"string", a number,trueorfalse. An alarm of a rule with any otherqclears by hand (Clear); one whose entity is deleted clears. - Rules…: added and removed per broker, kept in the configuration (
connect.alerts).
On the page's WebSocket: {"op": "alerts", "broker", "tenant", "rules": [{"id", "type", "q"}]} sets the
rules; the backend answers {"op": "alerts", "broker", "installed": [{"rule", "subscription"}], "failed": [{"rule", "detail"}]} and sends each notification as {"op": "alert", "broker", "rule", "message"}.
The attribute panel's History: each numeric Property of the selected entity over the last 10 minutes,
as a sparkline (a step line - a value holds until the next) with its range and count. Read once on
select - GET /ngsi-ld/v1/temporal/entities/{id}?timerel=after&timeAt=<10 minutes ago>&attrs=...&timeproperty=modifiedAt&options=sysAttrs
(every change, with an observedAt or not; a value's time is its observedAt if it has one) - then grown
from the live values. A broker without the temporal API, or whose TRoE plugin does not read history
(coraine's --troe ramDB answers 422), shows the live values from the moment the entity was selected,
and says so. coraine with --troe corDB answers it.
A front-end to coraine-build (doc/compile.md): c³ builds nothing itself - every build
is the command shown, run by the backend, the same build as on the command line.
- What this machine can do -
GET /api/capabilities:coraine-build(--coraine-build, else$PATH) and the source (--source, else the tarballcoraine-devinstalls) must both be there. Withoutcoraine-dev, both come from<clones>: the directory of git clones c³ was built against (the makefile'sCOR_LIBS, made absolute -C3_CLONES; empty in a package). Its coraine clone'scoraine-buildexports them as the packages' tarball is made: coraine at the commit checked out, each lib atorigin/main- committed trees only. Without them the page is greyed with Install coraine-dev to build brokers; the builds kept are still listed and can be deleted. Also: whether--pgo/--tunecan train here (curl,wrk,python3), whether the mongo-c driver v2 is installed (MONGOC), the host's architecture and distribution. Asked again every time the page is shown. - Features - from
coraine-build --list-features --json(cached percoraine-buildand tarball): each switch with its default and description; one changed from its default is marked; one whoserequiresare off is greyed with what it needs (CMakeLists.txt turns it off). MONGOC is the Stores choice (corDB only / corDB + mongoc); without the mongo-c driver the page starts at corDB only. - Optimisation - Release, Profile-guided (
--pgo), Tuned for a workload (--tune <file>, the workload by its absolute path on this machine), Debug. The training modes are greyed when the tools are missing. - Target - shown greyed:
coraine-buildbuilds a binary for the host. - HTTP server - libmicrohttpd (the default) or coraine's own (
--http-server builtin; not with the training modes). - Executable - the broker's file name in
install/bin(--name, defaultcoraine): a broker built for one role named after it, acor-agent. It reads the sameCORAINE_*environment variables, and Operate starts it by that name. - The command -
POST /api/builds/commandanswers the exactargvfor the choice; the page shows it, one option a line. Only the switches that differ from their defaults are passed. Build starts that command under the name it showed (the name field, else<mode>-<date>-<time>). - Following a build - its output line by line, a bar over the phases
coraine-buildannounces (coraine-build: phase build,install,bridges,check,done;toolsandtrainfor the training modes; unpack before the first): how far into a phase is its line count against the same phase of the last build of that mode that succeeded, with that build's phase times as the weights. Cancel: SIGTERM to the build's process group, SIGKILL 5 s later. - The result - the size of
install/and of the broker, the mode and time, the features as the built broker reports them (install/bin/<executable> --version, the switches off struck through), its--version, the directory, the whole log. Run it opens Operate with that build in the form. - Builds kept - newest first, with their mode, date, size and the features turned off; a click opens one (its log and result); Delete (press twice) removes its directory, log and record.
- Presets - the choice (mode, the features changed, the workload) saved under a name in
c3.json, loaded again, deleted. The current choice is kept there too, as it changes.
Where the builds live: $XDG_DATA_HOME/c3/builds/ (else ~/.local/share/c3/builds/):
<name>/ coraine-build's <dir>: the source, the build tree, install/ (the result)
<name>.json the build's record - c3-build.json
<name>.log everything coraine-build wrote, stdout and stderr, after two lines of c³'s own
The record and the log are beside the directory, not in it: coraine-build takes a new or empty
directory (or one it unpacked into), so nothing of c³'s can be there first. The record:
{
"name": "page-edge", "executable": "coraine", "state": "succeeded", "mode": "release",
"features": {"REGISTRATIONS": false, "SUBSCRIPTIONS": false, "MONGOC": false},
"workload": null,
"command": "/usr/bin/coraine-build --release --features REGISTRATIONS=OFF,SUBSCRIPTIONS=OFF,MONGOC=OFF ~/.local/share/c3/builds/page-edge",
"dir": ".../builds/page-edge", "install": ".../builds/page-edge/install", "log": ".../builds/page-edge.log",
"phase": "done", "phases": [{"name": "build", "at": "2026-10-10T00:08:14Z", "line": 4}, ...],
"lines": 741, "started": "2026-10-10T00:08:14Z", "ended": "2026-10-10T00:08:38Z", "seconds": 24,
"exitStatus": 0, "signal": null, "installBytes": 11415094, "binaryBytes": 4569928,
"version": "coraine 0.4.0\nfeatures: SUBSCRIPTIONS=0 REGISTRATIONS=0 ...\nhttpServer: mhd\n",
"builtFeatures": {"SUBSCRIPTIONS": false, "REGISTRATIONS": false, "GEOQ": true, ...},
"error": null
}state: running, succeeded, failed (with error), cancelled, or interrupted - a record c³
finds running when it starts (it stopped during the build).
Process control. One build at a time (409 for a second). coraine-build runs as a child in a
process group of its own, stdin /dev/null, stdout and stderr into a pipe, none of c³'s file descriptors
(its listening socket least of all), SIGTERM if c³ dies; a thread of the build reads the pipe, writes the
log, counts the lines and notes the phases. When the script has exited, whatever is left of its process
group is stopped too (SIGTERM, then SIGKILL after 3 s), and when c³ stops, a build that runs is cancelled
and waited for: no make, gcc or training broker outlives a build. coraine-build writes only under its
<dir>: it passes PLUGIN_DIR=<dir>/install/plugins to every make, and never runs the broker's own
install (whose ETC_DIR is /opt/seamware/etc).
The live log - GET /api/builds/<name>/log with Accept: text/event-stream (the page's
EventSource): server-sent events over a chunked response (corHttp's corHttpResponseStream). Each read
of the pipe is one event of its complete lines, data: a line, its id the log's byte offset after
them - a page that reconnects (Last-Event-ID) goes on from there; at most the last 4 MiB of the log
first (an event: skipped says how much is left out). event: build carries the record at the start
and at each phase, event: end the final record, and the stream ends; a comment every 15 s while the
build is quiet. Without that Accept, the request answers the log file (text/plain, Range taken).
Server-sent events and not the page's WebSocket (/api/live): the log only flows one way and resumes by
its byte offset, which EventSource does by itself (Last-Event-ID); and a write to a corHttp stream
never waits - bytes a page has not taken wait in the stream (16 MiB at most, then that page is
disconnected) - so a slow page cannot hold up the thread that reads the build's pipe.
Brokers started and stopped on this machine, and the services they use (doc/design.md, Run).
What a broker is started from
| From | Runs | How |
|---|---|---|
the installed coraine ($PATH) |
natively | coraine <options> |
| a build of Compile's | natively | /bin/sh -c '<script>' <build>/install/env <build>/install/bin/coraine <options> - the script sources install/env (its own PATH, SEAMWARE_PLUGIN_DIR and etc, as . install/env by hand) and, for a tuned build, install/etc/tune.env as the image's entry point does: TUNE_DBLOCKPREFER as CORAINE_DBLOCKPREFER (an option on the command line still wins), TUNE_ALLOCATOR preloaded (LD_PRELOAD, found with ldconfig -p; not installed: glibc's, and the broker's log says so). It writes one line to the broker's log, c3: <tune.env> applied - ... |
an image (quay.io/seamware/coraine:<tag>) |
in a container | docker run -d --name c3-broker-<name> ... <image> <options> - the image's entry point is coraine (in the foreground, the log on stdout) |
An image of Compile's is greyed (Compile makes no image yet), and so is a build in a container. The tag is
chosen from the images here and the tags published on quay.io (GET /api/run/images, which reads quay.io's
API: the multi-architecture tags, not the -amd64/-arm64 ones), or typed; a tag that is not here is
pulled at the start, its progress in the broker's log. Images are named by their tags - none is
latest.
The form - one of three sets, then each option as wanted:
| Set | Store | History | Bridges | Services (containers) |
|---|---|---|---|---|
| corDB alone | corDB, on disk | corDB | - | - |
| mongoc + timescale | mongoc | timescale | - | MongoDB (mongo:8.2), PostgreSQL + TimescaleDB (timescale/timescaledb-ha:pg16) |
| with MQTT | corDB, on disk | corDB | mqtt | Mosquitto (eclipse-mosquitto:2, /mosquitto-no-auth.conf) |
- Store corDB or mongoc; corDB's keep it on disk is
--dbDir: natively<run>/<name>/data, in a container the volumec3-broker-<name>at/data. - History
--troenone, corDB or timescale. - Port and health port (
--healthPort; empty: none), grace - the seconds from SIGTERM to SIGKILL. - Switches - the WebSocket transport (
--transports ws), the admin API (--apiPlugins admin), distributed operations (--distributed: queries and updates forwarded to the Context Sources registered - on by default; off and greyed for a broker built without REGISTRATIONS),-v. - Bridges - those the broker has (the
bridge/*.soof its plugin directory), and a bridge configuration file; without one, c³ writes<run>/<name>/bridges.json: the set's Mosquitto for mqtt, the DDS domain and mapping for dds, the CKAN datasets ticked, the OPC UA server. - DDS - the bridge configuration file (
--bridgeConfig) and the default user@contextfile (--defaultUserContext), and Map DDS topics, services and actions, the width of the form, active with the dds bridge ticked: the DDS mapper (below). A container broker with the dds bridge runs with--network host --ipc host: DDS discovery is UDP multicast, which a bridge network such asc3keeps to its own containers, and Fast DDS reaches a participant on the same machine over shared memory. Its port is then the host's - not published on127.0.0.1only: coraine listens on every interface, as a native broker does - and it reaches the services on127.0.0.1:<port>, as a native broker does. Its files are mounted: the--defaultUserContextfile at/etc/c3/user-context.jsonld, c³'sdds-types/at its own path. - OPC UA (native brokers - the image has no opcua bridge) - the server's URL, the model roots (an
Object's NodeId a line: each one an AssetChannel, its model a graph of entities), the security mode and
policy, the client's certificate, key and application URI, the server certificates to trust (or "trust
any", which says what it means), a username and password. Filling in the URL ticks the opcua bridge.
The password is sent once, on Start: c³ keeps it in
<run>/<name>/opcua.password(mode 600) and names that file inbridges.json; it is never inc3.jsonor the API, which says onlypasswordSet. Empty keeps the one c³ has. Channels - a Variable each, its value one attribute of an entity: the NodeId, the entity's id and type, the attribute (the bridge'stopicsinbridges.json). Browse the server - for a running broker with the admin API (its Options): the server's nodes as the broker's bridge sees them, one level at a time (GET /admin/bridges/opcua/inventory, coraine with bridge ABI 14). A Variable shows its value, DataType, unit and whether it can be written, and + Channel adds it - named as an AssetChannel on its Object would name it (the Object'stypeNameand name, the attribute its path from the Object), so that both write the same entity; every name stays editable. An Object's + model root adds it to the model roots. What is picked takes effect when the broker is stopped and started again. - More options - anything else, as typed (quotes keep words together).
- What the broker is built without is greyed with the reason - mongoc without
MONGOC, the health port withoutHEALTH, the transport withoutTRANSPORTS, the admin API withoutADMIN_API- from its--version(the installed one's, or the build's record); an image's are not known before it runs. Tenants have no switch at start: the form says whether the broker has them compiled in. - The command, exactly as it will run, from the backend (
POST /api/run/command), and the services it starts first with their ports.
Services. A set's services are containers of its own, c3-<set>-<service>, shared by the brokers of the
set, each with a volume of the same name and on the network c3; published on 127.0.0.1 only, on the
service's port + 10000 (MongoDB 37017, PostgreSQL 15432, Mosquitto 11883 - the next free one when that is
taken), so they do not collide with a MongoDB or PostgreSQL of one's own. Each broker has its own
database in them: mongoc --dbName c3_<name>, timescale --troeName c3_<name>_h (. and - as _).
A native broker reaches them on 127.0.0.1:<port>, a container broker by name on the network
(c3-mongoc-timescale-mongo:27017) - one with the dds bridge, on the host's network, on
127.0.0.1:<port> too.
- Started before the broker that needs them and waited for: MongoDB until
mongoshanswers its ping, PostgreSQL untilpg_isready, Mosquitto until it takes a connection (3 minutes at most). MongoDB is a single node, not a replica set: coraine needs one only for--high-availability mongo. mongo:8.2, not 8.0: mongo 8.0 does not start on Linux 6.19 and newer (SERVER-121912).- Stopped (
docker stop, thendocker rm) when the broker using them stops and nothing else started from c³ uses them; Stop on a service nothing uses. The volume is kept: Delete data (press twice) removes it, and only a volume c³ made (labelc3=run) whose container does not run.
Starting. The broker joins Explore's list (http://localhost:<port>, named as in Operate) and is kept in
c3.json. A native broker is started in a session of its own (setsid), its stdout and stderr appended
to its log, in its directory, with c³'s environment less CORAINE_* and SEAMWARE_PLUGIN_DIR (each option
of the broker's can be set from the environment; the form is the whole of it). It does not need c³: when
c³ stops, the brokers keep running. A container broker listens on 1026 inside its container, the port
chosen published on 127.0.0.1; one with the dds bridge listens on the port chosen, on the host's
network. The image's HEALTHCHECK asks the port in the broker's arguments (--healthPort, else
--port); in images from before 2026-10-10 it asks localhost:1026/version, and a broker on another
port is reported unhealthy.
Stopping. SIGTERM to the broker's process group - corDB syncs its log and writes its snapshots - and
SIGKILL when the grace period is over; the page counts it down. A container: docker stop -t <grace>
(the same two signals), then docker rm. Then its services, as above.
Status - every 2 s for each broker that runs: GET /ready on its health port, whose answer is the
health report (uptime, the store and the history plugin loaded and reachable, memory, requests), else
GET /version on its port. Shown as ready, not ready, answers (no health port) or no answer. A broker
that ends by itself is shown with its exit status and why.
After a restart of c³. A native broker's state.json holds its pid and the start time of that process
(/proc/<pid>/stat): when that process still runs, c³ attaches to it - it is watched by pid and start time,
not reaped (it is not c³'s child any more), and its exit status is not known. Containers are found by
their label c3=run. A container's log follower (docker logs -f) is started again from the time the log
file last changed.
The log - GET /api/run/brokers/<name>/log (and /api/run/services/<container>/log) as server-sent
events, as a build's: the file from Last-Event-ID, then each new line; event: reset when a new run
starts its log (the run before is broker.log.1). Without Accept: text/event-stream, the file (Range
taken). A native broker writes its log itself; a container's is filled by docker logs -f; c³'s own lines
start with c3: .
Configuration and build - above the log of a running broker with the admin API, read-only: what it is
built with (GET /build - version, debug or release, HTTP server, commit; each feature compiled in or out;
the plugins loaded) and every option it runs with (GET /admin/config, coraine with ADMIN_CONFIG) -
value, where it came from (command line, environment, default; "resolved by the broker" when a default of 0
means the broker chooses), the default, and on hover what it does, its environment variable and short form;
a plugin's options under its section. A secret (--dbPwd, --dbURI, --troePwd) comes masked from the broker.
Metrics - above the log of a running broker with the admin API: its /admin/metrics as charts over
the last 10 minutes, read every 5 s while the panel is open - from every broker running here with the admin
API, so one selected later has its last minutes too. Requests per second (all, reads, writes), errors (4xx,
5xx), latency (the mean and the 95th percentile of the requests in each 5 s, from the histogram's buckets),
notifications per second (sent, to context sources, Server-Sent Events, failed), subscriptions (entity,
periodic, context source, Server-Sent Events streams) and memory (used, resident, the budget); below them
the busiest operations over the last minute. A read that fails, or a request count that goes down (the
broker started again), is a break: no rate across it. A chart whose metrics the broker does not serve says
so. The summary line carries requests per second, the mean latency and the subscriptions.
Log and trace levels - above the log of a running broker with the admin API: verbose and info,
and a tick box per trace level, grouped by library, its tooltip what it traces - set live through coraine's
/admin/log (a change PUTs the whole set; a level c³ has no name for, set elsewhere, is kept). The levels
and what each traces are www/data/traceLevels.json, made from the libraries' trace-level headers by
tools/traceLevels.py <dir of the cor* repos> - run it again when a library adds one. Traces exist in
debug builds only: on coraine's "traces": false (a release build) the tick boxes are greyed, and say why.
Operate's left side, given to the mapping of a DDS domain to NGSI-LD - from Operate's Map DDS topics,
services and actions, or from Compile's, under its Bridges with dds on: that one asks first, then shows
Operate with the dds bridge ticked in its form and the mapper open, with ← Back to Compile. Each
topic, service or action mapped is one attribute of an entity. What was written is read back when c³
opens (GET /api/run/dds/files), and the mapper opens on it.
-
How c³ reaches your DDS system - domain (0-232; ROS 2:
ROS_DOMAIN_ID), transport (shared memory and UDP - DDS's default; UDP only,udp; shared memory only,shm) and interfaces - the IPv4 addresses of this host's interfaces UDP is kept to, comma-separated; empty: all; none withshm(refused). In the bridge configuration:dds.ddsmodule.ddsdomain,transport,whitelist-interfaces(a list of addresses needs corDdsBridge with SEAMWARE/corDdsBridge#21 - before it, the DDS Enabler refused a list of plain values and the broker did not start). Discover's broker and the broker Done starts use them alike. DDS discovery reaches this host and its network segment, not across a router, a VPN or a NAT; shared memory reaches this host alone, and a container only sharing its/dev/shm. A ROS 2 Discovery Server, unicast peers and Fast DDS XML profiles cannot be given: the DDS Enabler 1.2.2's participant reads none of them. Where c³ does not reach the system: Import inventory.json. -
A vocabulary - the namespace of the terms, each entity type and attribute
<vocabulary><term>; empty: the default context's (https://uri.etsi.org/ngsi-ld/default-context/). proposals' type - the entity type the proposals give (Robot). -
Discover - asks the DDS system what it announces: c³ joins its domain from the machine it runs on, so it must reach it - DDS discovery is multicast, on that host or its network. c³ starts a broker of its own for it (
POST /api/run/dds/discover): the form's source (the installed broker, a build, or the image: the containerc3-broker-<name>.discover,--rm, its entry point undertimeout 120- it ends by itself if c³ does not stop it) and the domain, transport and interfaces, the dds bridge alone, corDB in memory, no history, the admin API, a free port from 39026, its directory<run>/<name>.discover/(a container's output intobroker.logbefore it is removed). OneGET /admin/bridges/dds/inventory(coraine with bridge ABI 14), which the bridge answers once discovery has been quiet for 3 s (at most 10 s - "still announcing" when it ran out), and the broker is stopped again; a few seconds in all, one discovery at a time. Import inventory.json reads the same list from a file a DDS discovery tool wrote (dds-discovery -o inventory.json), without the fields. ROS 2's housekeeping (its discovery and log topics, every node's parameter services) is hidden unless asked for. -
Three lines - N DDS Topics, N DDS Services, N DDS Actions, each opened with ▸, with the number mapped. Open, an endpoint a line: its name, its type, and, mapped, its attribute and entity; one mapped and not in the last discovery is listed too.
-
The proposals - an endpoint not mapped shows, greyed, its proposal: the name's last segment camelCased the attribute, the segments before it (
rt/dropped) the entity -rt/turtle1/pose:poseofurn:ngsi-ld:Robot:turtle1; no segment:urn:ngsi-ld:Robot:robot1- and the proposals' type the entity's, once (an entity with a type keeps it). The green ✓ accepts it into the fields still empty; ✓ all (N) on a group accepts those of every endpoint shown and not mapped. Each name stays editable. -
An endpoint opened - Attribute name, Entity ID, Entity type (optional), the ids and types in use offered as you type. The first field typed in names the attribute from the endpoint: its last segment (
rt/dropped) camelCased -rt/turtle1/cmd_vel,cmdVel. the topic's payload (a service's request and reply, an action's goal, feedback and result) shows a sample of its DDS type - the value of the attribute it is mapped to; not mapped takes the endpoint out. A service keeps its request and reply types, an action its type, as discovered. -
An entity's type, once - an entity is created with its type: every entity id needs one on at least one of its endpoints, the others take it. Done is refused, and the endpoint says why, for: no attribute or no entity id, an id that is not a URI, an entity with no type anywhere, two types for one entity, a name with a character NGSI-LD forbids (space
< > " ' = ; ( )), a member of every entity (id,type,scope,createdAt, ...) as an attribute, two endpoints on one attribute of an entity. -
Done -
POST /api/run/dds/fileswrites$XDG_DATA_HOME/c3/dds/(else~/.local/share/c3/dds/):dds-bridge.json the bridge configuration: {"dds": {"ddsmodule": {"dds": {domain, transport, whitelist-interfaces}, ...}, "ngsild": {topics, services, actions, typesDirectory}}} dds-context.jsonld the @context: each entity type and attribute a term dds-mapping.json the mapping as posted - what the mapper reads back dds-types/ the bridge's typesDirectory -
The files screen - the two files' full paths, each with Copy, and Start a broker with it: the broker of the form with the dds bridge,
--bridgeConfig <dds-bridge.json>,--defaultUserContext <dds-context.jsonld>, and More options as typed - the command shown. Start the broker starts it and closes the mapper; Put it in the broker form fills the form only. A broker given both c³'s DDS vocabulary and a--defaultUserContextof its own is refused: one default user@context.
Containers go through docker, else podman (the same commands), found in $PATH and asked for its
version. Without either, or with a daemon that does not answer, the container parts are greyed with what
to install. c³ creates only c3-* containers, volumes and the network c3, all labelled c3=run, and
touches nothing else.
Where it lives: $XDG_DATA_HOME/c3/run/ (else ~/.local/share/c3/run/):
<name>/broker.log its output and c3's lines about it; broker.log.1 the run before
<name>/state.json a native broker that runs: {pid, procStart, started} - and broker.pid, the pid alone
<name>/data/ --dbDir of a native corDB
<name>/bridges.json the bridge configuration c3 writes (mqtt, dds, ckan, opcua)
<name>/dds-context.jsonld the DDS mapping's terms, the broker's --defaultUserContext (a DDS vocabulary given)
<name>.discover/ the broker DDS discovery runs: its bridges.json and broker.log
dds/ the DDS mapper's: dds-bridge.json, dds-context.jsonld, dds-mapping.json, dds-types/ (next to run/)
<name>/opcua.password the OPC UA user's password, mode 600 - never in c3.json
services/<container>.log
Forget (press, then keep or delete the data) removes a broker that does not run from c3.json; with its
data, also its directory and its volume.
Backend - C, on coraine's own libraries. One binary, c3, from the files in src/ - maps.c
is the map's downloaded areas (the planet read by Range, the extract, the fonts, the job and its API),
live.c the live values (the page's sessions, their subscriptions and the alert rules', the notifications), demo.c the named demos' descriptors, and ws.c
WebSocket (RFC 6455) both ways - the client end to a broker, the server end for the page - and
compile.c Compile (the capabilities, the feature list, the builds and their processes, the live log),
run.c Operate (brokers and services, their processes and containers, status, logs) and util.c what those two
share (a growing string, files, processes spawned and stopped, a log followed as server-sent events):
| Library | Used for |
|---|---|
| corHttp | the HTTP server: one event loop, one request callback - the pages and the API; corHttpUpgrade hands over the page's WebSocket; files sent from the file, a build's log as a stream |
| corRest | the HTTP client the proxy and the map downloads send with - its multi engine, for the cap on the response size and a response that stays valid until the engine is destroyed |
| corJson, corTree | reading and writing the configuration file, and the API's JSON bodies |
| corArgs | the command line |
| corAlloc, corBase, corLog | under the others |
| zlib | the PMTiles directories (gzip) |
| OpenSSL (libcrypto) | also Sec-WebSocket-Accept (SHA-1, base64) |
The backend's API, used by the pages:
| Request | |
|---|---|
GET /api/status |
version, port, configuration file, its error if any |
GET /api/config |
the configuration |
POST /api/brokers {"url", "name"} |
a broker connected to: first in the list, lastUsed now |
DELETE /api/brokers {"url"} |
forget a broker |
PUT /api/brokers/types {"url", "types"} |
the types Explore loads from a broker, as its connect.types (the broker must be in the list) |
PUT /api/brokers/connect {"url", "view", "map", "colourBy", "relationships", "alerts", "tenant", "local"} |
Explore's view of a broker: each member given replaces the stored one |
PUT /api/ui {"lastPage"} |
compile, run or connect |
GET /api/capabilities |
what this machine can do: host, arch, distribution, compile (available, why, coraineBuild, source, version, builds, pgo, pgoWhy, mongoc, mongocWhy, liveLog), run (coraine, coraineWhy, containers, engine, engineVersion, containersWhy, dir) |
GET /api/compile/features |
coraine-build --list-features --json as {"coraineBuild", "source", "version", "features": [{"name", "default", "description", "requires"}]} |
PUT /api/compile/choice {"mode", "features", "workload"} |
Compile's choice, kept as compile.choice |
PUT /api/compile/presets {"name", "mode", "features", "workload"} |
a preset, added or replaced (in its place) in compile.presets |
DELETE /api/compile/presets {"name"} |
|
POST /api/builds/command {"name"?, "executable"?, "mode", "httpServer"?, "features", "bridges"?, "workload"?} |
the build that body would start: name, dir, argv, command - nothing run |
POST /api/builds (the same body) |
start it: 202 with the record; 409 while a build runs or when the name is taken; 503 without coraine-build; 422 for a training mode without its tools or a tune without its workload |
GET /api/builds |
{"dir", "builds": [records, newest first]} |
GET /api/builds/<name> |
a record |
GET /api/builds/<name>/log |
the live log as server-sent events (Accept: text/event-stream), else the log file |
DELETE /api/builds/<name> |
the build that runs: cancelled (202); a finished one: deleted (204) |
GET /api/run |
{dir, engine, brokers, services, sets}: each broker kept with its options (def), state (stopped, starting, running, stopping, exited, failed), why, pid, attached, container, url, started, ended, stop (asked, grace, killIn, killed), exitStatus, ready, health (the report), services, log, dataDir/volume; each service with container, set, service, image, state, port, volume, volumeExists, usedBy, neededBy |
GET /api/run/sources |
the installed broker and the builds that succeeded: version (--version), their plugin directory and bridges |
GET /api/run/images |
{repository, local: [{tag, created, size}], published: [{tag, modified}], publishedError} |
POST /api/run/command {broker} |
{def, argv, command, services, problem} - nothing run |
POST /api/run/brokers {broker} |
kept (replacing one of that name) and started: 202; 409 while it runs or when a port is taken; 422 when it cannot start from its source (problem) |
POST /api/run/brokers/<name>/start |
start one kept |
POST /api/run/brokers/<name>/stop |
202; SIGTERM, SIGKILL after its grace |
DELETE /api/run/brokers/<name> |
forget one that does not run (204); ?data=true its directory and volume too |
GET /api/run/brokers/<name>/log, GET /api/run/services/<container>/log |
the log as server-sent events (Accept: text/event-stream), else the file |
POST /api/run/services/<container>/stop |
a service no broker uses: 202; 409 while one does |
DELETE /api/run/volumes/<volume> |
one of c³'s volumes (c3=run), its container not running: 204 |
POST /api/run/dds/discover {broker} |
the inventory of the broker's DDS domain - the dds bridge's (GET /admin/bridges/dds/inventory), from a broker of its own started and stopped for it: 200; 422 for a container broker, 409 while another discovery runs, 502 with why when the broker did not start or answer |
GET /api/run/dds/files |
the DDS mapper's files: {directory, bridgeConfig, userContext, mapping} (mapping null before the first Done) |
POST /api/run/dds/files {domain, vocabulary, topics, services, actions} |
write them from a mapping: 201 with the same answer; 400 with why for a row that is not one |
GET /api/maps |
the data directory, the downloaded areas, whether the fonts are there, the download (job) |
POST /api/maps/estimate {"bbox", "maxzoom"} |
tiles and bytes of an area, from the planet's directories (422 past 1 000 000 tiles) |
POST /api/maps/download {"name", "bbox", "maxzoom"} |
start the download (202; 409 while one runs); GET its progress, DELETE cancels it |
DELETE /api/maps/areas {"id"} |
delete an area: its file and its entry |
GET /api/maps/areas/<id>.pmtiles |
the file, with Range |
GET /api/maps/fonts/<fontstack>/<range>.pbf |
a glyph range of a downloaded font stack |
GET /api/live |
the page's WebSocket (live values). The page sends {"op": "watch", "broker", "tenant", "types"} (tenant optional, "" the default one) or {"op": "unwatch"}; the backend sends hello (the session), status (mode ws, http or off, subscription, types, why not the WebSocket, detail of a failure), notify ({"broker", "message"} - the broker's WebSocket message as it came, or {"body": ...} for an HTTP notification: message.body is the Notification) and lost (the broker closed its WebSocket). The Origin, when sent, must be this machine. |
GET /api/brokers/tokens |
which brokers have an access token of their own, and c³'s default: {"brokers": {"<url>": {"source", "tail", "length"}}, "default": {...} or null, "tokenFile"} - source is typed, remembered, file or env; never the token |
PUT /api/brokers/token |
{"url", "token", "remember"}: the broker's token (Bearer in front is taken off); RFC 6750's characters only, 16 KB at most |
DELETE /api/brokers/token |
{"url"}: the broker's token forgotten, in memory and in secrets.json |
GET /api/brokers/headers |
each broker's own headers: {"brokers": {"<url>": {"remembered", "headers": [{"name", "secret", "value"}]}}} - a secret one has tail and length instead of value |
PUT /api/brokers/headers |
{"url", "headers": [{"name", "value", "secret"}], "remember"}: the broker's headers, all of them, in their order - a secret header without value keeps the one it has; an empty list forgets them; remember false or left out keeps them in memory only (and out of secrets.json). Answers as the GET; a refused name, a bad value or a name given twice is a 400 and nothing changes |
DELETE /api/brokers/headers |
{"url"}: the broker's headers forgotten, in memory and in secrets.json |
POST /api/notify/<session> |
a broker's notification for a session's HTTP subscription: to the page (204); an unknown session is a 404 |
GET /api/proxy/<path>?<query> |
the same GET to the broker named in the X-C3-Broker header - the NGSI-LD API (/ngsi-ld/...), /version, /build, coraine's /admin/tenants, /admin/log, /admin/config, /admin/metrics and /admin/bridges/<bridge>/inventory, and /.well-known/oauth-protected-resource (RFC 9728) only, with the page's NGSILD-Tenant, the broker's own headers and its access token (Authorization: Bearer, unless an own header is the Authorization). Answers the broker's status, body, Content-Type, NGSILD-Results-Count and every Link header joined into one; a broker not reached is a 502 with "broker": false. |
POST, PATCH, PUT, DELETE /api/proxy/<path> |
the same write to the broker - the entities (/ngsi-ld/v1/entities, /ngsi-ld/v1/entities/...) and PUT /admin/log only, with the body and its Content-Type (application/json when none), the tenant and the token as for a GET; the broker's Location comes back too. Only from c³'s own page: a write without an Origin of this machine is a 403 - a browser always sends one on a write, and another site's page can neither set it nor send X-C3-Broker without a preflight c³ never answers. |
The proxy is why the browser never talks to a broker: the page and the API share one origin, so there
is no CORS to configure on the broker. A proxied request leaves the event loop (corHttpSuspend) and
is sent on a thread of its own, its answer handed back with corHttpResume; a slow or absent broker
holds up nothing else. The timeout is 15 s, the largest answer 64 MiB.
Frontend - plain JavaScript modules, HTML and CSS in www/: no build step, no framework,
nothing from a CDN, so it works offline. The graph is the world element moved with a CSS transform,
the boxes are elements in it and the arrows one SVG beneath them, as in the Connect design. The map's
libraries are copied, as published, into www/vendor/ (below) and loaded when the map is first shown.
| File | |
|---|---|
index.html, css/c3.css |
the shell and the look (dark only - Seamware magenta #A72E60 on #0E1014) |
css/compile.css, js/compile.js |
the Compile page |
css/run.css, js/run.js |
the Operate page (#run) |
js/metrics.js |
Operate's Metrics: /admin/metrics parsed, sampled and drawn |
js/main.js |
the splash screen, then the pages (#compile, #run, #connect; the last one shown is ui.lastPage) |
js/api.js |
the backend's API |
js/ngsild.js |
discovery, the paged load (all, or the types picked), linked entities, Relationships, values as text |
js/layout.js |
the layout |
js/graph.js |
boxes, arrows, zoom, pan, drag, selection, filters, a box expanded in place |
js/menu.js |
the context menu, mouse and keyboard |
js/connect.js |
the Explore page (#connect): discovery panel, picks, an entity's menu, neighbourhood, linked entities, graph or map, colour by, named demos |
js/map.js |
the map: GeoProperties as markers, lines and areas, Relationships with arrowheads (all, or the selected entity's), selection, click and menu, the changed entities ringed, movers gliding, alarms ringed |
js/icons.js |
the icons per type, the state colours, drawn on a canvas |
js/alerts.js |
alert rules and alarms: the toasts, the list, q evaluated for clearing, the rules kept per broker |
js/history.js |
the attribute panel's History: the temporal API, sparklines, the live values appended |
js/live.js |
live values: the backend's WebSocket, watch/unwatch, the notifications gathered into batches (250 ms), the alert rules and their notifications, reconnecting |
js/basemap.js |
the base map: c³'s dark style for OpenFreeMap's tiles, the downloaded areas' Protomaps layers, the tile and glyph protocols |
js/offline.js |
"Download this area": the estimate, the download's progress, the list of areas |
Fonts: Outfit, Source Sans 3 and JetBrains Mono are in www/fonts/ (SIL Open Font License, the
licences beside them); the coraine mark and the Seamware logo in www/img/.
src/ the backend (C)
www/ the pages, served by the backend
www/vendor/ MapLibre GL JS, pmtiles, @protomaps/basemaps - as published, with their licences
www/data/ data the pages read: the trace levels of the cor stack, the CKAN example's portal
examples/ factory.sh - sample entities for Explore; live.sh - its robots at work (live values);
ckan-bikes.sh - real open data from a CKAN portal
tools/ traceLevels.py - www/data/traceLevels.json; ckan-ingest.py - a CKAN resource into a broker
doc/design.md what c³ is and how it is built
doc/areas.md the parts of the work and the milestones
doc/compile.md Compile: what it needs from coraine-build and corHttp
design/ screen designs (Splash, Compile, Configure, Connect)
Apache License 2.0 - see LICENSE.
Bundled in www/, each with its licence beside it:
| Version | Licence | Files | |
|---|---|---|---|
| Outfit, Source Sans 3, JetBrains Mono | SIL Open Font License 1.1 | www/fonts/ |
|
| MapLibre GL JS | 6.13.0 | BSD 3-Clause | www/vendor/maplibre-gl/ (maplibre-gl.mjs, maplibre-gl-worker.mjs, maplibre-gl.css, LICENSE.txt) |
| pmtiles (JS) | 4.5.0 | BSD 3-Clause; it bundles fflate (MIT) | www/vendor/pmtiles/ (pmtiles.js, LICENSE.txt, LICENSE-fflate.txt) |
| @protomaps/basemaps | 5.7.2 | BSD 3-Clause (code); the visual design CC0 | www/vendor/protomaps-basemaps/ (basemaps.mjs, LICENSE.txt) |
Used at run time, not bundled:
- Map data: © OpenStreetMap contributors, ODbL - credited on the map, as the licence requires.
- Online tiles: OpenFreeMap (no key; its credit "OpenFreeMap © OpenMapTiles Data from OpenStreetMap" on the map). Its terms forbid collecting its data in automated ways, which is why downloads come from Protomaps.
- Downloaded areas: extracts of the Protomaps daily planet build - an ODbL Produced Work, OpenStreetMap attribution required (and given).
- Glyphs: Noto Sans (SIL Open Font License 1.1), from protomaps/basemaps-assets, downloaded with the first area.