Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,30 @@ if(BUILD_TESTING)
)
medkit_set_test_domain(test_opcua_client)

# Zero-config native A&C (auto_alarms): pure-function coverage of the
# subscription-source precedence rule (effective_alarm_sources) and the
# system-message filter (is_condition_event) - no live server needed.
ament_add_gtest(test_opcua_poller
test/test_opcua_poller.cpp
src/opcua_poller.cpp
src/opcua_client.cpp
src/node_map.cpp
)
target_include_directories(test_opcua_poller PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include
)
medkit_target_dependencies(test_opcua_poller
ros2_medkit_gateway
ros2_medkit_fault_detection
rclcpp
)
target_link_libraries(test_opcua_poller
open62541pp::open62541pp
nlohmann_json::nlohmann_json
yaml-cpp::yaml-cpp
)
medkit_set_test_domain(test_opcua_poller)

# Issue #386: pure-function state machine tests. Header-only target -
# no opcua dependency at link time so it runs fast and is sanitizer
# clean independent of the open62541pp build flavour.
Expand Down
100 changes: 99 additions & 1 deletion src/ros2_medkit_plugins/ros2_medkit_opcua/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,102 @@ The JSON/ROS-param form takes precedence over whatever the node-map YAML's
`auto_browse:` block set, mirroring how environment variables override the
rest of the plugin's YAML config.

### Zero-config native A&C (`auto_alarms`)

`event_alarms:` above requires a hand-written mapping per alarm. `auto_alarms`
needs none: enable it and every AlarmConditionType event on the subscribed
source (default the Server object, `i=2253` - the same system-wide catch-all
used in the `event_alarms` examples) becomes a fault automatically, with no
`condition_name` / `source_node` / `fault_code` mapping to write.

```yaml
# Bare boolean - all defaults.
auto_alarms: true
```

```yaml
# Map form - every key optional.
auto_alarms:
enabled: true
source_node_id: "i=2253" # EventNotifier to subscribe (default: Server object)
entity_id: "plc_alarms" # fallback SOVD entity (default: "<component_id>_alarms")
auto_clear: true # clear immediately on ActiveState=false, bypassing
# Acknowledge/Confirm (see below)
severity_bands: # OPC-UA Severity (1-1000) -> SOVD bucket
critical: 801
error: 501
warning: 201
include: [] # substring allow-list on ConditionName/SourceName/Message
exclude: ["CPU not in RUN"] # substring deny-list, checked first
```

Default `off`. Unknown keys are warned and ignored, not silently dropped.

Instead of (or on top of) the node-map YAML, `auto_alarms` can be set purely
via the plugin's ROS param / JSON config, which is what lets a discovered
endpoint go straight to native faults with no node-map file at all:

```yaml
plugins.opcua.endpoint_url: "opc.tcp://192.168.1.10:4840"
plugins.opcua.auto_alarms: true # or the same map form as above
```

The JSON/ROS-param form takes precedence over whatever the node-map YAML's
`auto_alarms:` block set (same precedence env vars use for the rest of the
plugin config). Explicit `event_alarms:` mappings still win over
auto-derivation at the poller regardless of how `auto_alarms` was enabled.
This completes the zero-config chain: discovery (endpoint) ->
`auto_browse` (SOVD tree) -> `auto_alarms` (native faults), each param-driven,
no node-map file required.

**Fault derivation** (no config, per observed event):
- `fault_code`: `PLC_ALARM_<slug>_<hash(SourceNode)>` from `ConditionName` when
present, else from `SourceName`, else `PLC_ALARM_<hash>` of
SourceNode + EventType + Message. The SourceNode is folded into every tier so
two distinct conditions sharing a ConditionName/SourceName but sitting on
different sources (e.g. two identical FB instances each raising
"Overpressure") never collapse onto one code - the fault manager keys/clears
by code alone, so a collision would let one condition's clear wipe the
other's still-active fault. Message is NOT folded into the slug tiers, so a
condition whose Message differs between its active and inactive notifications
still maps to one code. The hash tier additionally folds in the Message
deliberately - a real Siemens S7-1500 multiplexes every `Program_Alarm` of
one FB through a single SourceNode (e.g. `i=1845`) with no
ConditionName/SourceName, distinguished only by Message text ("pa" vs "pa2");
without the Message in the hash those two alarms would collapse onto one
fault_code. A given condition derives its code once (at first observation)
and keeps it for its whole lifecycle, so its raise and clear always agree.
- `entity`: the node-map entity that owns the event's SourceNode when it
matches a `nodes:` entry, else `auto_alarms.entity_id`.
- `severity`: the event's raw `Severity` (1-1000) mapped through
`severity_bands`.
- `description`: the event's `Message`, verbatim.

**System messages:** some servers deliver non-alarm notifications on the same
EventNotifier as real alarms - a Siemens Server object also emits SIMATIC
system messages (e.g. `"CPU not in RUN"`) over `i=2253`. Per OPC-UA Part 9
§5.5.2.13 only a true Condition instance carries a ConditionId; a system
message resolves it to `NodeId.Null`, and `auto_alarms` drops those events
before deriving a fault. Use `exclude` for anything that still needs
filtering by text (e.g. a server that does set a ConditionId on system
events).

**Precedence:** explicit `event_alarms` mappings are tried first; `auto_alarms`
covers whatever they do not match. On the same source (e.g. both configured
on `i=2253`) no duplicate subscription is created - unmatched events on an
`event_alarms` source simply fall through to auto-derivation when
`auto_alarms` is enabled for that source.

**Acknowledge/Confirm:** `auto_clear` (default `true`) clears an auto-derived
alarm as soon as it goes inactive, bypassing the Acknowledge/Confirm workflow
entirely - there is no `acknowledge_fault` SOVD operation for a zero-config
alarm to receive an Ack through, so without this it would latch forever on a
server that requires one (as most do). Set `auto_clear: false` to require the
poller's normal Acknowledge/Confirm gating before it clears - since a
zero-config alarm has no SOVD `acknowledge_fault` operation of its own,
Acked/Confirmed must come from the OPC-UA server side instead (an HMI/SCADA
system or another OPC-UA client acknowledging the condition directly).

### Gateway Parameters

```yaml
Expand All @@ -429,13 +525,15 @@ ros2_medkit_gateway:
plugins.opcua.poll_interval_ms: 1000
plugins.opcua.prefer_subscriptions: false
plugins.opcua.auto_browse: true
plugins.opcua.auto_alarms: true
```

| Parameter | Default | Description |
|-----------|---------|-------------|
| `endpoint_url` | `opc.tcp://localhost:4840` | OPC-UA server endpoint |
| `node_map_path` | (none) | Path to node map YAML (optional when `auto_browse` is enabled - see above) |
| `node_map_path` | (none) | Path to node map YAML (optional when `auto_browse` or `auto_alarms` is enabled - see above) |
| `auto_browse` | `false` | Boolean or object; recursively discover the SOVD tree from the live address space instead of (or in addition to) `node_map_path` - see above |
| `auto_alarms` | `false` | Boolean or object; subscribe a server EventNotifier and auto-derive a fault per AlarmCondition with no per-alarm `event_alarms` mapping - the zero-config native A&C surface (see above) |
| `poll_interval_ms` | `1000` | Polling interval in ms (clamped to [100, 60000]) |
| `prefer_subscriptions` | `false` | Use OPC-UA subscriptions instead of polling |
| `subscription_interval_ms` | `500` | Publishing interval for OPC-UA subscriptions when `prefer_subscriptions: true` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,72 @@ struct ResolvedAlarm {
bool matched{false};
};

/// OPC-UA Severity (1-1000) -> SOVD severity bucket thresholds for
/// ``auto_alarms`` (zero-config native A&C). Mirrors the fixed bands
/// ``OpcuaPlugin::map_severity`` uses for explicit ``event_alarms``
/// (>=801 CRITICAL, >=501 ERROR, >=201 WARNING, else INFO) but is
/// per-deployment overridable via ``auto_alarms.severity_bands`` since a PLC
/// vendor's own severity convention need not match ours.
struct AutoAlarmsSeverityBands {
uint16_t critical_min{801};
uint16_t error_min{501};
uint16_t warning_min{201};
};

/// Zero-config native OPC-UA Alarms & Conditions (completes the #509
/// discovery -> #510 auto_browse -> native faults story with no per-alarm
/// ``event_alarms`` mapping). When enabled, the plugin subscribes to
/// ``source_node_id`` (default the Server object, ``i=2253``) for
/// AlarmConditionType events and derives a fault for every condition it does
/// not already know from an explicit ``event_alarms`` mapping (issue #389
/// mappings always take precedence - see ``NodeMap::resolve_alarm``).
struct AutoAlarmsConfig {
bool enabled{false};

/// EventNotifier source to subscribe. Default is the Server object
/// (``i=2253``, "system-wide" catch-all): the natural zero-config choice
/// since Program_Alarm / ProDiag style conditions on Siemens, Beckhoff and
/// CodeSys controllers surface there without the operator having to find
/// the owning Object first.
std::string source_node_id_str = "i=2253";
opcua::NodeId source_node_id;

/// SOVD entity that hosts an auto-derived fault when its SourceNode is not
/// a known node-map entry. Defaults to ``"<component_id>_alarms"`` at load
/// time (see ``NodeMap::load``) - deliberately not the bare
/// ``component_id``, which the PLC runtime already registers as a
/// Component entity (a second App entity with the same id collides in the
/// SOVD merge pipeline). When the event's SourceNode matches an existing
/// ``nodes:`` entry, the fault is hosted on THAT entry's entity instead (a
/// more specific home than this fallback).
std::string entity_id;

/// When true (default), an auto-derived alarm clears as soon as
/// ActiveState becomes false, bypassing BOTH halves of the
/// Acknowledge/Confirm gate that otherwise applies to native alarms
/// (``AlarmStateMachine``'s clear rule is ``acked && (confirmed ||
/// !require_confirm_for_clear)`` - dropping only the Confirm half is not
/// enough, since a zero-config alarm has no SOVD ``acknowledge_fault``
/// operation registered to ever set Acked either). Zero-config is meant to
/// "just work" without an operator workflow, and several PLCs (e.g.
/// Siemens S7-1500) do not implement the optional Confirm transition at
/// all - without this, such an alarm would latch forever. Set false to
/// inherit the poller's normal ``require_confirm_for_clear`` gating
/// (Acknowledge still required) for auto-derived alarms too.
bool auto_clear{true};

AutoAlarmsSeverityBands severity_bands;

/// Case-sensitive substring filters against ConditionName / SourceName /
/// Message. ``exclude`` is checked first: any match drops the event
/// entirely (used to filter Siemens Server-object system messages such as
/// "CPU not in RUN" that are not real alarms). ``include`` is then checked
/// only if non-empty: the event is dropped unless at least one pattern
/// matches. Both empty (the default) admits every event.
std::vector<std::string> include_patterns;
std::vector<std::string> exclude_patterns;
};

/// Mapping entry: OPC-UA NodeId -> SOVD entity data point
struct NodeMapEntry {
std::string node_id_str; // OPC-UA node ID string (e.g., "ns=1;s=TankLevel")
Expand Down Expand Up @@ -256,6 +322,71 @@ class NodeMap {
const std::string & source_node, const std::string & event_type,
const std::string & message);

/// Get the zero-config native A&C configuration. ``enabled`` is false
/// unless the node map YAML declares a truthy ``auto_alarms:`` or the
/// plugin overlays a truthy ``plugins.opcua.auto_alarms`` param.
const AutoAlarmsConfig & auto_alarms() const {
return auto_alarms_;
}

/// Mutable access so ``OpcuaPlugin::configure`` can overlay the plugin's
/// ``plugins.opcua.auto_alarms`` JSON/ROS param on top of whatever the
/// node-map YAML declared (JSON wins - same precedence the env-var
/// overrides use for the rest of the plugin config). Call
/// ``finalize_auto_alarms_overlay`` afterwards to re-derive the fields
/// ``load`` normally fills in and rebuild the entity defs.
AutoAlarmsConfig & mutable_auto_alarms() {
return auto_alarms_;
}

/// Re-derive the ``auto_alarms`` fields ``load`` normally computes after the
/// plugin overlays a ``plugins.opcua.auto_alarms`` param (default
/// ``entity_id`` from ``component_id``, parsed ``source_node_id``) and
/// rebuild ``entity_defs_`` so a param-only zero-config deployment (no
/// node-map file, so ``load`` never ran) still surfaces the alarms App in
/// SOVD discovery. Idempotent - safe to call whether or not ``load`` ran.
/// @return false when the overlaid config is invalid (enabled with an empty
/// ``source_node_id``); the caller disables auto_alarms in that case.
bool finalize_auto_alarms_overlay();

/// Derive a stable SOVD fault_code for an auto-derived alarm with NO
/// per-alarm mapping. Tiered, in order:
/// 1. slug(ConditionName) + hash(SourceNode) - the OPC-UA condition
/// identity, when present.
/// 2. slug(SourceName) + hash(SourceNode) - the human-readable event
/// source, when ConditionName is empty.
/// 3. a hash of SourceNode + EventType + Message, when neither name is
/// available.
/// The SourceNode is folded into EVERY tier so two distinct conditions that
/// share a ConditionName/SourceName but sit on different sources (e.g. two
/// identical FB instances each raising "Overpressure") never collapse onto
/// one fault_code - fault_manager keys/clears by code alone, so a collision
/// would let one condition's clear wipe the other's still-active fault. The
/// SourceNode is canonicalized first, so ``i=2253`` and ``ns=0;i=2253`` fold
/// to one code. Tier 3 additionally folds in ``message`` deliberately: a
/// real Siemens S7-1500 multiplexes every Program_Alarm of one FB through a
/// single SourceNode (e.g. ``i=1845``) with no ConditionName/SourceName,
/// distinguished only by Message text ("pa" vs "pa2"). Message is NOT in
/// tiers 1/2, so a condition whose Message differs between its active and
/// inactive notifications still maps to one code. Pure / static so it is
/// unit-testable without a server.
static std::string derive_auto_fault_code(const std::string & condition_name, const std::string & source_name,
const std::string & source_node_str, const std::string & event_type_str,
const std::string & message);

/// Apply ``auto_alarms.include``/``exclude`` substring filters to an
/// observed event's identity fields. ``exclude`` wins first (drops the
/// event on any match); a non-empty ``include`` then requires at least one
/// match. Pure / static so it is unit-testable without a server.
static bool auto_alarm_passes_filters(const std::string & condition_name, const std::string & source_name,
const std::string & message, const std::vector<std::string> & include_patterns,
const std::vector<std::string> & exclude_patterns);

/// Map a raw OPC-UA event Severity (1-1000) to a SOVD severity bucket using
/// ``bands`` (``auto_alarms.severity_bands``, overridable per deployment).
/// Pure / static so it is unit-testable without a server.
static std::string map_auto_severity(uint16_t severity, const AutoAlarmsSeverityBands & bands);

/// Get derived SOVD entity definitions
const std::vector<PlcEntityDef> & entity_defs() const {
return entity_defs_;
Expand Down Expand Up @@ -335,6 +466,12 @@ class NodeMap {
// build_entity_defs() so SOVD discovery is unaffected.
std::vector<AlarmEventConfig> event_alarms_;

// Zero-config native A&C (issue #(auto-alarms)), loaded from top-level
// ``auto_alarms:``. Unlike ``event_alarms_`` this carries no per-condition
// mappings; the poller derives a fault_code/entity/severity per observed
// event at runtime (see NodeMap::derive_auto_fault_code / map_auto_severity).
AutoAlarmsConfig auto_alarms_;

std::string area_id_ = "plc_systems";
std::string area_name_ = "PLC Systems";
std::string component_id_ = "openplc_runtime";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ class OpcuaPlugin : public ros2_medkit_gateway::GatewayPlugin,
// else INFO). Pure + static so it is unit-testable without a live server.
static std::string map_severity(uint16_t live_severity, const std::string & severity_override);

// Overlay a ``plugins.opcua.auto_alarms`` JSON/ROS param onto ``cfg``. Accepts
// the bare-boolean shorthand or the full map form (same fields as the node-map
// YAML ``auto_alarms:`` loader: source_node_id, entity_id, auto_clear,
// severity_bands, include/exclude). Only keys actually present overwrite
// ``cfg``, so this composes on top of whatever the YAML block set - the JSON
// param wins, mirroring how ``plugins.opcua.auto_browse`` overlays its config
// and how env vars override the rest of the plugin config. Unknown keys warn.
// Callers run ``NodeMap::finalize_auto_alarms_overlay`` afterwards to re-derive
// the default entity and parsed source NodeId. Static + injected ``warn`` so
// the parse is unit-testable without a plugin instance.
static void apply_auto_alarms_param(const nlohmann::json & value, AutoAlarmsConfig & cfg,
const std::function<void(const std::string &)> & warn);

private:
// Route handlers
void handle_plc_data(const ros2_medkit_gateway::PluginRequest & req, ros2_medkit_gateway::PluginResponse & res);
Expand Down
Loading
Loading