ros2_medkit_opcua
Design notes for the OPC-UA gateway plugin.
Motivation
Mixed ROS 2 + PLC diagnostics
Industrial robot deployments are rarely pure ROS 2. A typical cell combines ROS 2 manipulators with PLC-controlled fixtures, conveyors, safety relays, and process equipment. Operators want a single diagnostic surface that covers both sides.
The OPC-UA plugin lets ros2_medkit_gateway expose OPC-UA PLC tags as first-class SOVD entities,
giving diagnostic tools and MCP agents one consistent API across the whole cell.
Architecture
The plugin is loaded by ros2_medkit_gateway at runtime via dlopen and implements two
interfaces:
GatewayPlugin- lifecycle (configure,set_context,get_routes,shutdown) and vendor REST endpoints.IntrospectionProvider- emits anIntrospectionResultdescribing the SOVD entities generated from the PLC node map, so the gateway’s merge pipeline can place them in the entity tree.
OPC-UA server (PLC, OpenPLC, Siemens S7, Beckhoff, ...)
| opc.tcp, port 4840
v
OpcuaClient (open62541pp wrapper)
|
v
OpcuaPoller --- threshold alarms ---> OpcuaPlugin::on_alarm_change
| |
| PollSnapshot | ros2_medkit_msgs/ReportFault
v v (or ClearFault when alarm clears)
OpcuaPlugin::publish_values ros2_medkit_fault_manager
| |
v v
std_msgs/Float32 topics SOVD faults on PLC entities
(one per numeric node)
Node map schema
All mapping lives in YAML (config/tank_demo_nodes.yaml is the reference example):
area_id/component_id- SOVD tree placement for the PLC runtimenodes- list of entries, each with:node_id- OPC-UA node identifier (e.g."ns=2;i=1")entity_id- SOVD app the value belongs todata_name- short name used in REST URLsdisplay_name,unit,data_type,writable- metadata.writableis ignored in a read-only build (see below)min_value/max_value- optional write range checkalarm- optional fault definition (fault_code,severity,threshold,above_thresholddirection)
The plugin binary is completely PLC-agnostic; pointing it at a different node map file reuses the same build for a different PLC.
Poll vs subscription mode
The plugin supports two data paths, selected by the prefer_subscriptions config flag:
Polling (default) - a background thread reads all mapped nodes at a fixed interval
(poll_interval_ms, default 1000 ms). Simple, predictable, no event loop needed, sufficient
for diagnostic use cases where latency below one second does not add value.
Subscription - registers OPC-UA monitored items and receives change notifications at
subscription_interval_ms. Lower CPU cost on large node sets, but requires a running OPC-UA
client event loop. Kept as an opt-in path because many PLC servers have limits on the number
of active subscriptions.
Both modes share the same PollSnapshot output structure, so downstream consumers
(OpcuaPlugin::publish_values, REST handlers, fault mapping) are agnostic to the source.
Alarm -> fault mapping
Each node in the map may declare an alarm block. The poller compares the current value
against the configured threshold on every poll; transitions across the threshold produce
edge-triggered callbacks:
Active -> fault reported via
/fault_manager/report_faultwith the configured severity and messageCleared -> fault cleared via
/fault_manager/clear_faultby fault code
The plugin keeps per-fault state only long enough to detect edges; the fault manager owns persistence and fault lifecycle.
Read-only is a property of the build
Four of the five protocol plugins cannot write to their device at all. OPC UA can,
and config-less discovery would mark a point writable straight from the server’s
CurrentWrite bit. A plant asking “can this box change a controller” cannot be
answered by a configuration value, because a configuration value can be changed
without rebuilding or shipping anything. So the answer is carried by the
binary.
MEDKIT_OPCUA_READ_ONLY is a CMake cache option, default ON. With it on:
OpcuaClient::write_value, theopen62541ppWrite service functions and templates it reaches, the vendor route handlerOpcuaPlugin::handle_plc_operationsand the value-coercion helper are all outside#ifand are never compiled.The link removes what the compiler alone cannot.
-fvisibility=hiddencovers the sources compiled into the module but not the static archives it links, so without further flags open62541’s ownUA_Client_write*primitives sit in the object and in its dynamic symbol table, where a caller holding the.socandlsymone and drive a controller with it even though no route reaches it.-Wl,--exclude-libs,ALLtakes the archives out of the export table, and-ffunction-sections -fdata-sectionsplus-Wl,--gc-sectionsthen let the linker drop them from the object entirely, because nothing references them once the C++ write path is gone. The read-only object exports the six plugin entry points and nothing from the OPC-UA stack.The section flags are set on
open62541-objectandopen62541-plugins, which is where upstream compiles its C sources -open62541itself is assembled from$<TARGET_OBJECTS:...>and compiles nothing. Upstream adds the same two flags, but only underReleaseandMinSizeRel, so a default-type build previously kept twelveUA_*_write*symbols in the read-only object as local, unexported code. Setting them here makes the property independent ofCMAKE_BUILD_TYPE, andtest_opcua_build_variantasserts the absence of that whole family so the difference cannot come back unnoticed.What is left of open62541 inside the object is one shared transport, not a write path. open62541 is a single static library, so removing the write code does not remove what it shared with the read code: the generic dispatcher
__UA_Client_Service(used by read, browse and ConditionRefresh), the binary encoders (23*_encodeBinarysymbols), and the generatedUA_TYPESdescriptors, which the table references as a whole so the linker cannot drop individual entries. Measured on the read-only object, those descriptors includeWriteRequest,WriteValue,WriteResponse,AddNodes,DeleteNodes,AddReferences,SetMonitoringMode,SetPublishingModeandTransferSubscriptions;HistoryUpdateis absent.Descriptors are data. What makes them unreachable is that no function in the object composes any of those requests, none of these symbols is exported, and a read-only build registers three GET routes - no route, node-map key or config key supplies a NodeId, a method id or an attribute id. So the claim the package makes is the exact one: every C++ path that composes a Write or a condition method call is absent, and no OPC UA symbol is exported. Not “no OPC UA machinery at all”, which would be false.
Subscription and monitored-item creation stay in both variants. They change server-side session state, which is not controller data, and the read path cannot receive values or alarms without them.
NodeMap::loadforceswritableto false and warns once when the file asked otherwise;AutoBrowserdoes not compile theinfer_writableinference, so the server’sCurrentWritebit is never read.No entity registers the
x-plc-operationscapability,list_operationsemits noset_<name>entry, andget_routesdoes not register the write route.x-plc-statuscarrieswrite_capable, taken from the same macro, so the property is readable from the wire as a positive statement. The absence of thex-plc-operationscapability is not one: a write-capable build whose node map marks no point writable advertises exactly the same absence.write_dataand the value-write half ofexecute_operationreturn 501 as their first statement, before any node lookup, with a message naming the build property. SOVD spells “the entity does not support this” as 501 (fault deletion, data lists, subscriptions and triggers all use it); 403 is defined once, for a valid token with insufficient permissions, which is the one reading that is wrong here - no credential reaches a path that is not in the binary. They stay declared because the gateway reaches the plugin through theDataProvider/OperationProviderinterfaces; the refusal reaches a client as SOVD vendor codex-medkit-plugin-error, which is the code the gateway assigns to every plugin provider error.acknowledge_fault/confirm_faultgo the same way. A method call that changes alarm state on the server is a write to the controller, and the rule is that a read-only build carries no write path at all, not merely no value writes.OpcuaClient::call_condition_method- the entry point that issues the Part 9 Acknowledge (i=9111) and Confirm (i=9113) calls - is compiled only in a write-capable build,list_operationsoffers neither operation on an event-alarm entity, andexecute_operationrefuses both by name before any condition lookup.ConditionRefreshstays, and so does the genericOpcuaClient::call_methodit rides on. Part 9 5.5.7 makes it a request for the server to replay the conditions it already holds to the calling subscription; it changes nothing on the controller, and without it a restart loses the active fault set. That is why the guard sits on the condition-method entry point rather than oncall_method, and why neithercall_methodnoropcua::services::callis a write marker - measured, they are present in both variants.
The acceptance is an inspection of the built object, not a reading of the
configuration: test_opcua_build_variant runs nm over the plugin .so and
asserts the write symbols are absent, the read symbols present, the dynamic symbol
table free of OPC-UA machinery, and the six plugin entry points still exported. It
runs in both variants with opposite expectations, so a symbol list that stopped
matching anything fails in the write-capable build instead of passing everywhere.
Type-aware writes (write-capable build)
POST /apps/{id}/x-plc-operations/{op} accepts a JSON body {"value": ...}. The handler:
Looks up the node by
data_name(with optionalset_prefix in the op name).Rejects read-only nodes with a 400 error.
Coerces the JSON value to the node’s declared
data_type(bool,int,string, otherwisedouble) and returns a typed 400 on mismatch.Applies the range check (
min_value/max_value) if configured.Calls
OpcuaClient::write_valuewhich uses the node’s OPC-UA data type to build the correct binary payload.
This avoids the common failure mode of writing a float64 to an OPC-UA REAL (float32)
node and getting silent truncation or a server-side rejection.
ROS 2 topic bridge
set_context creates one std_msgs/Float32 publisher per numeric node, named from the
ros2_topic field of the node map. After every poll, publish_values pushes the current
values onto these topics. Non-numeric nodes (strings) are skipped with a one-time warning per
node.
This lets ROS 2 consumers (rqt plots, nav2 behavior trees, diagnostic aggregators) consume PLC state without having to speak OPC-UA directly.
Security
The current implementation uses anonymous OPC-UA authentication with SecurityPolicy=None.
This is explicitly not suitable for untrusted networks - the plugin logs a warning on
startup. Proper username/password and certificate-based authentication are planned.
Supported PLC servers
The plugin speaks plain OPC-UA and supports all four node identifier types
defined by OPC 10000-6 section 5.3.1.10 (i= numeric, s= string,
g= GUID, b= opaque ByteString). This means the same binary works
against any compliant OPC-UA server without a code change - only the
node_map.yaml changes per vendor. config/tank_demo_nodes.yaml carries
ready-to-paste examples for OpenPLC (this demo), Siemens S7-1500 TIA Portal,
Beckhoff TwinCAT 3, Allen-Bradley via Kepware, and KUKA KR C5.
What the plugin can NOT do today (regardless of vendor):
Complex OPC-UA types: only scalar Variables (
int,float,bool,string) are mapped. Structures, arrays, enums, unions and ExtensionObjects are ignored.Vendor information models: profiles like Euromap 77 (injection molding), Siemens DI (device integration), and PA-DIM (process automation) are not interpreted natively. Tags exposed through those models can still be mapped manually via their node IDs, but the plugin does not understand the model semantics.
Native OPC-UA Alarms & Conditions: the plugin detects alarms by applying thresholds to polled values. Servers that publish native AlarmCondition events (e.g. Siemens
AlarmConditionType) are not subscribed to.
Future work
Tracked issues in the ros2_medkit issue tracker:
#367 Certificate-based OPC-UA authentication (Basic256Sha256)
#368 Optional auto-browse of the OPC-UA address space to seed the node map (deferred pending validated user demand; UaExpert and
python -m asyncua.tools.ualsare recommended for now)#366 Vendor
open62541ppinline so the package can be added to the rosdistro release list
Untracked (open the issue if you hit the pain):
Hot-reload of the node map without restarting the plugin
Complex OPC-UA type support (structures, arrays, enums)
Vendor information model bindings (Euromap 77, Siemens DI, PA-DIM)
Native AlarmConditionType event subscription (issue #386)
The plugin subscribes to native OPC-UA Part 9 AlarmConditionType events
emitted by vendor PLCs, in addition to the threshold-based polling path. Both
modes coexist in a single node_map.yaml (different YAML keys) and feed the
same fault_manager service.
Configuration
Two YAML forms describe alarms; an entry can use one but never both:
nodes:
# Threshold-based (existing): polls the scalar value and raises a fault
# when it crosses the configured threshold.
- node_id: 'ns=2;i=2'
entity_id: tank_process
data_name: tank_temperature
data_type: float
alarm:
fault_code: TANK_OVERHEAT
severity: ERROR
threshold: 80.0
above_threshold: true
event_alarms:
# Native AlarmConditionType (new): subscribes to events emitted from
# the source NodeId and bridges them through the state machine below.
- alarm_source: 'ns=4;s=Alarms.Overpressure'
entity_id: tank_process
fault_code: PLC_OVERPRESSURE
severity_override: ERROR # optional - else derived from event Severity
message: 'Tank overpressure' # optional - else event Message field
State machine
Inputs from each event payload (positional EventFilter select clauses):
ConditionId- NodeId; null means the notification is not an A&C Condition (a plain BaseEvent / system message), gated by rule 0 below before the restEnabledState.Id- boolShelvingState.CurrentState.Id- NodeId; non-Unshelved => suppressedActiveState.Id- boolAckedState.Id- boolConfirmedState.Id- boolBranchId- NodeId; non-null means historical branch (Part 9 §5.5.2.12)
Decision order, first match wins:
# |
Condition |
Outcome |
|---|---|---|
0 |
|
not a Condition: dropped for every alarm source - explicit and auto - before the state machine runs |
1 |
|
history-only (no SOVD update) |
2 |
|
clear if was active, else no-op |
3 |
|
clear if was active, else no-op.
A null/unset/unknown |
4 |
|
|
5a |
|
internal |
5b |
|
|
Rule 0 is enforced in the poller’s on_event (via is_condition_event)
before any state-machine input is built, and applies to every alarm source -
explicit event_alarms (including a source-level fault_code catch-all on
ns=0;i=2253) and zero-config auto_alarms alike. A drop on an explicit
source is additionally logged once as an operator warning, since a real alarm
that genuinely lacks a ConditionId would otherwise vanish without a trace.
require_confirm_for_clear (default true) gates rule 5b on both
Acknowledge and Confirm. Some servers do not implement the optional Confirm
transition - Siemens S7-1500 supports Acknowledge / ConditionRefresh /
AddComment but not Confirm - so ConfirmedState never becomes true
and the alarm would latch in HEALED forever. Setting the flag false
(YAML require_confirm_for_clear / env OPCUA_REQUIRE_CONFIRM_FOR_CLEAR)
clears such an alarm on Acknowledge alone. The default is unchanged
(spec-strict, no regression) and the relaxed path still requires
acknowledgement; it needs real-S7-1500 validation (issue #478).
Retain is NOT used by this live state-machine table (lifecycle is driven by
Active / Acked / Confirmed; per Part 9 §5.5.2.10 Retain controls visibility
during ConditionRefresh bursts). It IS used in the read-based reconnect
replay path (below) as the interesting-state filter, mirroring what
ConditionRefresh would replay. The SOVD PREFAILED state has no native
equivalent and is reserved for the threshold-polling pre-trigger path.
Note
HEALED is internal-only. Rule 5a transitions the bridge’s internal
SovdAlarmStatus to Healed and emits the ReportHealed action,
but OpcuaPlugin::on_event_alarm deliberately treats that action as a
no-op. The reason: ros2_medkit_msgs/srv/ReportFault has only
EVENT_FAILED / EVENT_PASSED verbs, and routing EVENT_PASSED
on every latch would feed the fault_manager debounce engine - which has
its own statistical HEALED semantics (healing_threshold cycles
of PASSED events) and may auto-clear the fault, defeating Part 9’s
mandatory ack/confirm contract. Until ros2_medkit_msgs/msg/Fault
gains a STATUS_LATCHED (or equivalent) value, /faults keeps
status=CONFIRMED for a latched alarm; the next Cleared (rule 5b)
removes the entry.
This is a known UX gap: an operator looking at /faults cannot
distinguish “alarm physically active” from “alarm physically cleared,
awaiting confirm”. The ack/confirm SOVD operations work end-to-end; the
visibility limitation is tracked as a follow-up.
Severity mapping
OPC-UA severity is a 1-1000 scalar. The plugin maps it to selfpatch’s SOVD severity buckets:
1-200 ->
INFO201-500 ->
WARNING501-800 ->
ERROR801-1000 ->
CRITICAL
This is the selfpatch convention, not IEC 62682 - that spec defines a
1-1000 priority scale but no normative band names. severity_override on
an event_alarms entry takes precedence when set.
ConditionRefresh
After creating event monitored items the plugin invokes ConditionRefresh
so the server pushes any condition that fired before the subscription started.
The same call fires on every successful reconnect.
Per Part 9 §5.5.7 ConditionRefresh is a Method of the ConditionType, so
the Call’s ObjectId is the well-known ConditionType node i=2782 (method
i=3875), NOT the Server object i=2253. Calling it on the Server object
was the root cause of BadNodeIdUnknown rejections on conformant servers,
including Siemens S7-1500, which documents ConditionRefresh as supported
(only the optional ConditionRefresh2 is not). With the corrected target the
method is expected to succeed on Siemens, making the read fallback unnecessary
there (issue #478).
The bracketing RefreshStartEventType (i=2787) and RefreshEndEventType
(i=2788) are recognized and used to set a diagnostic flag; live notifications
arriving during the burst are applied normally because the state machine is
driven by per-condition ConditionId and runs idempotently.
Read-based replay fallback
When ConditionRefresh is rejected (condition_replay_strategy =
read / auto) the plugin browses each alarm_source for AlarmCondition
instances - hierarchical Object children plus targets of the HasCondition
reference (i=9006), recursing one level - reads their current state, and
drives the same state machine. It mirrors ConditionRefresh semantics:
only conditions with
Retain == trueform the active set;a
Disabledcondition (EnabledState/Id = false) is never active;a transient browse/read failure keeps the condition (it is not dropped and must not be reconciled away).
Safety gate (issue #478). The reconcile that clears faults for conditions
no longer present NEVER fires for a source that has not positively exposed at
least one Condition instance node. An EventNotifier-only server (S7-1500 models
no per-condition instance nodes) returns a successful but empty browse; rather
than wiping the tracked active alarms, the plugin preserves them, logs an
operator warning, and relies on ConditionRefresh / live transitions. This
prevents the net-negative outcome where a reconnect erased a still-active alarm.
Acknowledge / Confirm round-trip
Two SOVD operations appear on every entity that has at least one event-mode alarm declared, in a write-capable build. Both change condition state on the controller, so the default read-only build neither lists nor performs them (see “Read-only is a property of the build” above):
POST /apps/{entity}/operations/acknowledge_fault/executionsPOST /apps/{entity}/operations/confirm_fault/executions
Body:
{ "fault_code": "PLC_OVERPRESSURE", "comment": "operator on radio" }
The plugin resolves (entity_id, fault_code) to the live ConditionId
maintained by the poller, then calls the inherited
AcknowledgeableConditionType method (Acknowledge i=9111 or
Confirm i=9113) on that NodeId. The latest EventId ByteString
captured from the most recent notification is passed as the first argument;
without it servers return BadEventIdUnknown (Part 9 §5.7.3).
Vendor matrix
Vendor / runtime |
AlarmConditionType |
Notes |
|---|---|---|
Siemens S7-1500 |
yes (FW V2.9+) |
ProDiag, Program_Alarm, system diagnostics. ConditionRefresh + Acknowledge supported, NO Confirm (set require_confirm_for_clear false). EventNotifier-only: no condition instance nodes |
Beckhoff TwinCAT 3 |
yes (TF6100) |
|
Rockwell ControlLogix |
yes (via FactoryTalk Linx FW 16.20+) |
Tag-based alarms bridged by the gateway |
CodeSys 3.5+ |
yes (alarm manager provider library) |
Custom severity mapping |
OpenPLC v3 |
no |
Scalar variables only; use threshold mode |
Out of scope
ShelvingStatewrite operations (TimedShelve/OneShotShelve/Unshelve). The plugin reads the state to suppress active alarms but does not yet expose operator UI to set it.OPC-UA branch reasoning beyond
BranchId-based suppression. Re-fires are tracked viafault_manageroccurrence_countplus the/faults/streamSSE history.Auto-discovery of alarm sources via
Server.GeneratedEventsbrowse (tracked in #368 alongside scalar auto-discovery).Quality(StatusCode) propagation to a SOVDstatus_qualityfield. Requires an additive field on theReportFault.srvschema; tracked separately.