A bridge plugin for the coraine NGSI-LD broker: tmpl.so, a working bridge with a tiny example
transport, to be turned into a real one - OPC UA, CKAN, Philips Hue, anything the broker does not
speak. It builds, loads into a broker, and carries a value each way before a line of its own is
written.
mkdir corOpcuaBridge && git -C corBridgeTemplate archive HEAD | tar -x -C corOpcuaBridge
cd corOpcuaBridge && git init -b main
./rename.sh opcua # tmpl -> opcua everywhere: opcua.so, src/opcuaRegister.c, ...
make && make test # builds, and a broker moves a value both ways through it
grep -rn --exclude-dir=.git TODO . # where the protocol goes - the README and the CI too
rename.sh takes the bridge's alias: lowercase letters and digits. It removes this section and itself.
An export, not a copy of the checkout: cp -r takes the template's .git along - its history, and an
origin that pushes to corBridgeTemplate (rename.sh warns about that).
A bridge connects the broker to a foreign transport. It knows nothing about entities: it carries
(endpoint, json, time) in both directions, and nothing else.
device <-> transport <-> tmpl.so <-> broker <-> Channel <-> entity attribute
sampleIn() -> (in)
publish() <- (out)
- Endpoint - the transport's own name for a value: an MQTT topic, a Modbus register, an OPC UA node id. Exact, as the wire spells it.
- Channel - this endpoint <-> this entity attribute, this direction. Channels live in the
broker, which configures them from the
--bridgeConfigfile and shows them onGET /ngsi-ld/v1/channels. A Channel the bridge refuses stays,dormant, with the reason. - DataChannel - for a transport whose messages are records (a CKAN row, a CSV line: one
JSON object, one member per column) naming their entity in a column: this endpoint -> the entities
each record makes, inbound only - the
dataChannelssection of the file,GET /ngsi-ld/v1/dataChannels(coraineCOR_FEATURE_DATA_CHANNELS). Not a Channel; the plugin's side is the samesampleIn, one record a call, its time 0 when the record has none (noobservedAt); corCkanBridge is the example. - Bridge - one transport instance (one connection, one server),
GET /ngsi-ld/v1/bridges.--bridges tmplloads the plugin; it is never loaded at runtime or inferred from configuration. - In - a value arrives on an endpoint; the plugin calls
brokerP->sampleIn(alias, endpoint, json, timeNs)from its own thread; the broker writes it into the Channel's attribute (observedAt= the time), notifies, and records the temporal event. - Out - an attribute a Channel carries is written; the broker calls
publish(endpoint, json)inside that request; the plugin sends it. A value that came in from a transport is never published back out (the broker sees to that); the plugin keeps its own writes from coming back in as samples.
corBridge/BridgeDriver.h (what the plugin fills in) and corBridge/BridgeBroker.h (what the broker
offers). In the order the broker uses them:
| entry point | thread | what the plugin does |
|---|---|---|
bridgeRegister(BridgeDriver*) |
broker, at load | the one exported symbol: fills in the driver - alias, version, the entry points it has |
init(configFile, brokerP) |
broker, late in startup | keeps brokerP; reads its member of the --bridgeConfig file; brings the transport up |
channelAddInfo(endpoint, kind, direction, info) |
broker | per Channel, after init: checks the endpoint and channelInfo, subscribes |
brokerP->sampleIn(...) |
the plugin's own | a value arrived |
brokerP->channelStatusIn(...) |
the plugin's own, or inside channelAddInfo |
ABI 12: a Channel carries, or not and why - shown as its status and statusReason |
publish(endpoint, json) |
broker, inside the request | hands the value to the transport and returns - must not block |
channelDel(endpoint) |
broker | stops carrying an endpoint (not called yet: Channels come from the file only) |
close() |
broker, at shutdown | returns only when no plugin thread can call sampleIn any more |
versionInfo() |
broker | one line for GET /version |
- NULL means "not supported". A slot the plugin leaves NULL is answered
BRIDGE_UNSUPPORTEDby the broker. Optional slots:args(command-line options of the plugin's own), services and actions (ABI 2-4),notifySchemes+notify(notifications to a URI scheme - corMqttBridge), Service Execution (ABI 11) - andpublish, for a bridge that only reads (its Channels inbound). - Why a Channel is dormant is the plugin's to say: through
channelStatusIn(ABI 12) beforechannelAddInforefuses it, and at any time after - a peer that does not have the endpoint, a peer that cannot be reached. Without it,GET /ngsi-ld/v1/channelsshows only the broker's "its address or channelInfo does not fit the transport", and anything learned later shows nowhere but the log. - The ABI handshake. The broker zeroes the driver, writes its
BRIDGE_ABI_VERSIONintoabiVersionand callsbridgeRegister. The struct is the broker's, at the size of its header: the plugin fills in only the slots that version has (channelAddInfofrom ABI 9), then writes its own version back. On the broker side (BridgeBroker), an upcall newer than ABI 1 is called only after checkingbrokerP->abiVersionand that the pointer is not NULL. - Plain data across the seam:
const char*andint64_t.jsonpassed to the plugin is borrowed: copy what is needed after the call. Return codes:BRIDGE_OK,BRIDGE_ERR,BRIDGE_NOT_FOUND,BRIDGE_UNSUPPORTED,BRIDGE_BAD_INPUT. - Built against the broker's headers. The broker is linked
-rdynamic:corLog,corJson,corTreeandcorAllocresolve from the running broker atdlopen, and nothing of the cor stack is linked into the plugin - so it is built against the headers of the broker it is loaded into. - Logging:
COR_E/COR_W/COR_V(corLog) for the plugin's own lines;brokerP->logFunctionto forward the transport library's own log sink.
| file | what | in a real bridge |
|---|---|---|
src/tmplRegister.c |
the seam: bridgeRegister, the driver entry points, the bridge's configuration |
stays; the TODOs: channelInfo keys, version line |
src/tmplTransport.h |
the transport's interface: open, close, add/remove an endpoint, send; a receive callback | stays, or grows |
src/tmplTransport.c |
the example transport: a directory of files, polled | replaced by the protocol |
test/funcTests/cases/bridge_tmpl.test |
a broker with this plugin: a value in, a value out, no echo, a refused endpoint | the device becomes the protocol's test peer |
test/funcTests/corTestFunctions.sh |
brokerStart, deviceWrite, entityAwait, channels, ... |
deviceWrite / deviceAwait talk to the peer |
makefile |
debug / release, install, contract, test |
add the transport library to LIBS |
.github/workflows/ci.yml |
contract check, both builds, the tests, against the cor stack | install the transport library |
One --bridgeConfig file serves every bridge: one member per alias. The ngsild section is the
broker's - it makes the Channels; everything else in the member is the plugin's.
{ "tmpl": { "server": { "dir": "/var/lib/tmpl", "pollMs": 1000 },
"ngsild": { "topics": {
"tank1/level": { "entityId": "urn:ngsi-ld:Tank:T1", "entityType": "Tank", "attribute": "level" },
"tank1/state": { "entityId": "urn:ngsi-ld:Tank:T1", "entityType": "Tank", "attribute": "state",
"channelInfo": [ { "key": "format", "value": "text" } ] } } } } }- A Channel's direction is
bothand every configured attribute exists from startup, with the value"uninitialized"until the first sample. - The catch-all entity is on unless the section says
"defaultEntity": false: a sample on an endpoint no Channel claims is stored onurn:ngsi-ld:tmpl:default. A transport that hands over only the endpoints it was given never reaches it; one that hands over everything it hears does. channelInfo- how to read or carry one endpoint - is checked by the broker for its shape (an array of{"key", "value"}strings) and never for its keys: an unknown key is the plugin's to refuse (BRIDGE_BAD_INPUT).- The example transport: an endpoint is a file path under
server.dir; a file whose content changes is a sample, its modification time the sample's time; a published value is written into the file.format: textmakes the content a JSON string; the default,json, takes it as JSON.
make # tmpl.so, debug (traces compiled in)
make BUILD=release
make contract # does src/tmplRegister.c still satisfy BridgeDriver.h?
make install # to /opt/seamware/plugins/bridge (PLUGIN_DIR=...)
The cor-lib headers are found in COR_LIBS (default ..: the sibling repos, as every cor repo builds).
Without siblings and with the coraine-dev package installed, the source of the installed broker
(/usr/share/coraine/src/coraine-src.tar.xz) is unpacked into .coraine-src/ and used instead. For
a broker built with coraine-build <dir>: make COR_LIBS=<dir>, and
make install PLUGIN_DIR=<dir>/install/plugins/bridge.
make test
Starts coraine (from PATH: the coraine package, or . <dir>/install/env after coraine-build) on
port 19026 with ramDB - no database server - loads ./tmpl.so by path, and runs
test/funcTests/cases/*.test with corTest (PATH, or CORTEST=), with COR_TEST_NO_REAP=on:
corTest's start-up reaping is for coraine's own suite - it kills every coraine process on the host -
and these tests stop their broker by its pid. TMPL_BROKER, TMPL_PORT and
TMPL_TEST_DIR override; the broker's log is /tmp/tmpl-bridge-test/broker.log.
coraine --bridges tmpl --bridgeConfig bridges.json # installed in <plugin dir>/bridge
coraine --bridges $PWD/tmpl.so --bridgeConfig bridges.json # a path: used as it is
The plugin directory is /opt/seamware/plugins, or $SEAMWARE_PLUGIN_DIR.
- corBridge - the contract; every entry point is documented
in
BridgeDriver.handBridgeBroker.h - coraine: Bridges and Channels, the full reference, plugin architecture
- Real bridges: corMqttBridge (a library's own thread,
notifications), corModbusBridge (polling, a write
queue), the broker's
src/plugins/bridge/loopback(every entry point, services and actions) - corTest - the test runner
Copyright 2026 Seamware · Apache-2.0