This guide is aimed at someone who already knows DDS / Fast-DDS / ROS 2 (topics, services, actions, IDL types, QoS), and wants to know how those map onto the NGSI-LD entity model that Orion-LD exposes over REST.
The DDS integration is built on top of FIWARE-DDS-Enabler (an in-process
DDS participant on the broker side), and is gated by the -wip dds CLI
flag — the feature is "work in progress" but functional end-to-end for the
three modes documented here.
Orion-LD can act as a peer of any DDS application, in three modes:
| DDS concept | NGSI-LD mapping | Broker role | Direction |
|---|---|---|---|
| Topic (pub/sub) | A DDS topic ↔ one attribute of one entity. Publishes/receives the value field of that attribute as the DDS sample. |
Publisher and subscriber. | Bidirectional. A REST update of the attribute publishes to DDS. A DDS sample on the topic updates the attribute. |
| Service (request/reply) | A DDS service ↔ one attribute. PATCHing the attribute issues a DDS service request whose payload is the attribute value. The reply is merged back into the same attribute. |
Client only. | Outbound trigger via REST → reply lands back as a sub-Property. |
| Action (long-running goal) | A DDS action ↔ one attribute. PATCHing the attribute issues a goal; each goal is a separate instance keyed by datasetId="urn:goal:<uuid>", with feedback / result / status as envelope sub-Properties. An optional endpoint sub-attribute auto-creates a temporary subscription streaming the goal's lifecycle to the initiator. On completion the instance is removed — and the whole attribute when it was the last in-flight goal (re-created by the next goal PATCH); history stays in TRoE. |
Client only. | Outbound trigger via REST → async feedback / result / status; optional temp-subscription notifications. |
In all three modes the wire payload on the DDS side is just the JSON
serialization of attribute.value. Sub-attributes (metadata, observedAt,
unitCode, …) and the attribute envelope itself (type, datasetId, …) are
not transmitted. See §5.
For DDS topics the broker is a fully symmetric peer — it can publish and it can subscribe, because in either case it's just shuttling JSON values into and out of an attribute.
For DDS services and actions the broker is exclusively the client. There is no mode in which orion-ld acts as the DDS server that serves a request or executes a goal. The reason is architectural: an NGSI-LD broker only knows about entities, subscriptions and registrations. It has no extension point for hand-written business logic that would compute a reply or run a goal — that's the job of an application, not a generic context broker.
To expose a DDS service or action from the FIWARE side, an external application (typically an IoT Agent) provides the server. How much DDS the agent itself has to know depends on the service:
- For a pure-compute service like
add_two_ints, the agent could in principle stay DDS-unaware. The broker would need to be extended to act as a DDS-server proxy, forwarding incoming DDS requests to the agent via an NGSI-LD registration and shipping the agent's NGSI-LD reply back out on the DDS wire. Nothing in the NGSI-LD model rules this out, but orion-ld does not implement it today. - For an action like "move the robot to position X", the agent will almost certainly need to speak DDS itself — to drive the underlying ROS 2 / DDS stack and stream feedback in real time. In that case the agent is the DDS server end-to-end, and orion-ld stays out of the DDS server side entirely (it may still appear in the NGSI-LD picture as the place that holds the entity's state, but the DDS wire goes agent ↔ robot, with the broker not involved on that path).
Either way, today: orion-ld is client-only for DDS services and actions. The server side lives elsewhere.
An NGSI-LD Entity is a JSON-LD object with an id, a type, and a flat
collection of Attributes (named fields). Each Attribute is either a
Property (carrying a value) or a Relationship (carrying an object
URI pointing to another entity). Attributes can themselves carry
sub-Properties (metadata) — observedAt, unitCode, lang, etc.
Attributes are CRUD'd over REST. The endpoints relevant to DDS are listed in §11.
For DDS purposes you only need to remember: one attribute = one DDS
endpoint (topic, service, or action), and the JSON value is what travels.
orionld -wip dds [-mongocOnly] [other-flags]
When -wip dds is set, the broker reads ~/.orionld at startup, spins up an
embedded DDS participant via the FIWARE-DDS-Enabler, and registers the
topics / services / actions declared in the config. Without -wip dds the
file is ignored and no DDS interaction occurs.
Runtime requirements:
- A running FastDDS implementation reachable on the configured domain (multicast or discovery server).
- The FIWARE-DDS-Enabler shared libraries on the broker's
LD_LIBRARY_PATH. - For services/actions: matching IDL
.bintype files in the directory pointed to bytypesDirectory(see §12).
Orion-LD reads ~/.orionld (a JSON file) at startup. The shape:
{
"dds": {
"ddsmodule": { // passed verbatim to the Fast-DDS / ddsenabler runtime
"dds": {
"domain": 0,
"allowlist": [ { "name": "*" } ],
"blocklist": [ { "name": "add_blocked_topics_list_here" } ]
},
"topics": {
"name": "*",
"qos": {
"durability": "TRANSIENT_LOCAL",
"reliability": "RELIABLE",
"history-depth": 20
}
},
"ddsenabler": null,
"specs": {
"threads": 12,
"logging": { "stdout": false, "verbosity": "info" }
}
},
"ngsild": { // NGSI-LD ↔ DDS mappings (orion-ld owns this)
"topics": { "<TopicName>": { "entityType": "...", "entityId": "...", "attribute": "..." } },
"services": { "<ServiceName>": { "entityType": "...", "entityId": "...", "attribute": "..." } },
"actions": { "<ActionName>": { "entityType": "...", "entityId": "...", "attribute": "..." } },
"typesDirectory": "/abs/path/to/types",
"syncTimeoutMs": 5000
}
},
"troe": { ... } // unrelated to DDS, may be empty
}The relevant fields for DDS users:
| Field | Meaning |
|---|---|
dds.ddsmodule.dds.domain |
DDS domain ID. Must match every other participant you want to interoperate with. |
dds.ddsmodule.topics.qos |
Default QoS applied to all topics the broker creates. |
dds.ddsmodule.specs.threads |
DDS thread pool size. |
ngsild.topics["<TopicName>"] |
Maps the DDS topic <TopicName> to an (entityType, entityId, attribute) triple. |
ngsild.services["<ServiceName>"] |
Same, for DDS services. |
ngsild.actions["<ActionName>"] |
Same, for DDS actions. |
ngsild.typesDirectory |
Directory of pre-built .bin IDL type files (required for services and actions — see §12). |
ngsild.syncTimeoutMs |
Default timeout (ms) for synchronous service requests. |
Writing this JSON by hand is tedious. scripts/configFile.sh generates it
from a flat CLI:
$REPO_HOME/scripts/configFile.sh \
--ddsTopic "Camera,Camera,urn:ngsi-ld:camera:cam1,shutterSpeed" \
--ddsService "Adder,Robot,urn:ngsi-ld:robot:r1,sum" \
--ddsAction "Fibonacci,Robot,urn:ngsi-ld:robot:r1,fib" \
--ddsTypesDirectory "$HOME/.orionld-types" \
--ddsSyncTimeoutMs 5000 \
> ~/.orionldEach --ddsTopic / --ddsService / --ddsAction takes a comma-separated
tuple <endpoint name>,<entity type>,<entity id>,<attribute name>. You can
pass the flag multiple times.
When the broker publishes an attribute on a DDS topic, only the JSON value of the attribute is put on the wire — the attribute envelope and any sub-attributes are stripped. Conversely, when a DDS sample arrives, its payload is interpreted as a new value for the mapped attribute.
// REST: PATCH attribute "shutterSpeed" on entity cam1
{
"type": "Property",
"value": { "fNumber": 5.6, "speedMs": 0.002 },
"observedAt": "2026-05-13T17:30:00Z",
"unitCode": "C26"
}
// On the DDS topic "Camera":
{ "fNumber": 5.6, "speedMs": 0.002 }
// ^ only the value object travels. observedAt, unitCode, type are stripped.Consequences you should know:
- Sub-attributes are invisible over DDS. If a REST client updates
attribute.observedAtor any other sub-Property without changingattribute.value, nothing is published to DDS. Sub-attributes are an NGSI-LD-side concern only. attribute.valuemust be a JSON object to be publishable. Scalar values (string, number, boolean) are not published — the broker logs a warning and skips them. This is a current limitation: the IDL types the FastDDS side discovers are structs, and structs need keyed fields.- Type compatibility is on you. The shape of
attribute.valuemust match the IDL type the topic is bound to. The broker does not validate field-by-field; mismatched shapes will fail at the FastDDS marshalling layer. - Loop protection is built in. When a DDS sample arrives and gets merged into the entity as an attribute update, the broker does not re-publish that update back to DDS. Without this guard you'd have an infinite loop. Practical effect: a DDS-originated update never re-appears on the same topic.
We map a DDS topic Camera to entity urn:ngsi-ld:camera:cam1, attribute
shutterSpeed.
$REPO_HOME/scripts/configFile.sh \
--ddsTopic "Camera,Camera,urn:ngsi-ld:camera:cam1,shutterSpeed" \
> ~/.orionld
orionld -wip ddsCreate the entity once, then update the attribute:
# Initial create (also publishes if value is an object)
curl -X POST http://localhost:9999/ngsi-ld/v1/entities \
-H "Content-Type: application/json" \
-d '{
"id": "urn:ngsi-ld:camera:cam1",
"type": "Camera",
"shutterSpeed": {
"type": "Property",
"value": { "fNumber": 5.6, "speedMs": 0.002 }
}
}'
# Update — also publishes
curl -X PATCH http://localhost:9999/ngsi-ld/v1/entities/urn:ngsi-ld:camera:cam1/attrs/shutterSpeed \
-H "Content-Type: application/json" \
-d '{ "value": { "fNumber": 8.0, "speedMs": 0.001 } }'What goes on the DDS wire: {"fNumber":8.0,"speedMs":0.001}.
When a DDS publisher emits a sample on topic Camera, the broker:
- Looks up the topic in
~/.orionld→ finds(Camera, cam1, shutterSpeed). - Builds an attribute update with
value= the sample. - Auto-creates the entity if it doesn't exist (
typedefaults to the configuredentityType). - Merge-patches the attribute on the entity.
You can immediately GET the entity to see the latest sample as the
attribute's value.
We map a DDS service Adder to attribute sum on entity urn:ngsi-ld:robot:r1.
$REPO_HOME/scripts/configFile.sh \
--ddsService "Adder,Robot,urn:ngsi-ld:robot:r1,sum" \
--ddsTypesDirectory "$HOME/.orionld-types" \
--ddsSyncTimeoutMs 5000 \
> ~/.orionld
orionld -wip ddsPATCH the mapped attribute with the request as its value:
curl -X PATCH http://localhost:9999/ngsi-ld/v1/entities/urn:ngsi-ld:robot:r1/attrs/sum \
-H "Content-Type: application/json" \
-d '{ "value": { "a": 3, "b": 4 } }'Behaviour:
- Synchronous (default): the REST call blocks until the DDS reply
arrives, then returns. The reply is merged into the
sumattribute as a sub-PropertyddsServiceReply(carryingvalue= the reply payload, plusreplyTime). - Asynchronous: append
?ddsSync=false. The REST call returns immediately; the reply lands later as a sub-Property update via the same notification mechanism.
Timeout is governed by ngsild.syncTimeoutMs from the config (default 5 s).
On timeout, REST returns 504.
curl http://localhost:9999/ngsi-ld/v1/entities/urn:ngsi-ld:robot:r1{
"id": "urn:ngsi-ld:robot:r1",
"type": "Robot",
"sum": {
"type": "Property",
"value": { "a": 3, "b": 4 },
"ddsServiceReply": {
"type": "Property",
"value": { "result": 7 },
"replyTime": { "type": "Property", "value": 1715617200.123 },
"instanceHandleId": { "type": "Property", "value": "..." }
}
}
}DDS actions are the trickiest mapping, because a single attribute may host
many concurrent in-flight goals. The broker handles this by giving each
goal its own attribute instance, distinguished by datasetId.
We map a DDS action Fibonacci to attribute fib on entity
urn:ngsi-ld:robot:r1.
$REPO_HOME/scripts/configFile.sh \
--ddsAction "Fibonacci,Robot,urn:ngsi-ld:robot:r1,fib" \
--ddsTypesDirectory "$HOME/.orionld-types" \
> ~/.orionld
orionld -wip ddsPATCH the attribute. The value is the goal payload (e.g. for Fibonacci,
the order).
curl -X PATCH http://localhost:9999/ngsi-ld/v1/entities/urn:ngsi-ld:robot:r1/attrs/fib \
-H "Content-Type: application/json" \
-d '{ "value": { "order": 5 } }'The broker generates a goalId (UUID), sends the goal over DDS, and
returns immediately. From that moment a new attribute instance exists on
the entity, with datasetId = "urn:goal:<goalId>".
Add an endpoint sub-attribute to the goal PATCH and the broker
auto-creates a temporary subscription (cache-only, not persisted) scoped
to the goal's entity: the feedback / result / status updates are delivered to
that endpoint for the life of the goal, and the subscription is torn down
automatically when the goal terminates. No separate POST /subscriptions
needed.
curl -X PATCH http://localhost:9999/ngsi-ld/v1/entities/urn:ngsi-ld:robot:r1 \
-H "Content-Type: application/json" \
-d '{ "fib": { "type": "Property",
"value": { "order": 5 },
"endpoint": { "type": "Property", "value": "http://my-app:7000/notify" } } }'endpoint is control metadata: it is not sent on the DDS wire (only value
is) and it is removed together with the attribute when the goal completes.
The temporary subscription is datasetId-scoped to this goal in two independent ways:
- its
watchedAttributesentry is"<attr>@<goalDatasetId>", so it is triggered only by changes to this goal's instance; and - its top-level
datasetIdprojects each notification body down to this goal's own feedback / result / status envelope.
So when several goals run on the same action attribute at the same time, each initiator receives only its own goal's updates — concurrent goals do not cross-talk.
NOTE. datasetId in
watchedAttributes(syntax"<attr>@<datasetId>", with"<attr>@@none"for the default instance) is an Orion-LD extension — not yet part of the ETSI NGSI-LD API; an addition has been proposed, so the syntax may change.
As the DDS action server emits feedback samples and finally a result with a status, the broker stores each in the goal's attribute instance:
{
"fib": [
{
"type": "Property",
"value": { "order": 5 } // the *default* instance (the goal request)
},
{
"type": "Property",
"datasetId": "urn:goal:9b…", // this is the goal's instance
"ddsActionFeedback": { // updated each time a feedback sample arrives
"type": "Property",
"value": { "partial_sequence": [0, 1, 1, 2, 3] },
"publishedAt": { "type": "Property", "value": 1715617200.456 }
},
"ddsActionResult": { // appears once the server returns the result
"type": "Property",
"value": { "sequence": [0, 1, 1, 2, 3, 5] },
"publishedAt": { "type": "Property", "value": 1715617200.789 }
},
"ddsActionStatus": { // status transitions (succeeded, aborted, executing, …)
"type": "Property",
"value": { "code": "succeeded", "message": "" },
"publishedAt": { "type": "Property", "value": 1715617200.790 }
}
}
]
}Use the standard NGSI-LD ?datasetId query parameter:
curl 'http://localhost:9999/ngsi-ld/v1/entities/urn:ngsi-ld:robot:r1?datasetId=urn:goal:9b…'When -wip dds is on, the broker also accepts ?goal=<uuid> as a synonym
that auto-prefixes urn:goal::
curl 'http://localhost:9999/ngsi-ld/v1/entities/urn:ngsi-ld:robot:r1?goal=9b…'DELETE the goal's attribute instance. The broker sees datasetId = urn:goal:<uuid> on a DDS-action attribute and emits a cancel-request to
the action server before removing the local instance.
curl -X DELETE \
'http://localhost:9999/ngsi-ld/v1/entities/urn:ngsi-ld:robot:r1/attrs/fib?datasetId=urn:goal:9b…'
# Or, equivalently:
curl -X DELETE \
'http://localhost:9999/ngsi-ld/v1/entities/urn:ngsi-ld:robot:r1/attrs/fib?goal=9b…'When a goal reaches a terminal status (succeeded / canceled / aborted / rejected / timeout / failed), the broker:
- delivers the final notification (if a temporary
endpointsubscription exists), - tears down that temporary subscription,
- removes the goal's
datasetIdinstance — and when it was the last instance, removes the whole attribute (fibdisappears from the entity).
A subsequent goal PATCH re-creates the attribute, so the entity only carries
fib while one or more goals are in flight. The goal's full history (every
feedback / result / status update) remains in the temporal (TRoE) database —
only the live representation is removed.
The instance/attribute removal is a direct DB write, so it does not itself emit a TRoE record of the disappearance (the lifecycle leading up to it is recorded). See §13.
For PATCH /entities/{id} specifically (the entity-level PATCH, not the
single-attribute one), the DDS call is synchronous and load-bearing. When
-wip dds is active and the caller has not opted out with ?ddsSync=false,
the broker:
- Executes the DDS service / action calls embedded in the payload first.
- If any of them fails (timeout, 503/504), the entire PATCH is rejected before anything is written to mongo. That includes non-DDS attributes in the same payload — they are not persisted.
- Only if all DDS calls succeed does the PATCH go on to merge the changes into the entity and fire subscription notifications.
This is intentional: PATCH /entities/{id} is the API surface for
DDS-driven workflows where the entity state and the upstream DDS world must
move together or not at all.
The single-attribute PATCH (PATCH /entities/{id}/attrs/{attr}) keeps
fire-and-forget semantics — it's the explicit escape hatch when the caller
wants async behaviour. To force fire-and-forget on the entity-level PATCH
too, pass ?ddsSync=false.
Inbound DDS samples are merged into entities via the broker's normal
attribute-update path (orionldPutAttribute under the hood). The DDS
flow joins the standard alteration / notification pipeline at exactly the
same point as a REST update, which means:
-
NGSI-LD subscriptions fire on DDS-driven updates with no extra configuration. A subscription that watches
(entityId, attribute)will notify on:- A DDS topic sample arriving on the mapped attribute.
- A DDS service reply landing on the attribute as a
ddsServiceReplysub-Property. - A DDS action's feedback / result / status arriving on the goal's attribute instance.
-
The notification payload is the standard NGSI-LD notification — no DDS-specific framing. The goal id is the instance's
datasetId(urn:goal:<uuid>); the publish time and other DDS envelope metadata are available as sub-Properties of the attribute (see §8.3).
curl -X POST http://localhost:9999/ngsi-ld/v1/subscriptions \
-H "Content-Type: application/ld+json" \
-d '{
"id": "urn:ngsi-ld:Subscription:fib-progress",
"type": "Subscription",
"entities": [ { "type": "Robot" } ],
"watchedAttributes": [ "fib" ],
"notification": {
"attributes": [ "fib" ],
"format": "normalized",
"endpoint": { "uri": "http://my-app:7000/notify",
"accept": "application/json" }
},
"@context": "https://uri.etsi.org/ngsi-ld/v1/ngsi-ld-core-context-v1.8.jsonld"
}'Every feedback sample from the DDS action server, every status transition,
and the final result will arrive at http://my-app:7000/notify as a normal
NGSI-LD notification carrying the full fib attribute (with all goal
instances). To narrow down to a single goal, filter by datasetId on the
consumer side, or use q-filters on the sub-Properties — e.g.
q=fib.ddsActionStatus.value.code=="succeeded".
The subscription itself is plain NGSI-LD: it has no notion of DDS, doesn't care that the updates originated from DDS, and works the same whether the attribute is driven by REST writes or by a DDS publisher.
Not every NGSI-LD operation talks to DDS today. The current set of DDS-aware service routines:
| Verb + path | Effect on DDS |
|---|---|
POST /entities |
For every attribute in the body that's mapped to a topic / service / action, performs the corresponding publish / request / goal-send. |
POST /entityOperations/create |
Same, per entity in the batch. |
POST /entityOperations/upsert |
Same. |
POST /entityOperations/update |
Same. |
PUT /entities/{id} |
Same — every mapped attribute in the body is published. |
PUT /entities/{id}/attrs/{attr} |
If the attribute is mapped, publishes / requests / sends-goal. |
PATCH /entities/{id} |
Same — every mapped attribute in the body. |
PATCH /entities/{id}/attrs/{attr} |
Same — single attribute. This is the canonical way to drive services / actions. |
DELETE /entities/{id}/attrs/{attr}?datasetId=urn:goal:<uuid> |
Only for DDS Actions: cancels the upstream goal before deleting the instance. |
Endpoints that do not touch DDS today: GETs (entity / attribute / subscriptions), DELETE entity, DELETE attribute without a goal datasetId, subscription / registration CRUD, temporal API.
For DDS topics, FastDDS picks up the type via discovery the first time a peer publishes — orion-ld doesn't need anything more than the topic name.
For DDS services and actions, the broker has to be able to send typed requests/goals before any reply has flowed back. There's no discovery peer to learn the type from, so the IDL has to be available locally:
- The
typesDirectoryfrom~/.orionldpoints to a directory of pre-serialised IDL types (.binfiles), one per type name. - Files used by the orion-ld functests live in
test/functionalTest/ddsTypes/. The naming convention is<typeName>.binwhere<typeName>is the DDS IDL type used on the request / goal topic. - The
.binfiles are produced byfastddsgenfrom the matching.idl. Use any standard FastDDS toolchain (or copy them from a ROS 2 / Vulcanexus install).
If you forget the directory or it's missing a type, service / action calls will fail with a "type not found" error in the broker log and the REST caller will see a 503 or 504.
- The broker cannot introduce new DDS types or topics on its own.
There is no runtime / REST API in orion-ld to upload an IDL, register a
new type, or create a brand-new topic from scratch. DDS itself requires
every participant in the network to have the type definition statically,
which means a new (type, topic) pair always needs source-code changes
and a recompile somewhere in the network (the publisher, the
subscriber, or both). The broker is no different — it joins existing DDS
topics whose types are already defined in the network. In practice this
means: design the IDL and roll it out across the DDS side first, then
add the
(topic, entity, attribute)mapping in~/.orionldon the orion-ld side. - Value must be a JSON object. Publishing scalars on a DDS topic is silently skipped with a warning in the log.
- No sub-attributes on the wire. See §5.
If your application needs
observedAt,unitCode, etc. on the DDS consumer side, embed them insidevalue. - One attribute per endpoint. Each topic / service / action maps to
exactly one
(entityId, attribute)pair. To expose the same DDS topic on multiple entities, configure it multiple times under different topic names that share the same underlying DDS topic — there's no built-in fan-out. - Action notifications are not yet goal-scoped. A subscription on an
action attribute (including the temp
endpointone) fires on every change, but the notification body carries the attribute's default instance, not the changed goal's per-instance feedback/result/status. Datasetid-scoped notification projection is planned; until then, read goal detail viaGET …?datasetId=urn:goal:<uuid>or the temporal API. - Goal completion isn't recorded in TRoE. The per-goal lifecycle (feedback/result/status updates) is recorded, but the final removal of the instance/attribute is a direct DB write and emits no TRoE "deletion" entry.
FibonacciServer.pycrashes on Jazzy. The Python action server shipped with FIWARE-DDS-Enabler has a knownrclpyissue on Jazzy that hits before any goal is sent — seeeprosima_fibonacci_server_jazzy_bug.md. Workaround until fixed upstream: use a C++ action server (note that the enabler uses a non-standard topic-naming convention; see the bug report for details).- The
-wipflag is intentional. The DDS feature set is still evolving — wire formats, type-discovery semantics, and the mapping schema may change before the-wipqualifier is dropped.
Usage: configFile.sh [options]
--ddsTopic <topic>,<entity type>,<entity id>,<attribute name> (repeatable)
--ddsService <name>,<entity type>,<entity id>,<attribute name> (repeatable)
--ddsAction <action>,<entity type>,<entity id>,<attribute name> (repeatable)
--ddsTypesDirectory <absolute path to directory of .bin type files>
--ddsSyncTimeoutMs <milliseconds>
--troe <id,idPattern,type1+type2+...,attr1+attr2+...> (repeatable, unrelated to DDS)
-u Usage
Pass each --dds* flag once per endpoint you want to register. The
resulting JSON is written to stdout; pipe to ~/.orionld and start the
broker with -wip dds.
A self-contained example demonstrating a broker + a Fast-DDS publisher
sharing a topic lives in
test/functionalTest/cases/0000_ld/dds/dds_publish_post_entity.test. The
disabled action-roundtrip test
(test/functionalTest/cases/0000_ld/dds/dds_action_full_roundtrip.test.DISABLED)
shows the full broker ↔ ROS 2 action-server flow with feedback / result /
status materialisation. Both are useful as recipes.
{ "id": "urn:ngsi-ld:robot:r1", "type": "Robot", "fib": { // an Attribute "type": "Property", "value": { "order": 10 }, // the attribute's value "observedAt": "2026-05-13T17:30:00Z" // a sub-Property (metadata) } }