From 2da3bff750f3d48b40bd2335fb783b3088154fd6 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Thu, 9 Jul 2026 18:54:23 +0200 Subject: [PATCH 1/5] feat(opcua): zero-config native A&C alarms -> faults Add auto_alarms to the node map: subscribe to a server EventNotifier (default Server object) and auto-derive a fault per condition with no per-alarm event_alarms mapping. fault_code from ConditionName/SourceName, falling back to a hash of SourceNode+EventType+Message so Siemens alarms sharing one SourceNode don't collapse. Filters non-condition system messages (null ConditionId). Explicit event_alarms still take precedence. Composes with #509 (discovery) and #510 (auto_browse). --- .../ros2_medkit_opcua/CMakeLists.txt | 24 ++ .../ros2_medkit_opcua/README.md | 67 +++++ .../include/ros2_medkit_opcua/node_map.hpp | 105 ++++++++ .../ros2_medkit_opcua/opcua_poller.hpp | 38 ++- .../ros2_medkit_opcua/src/node_map.cpp | 238 ++++++++++++++++- .../ros2_medkit_opcua/src/opcua_poller.cpp | 196 +++++++++++--- .../ros2_medkit_opcua/test/test_node_map.cpp | 247 ++++++++++++++++++ .../test/test_opcua_poller.cpp | 117 +++++++++ 8 files changed, 990 insertions(+), 42 deletions(-) create mode 100644 src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_poller.cpp diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt b/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt index f0c0ddba..108b19cd 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt @@ -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. diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md b/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md index 1591cdc4..f7316ac1 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md @@ -418,6 +418,73 @@ 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: "_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. + +**Fault derivation** (no config, per observed event): +- `fault_code`: `PLC_ALARM_` from `ConditionName` when present, else from + `SourceName`, else `PLC_ALARM_` of SourceNode + EventType + Message. + The hash tier 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. +- `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 +Acknowledge (via the `event_alarms` mapping path) before it clears. + ### Gateway Parameters ```yaml diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp index 4ed7719a..24d6b917 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp @@ -118,6 +118,69 @@ 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 the node map's ``component_id`` at + /// load time (see ``NodeMap::load``). 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 include_patterns; + std::vector 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") @@ -256,6 +319,42 @@ 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:``. + const AutoAlarmsConfig & auto_alarms() const { + return auto_alarms_; + } + + /// Derive a stable SOVD fault_code for an auto-derived alarm with NO + /// per-alarm mapping. Tiered, in order: + /// 1. slug(ConditionName) - the OPC-UA condition identity, when present. + /// 2. slug(SourceName) - the human-readable event source, when + /// ConditionName is empty. + /// 3. a hash of SourceNode + EventType + Message, when neither name is + /// available. + /// Tier 3 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"). Without the message in the hash, every + /// alarm on that FB would collapse onto one fault_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 & include_patterns, + const std::vector & 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 & entity_defs() const { return entity_defs_; @@ -335,6 +434,12 @@ class NodeMap { // build_entity_defs() so SOVD discovery is unaffected. std::vector 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"; diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp index f9dcab78..912a30de 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp @@ -237,6 +237,30 @@ class OpcuaPoller { std::chrono::steady_clock::time_point down_since, std::chrono::steady_clock::time_point now, std::chrono::milliseconds debounce); + /// Zero-config native A&C (``auto_alarms``): the alarm sources that should + /// actually be subscribed / replayed, i.e. every explicit ``event_alarms`` + /// entry plus (when ``auto_cfg.enabled`` and no explicit entry already + /// targets the same source) one synthetic, mapping-less + /// ``AlarmEventConfig`` for ``auto_cfg.source_node_id``. Because the + /// synthetic entry carries no ``fault_code``/``mappings``, + /// ``NodeMap::resolve_alarm`` always reports it unmatched, which is + /// exactly what routes every event on that source through the + /// auto-derivation branch in ``on_event`` / ``read_fallback_replay`` + /// (explicit mappings on a shared source are tried first and still win - + /// precedence). Pure and static so subscription-source selection and the + /// precedence rule are unit-testable without a server. + static std::vector effective_alarm_sources(const std::vector & explicit_sources, + const AutoAlarmsConfig & auto_cfg); + + /// True only for a real OPC-UA Condition event. Per Part 9 §5.5.2.13 the + /// ConditionId SAO resolves to a non-null NodeId only for AlarmConditionType + /// (and subtype) instances; a plain BaseEvent / SystemEvent notification - + /// e.g. a Siemens Server-object "CPU not in RUN" system message delivered on + /// the same EventNotifier (i=2253) auto_alarms subscribes to - carries no + /// ConditionId and must not be auto-derived into a fault. Pure and static so + /// the system-message filter is unit-testable without a server. + static bool is_condition_event(const opcua::NodeId & condition_id); + private: void poll_loop(); void do_poll(); @@ -254,6 +278,13 @@ class OpcuaPoller { const opcua::NodeId & source_node, const opcua::NodeId & event_type, const opcua::NodeId & condition_id); + /// True when native alarm events (explicit ``event_alarms`` or + /// ``auto_alarms``) are configured at all - gates whether the poller + /// bothers subscribing / re-subscribing on (re)connect. + bool has_alarm_sources() const { + return !node_map_.event_alarms().empty() || node_map_.auto_alarms().enabled; + } + /// Run the configured active-condition replay (issue #389). Dispatches to /// ConditionRefresh and/or the read-based fallback per /// ``condition_replay_strategy``. @@ -297,10 +328,13 @@ class OpcuaPoller { /// Apply one condition state observation (from a live event or a read scan) /// to the tracked condition map + state machine, dispatching the resulting /// fault action. ``event_id`` is the live EventId for ack/confirm (null for - /// read-based observations). + /// read-based observations). ``require_confirm_for_clear`` overrides + /// ``config_.require_confirm_for_clear`` for this one observation - + /// auto-derived alarms pass ``!auto_alarms.auto_clear`` complemented + /// (see on_event) instead of the poller-wide setting. void apply_condition_state(const AlarmEventConfig & cfg, const opcua::NodeId & condition_id, const AlarmEventInput & input, uint16_t severity, const std::string & message, - const opcua::ByteString * event_id); + const opcua::ByteString * event_id, bool require_confirm_for_clear); OpcuaClient & client_; const NodeMap & node_map_; diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp index be52c219..0502baa6 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp @@ -284,7 +284,8 @@ bool NodeMap::load(const std::string & yaml_path) { }; // Warn on keys a block-style loader does not recognise so a future typo // (e.g. ``severty:``) is surfaced instead of silently dropped. Shared by - // the ``auto_browse:`` map form below and the ``event_alarms:`` loader. + // the ``auto_browse:`` map form below and the ``event_alarms:`` and + // ``auto_alarms:`` loaders. auto warn_unknown_keys = [logger](const YAML::Node & node, const std::string & context, std::initializer_list known) { if (!node.IsMap()) { @@ -800,6 +801,106 @@ bool NodeMap::load(const std::string & yaml_path) { } } + // Zero-config native A&C (completes #509 discovery -> #510 auto_browse -> + // native faults with no per-alarm event_alarms mapping). ``auto_alarms:`` + // accepts either a bare boolean or a map of overrides. + auto_alarms_ = AutoAlarmsConfig{}; + auto auto_alarms_node = root["auto_alarms"]; + if (auto_alarms_node && !auto_alarms_node.IsNull()) { + if (auto_alarms_node.IsScalar()) { + try { + auto_alarms_.enabled = auto_alarms_node.as(); + } catch (const YAML::Exception &) { + RCLCPP_WARN(logger, "auto_alarms: non-boolean scalar - ignoring (expected true/false or a map)"); + } + } else if (auto_alarms_node.IsMap()) { + auto_alarms_.enabled = parse_bool(auto_alarms_node["enabled"], true, "auto_alarms.enabled", "auto_alarms"); + if (auto_alarms_node["source_node_id"]) { + auto v = parse_string(auto_alarms_node["source_node_id"], "source_node_id", "auto_alarms"); + if (v && !v->empty()) { + auto_alarms_.source_node_id_str = *v; + } + } + if (auto_alarms_node["entity_id"]) { + auto v = parse_string(auto_alarms_node["entity_id"], "entity_id", "auto_alarms"); + if (v && !v->empty()) { + auto_alarms_.entity_id = *v; + } + } + auto_alarms_.auto_clear = + parse_bool(auto_alarms_node["auto_clear"], true, "auto_alarms.auto_clear", "auto_alarms"); + + if (auto_alarms_node["severity_bands"]) { + const auto & bands_node = auto_alarms_node["severity_bands"]; + if (!bands_node.IsMap()) { + RCLCPP_WARN(logger, "auto_alarms.severity_bands: expected a map - ignoring"); + } else { + auto read_band = [&](const char * key, uint16_t def) -> uint16_t { + const auto raw = parse_int64(bands_node[key], def, key, "auto_alarms.severity_bands"); + if (raw < 0 || raw > 1000) { + RCLCPP_WARN(logger, "auto_alarms.severity_bands.%s=%lld out of range (0..1000) - using default", key, + static_cast(raw)); + return def; + } + return static_cast(raw); + }; + auto_alarms_.severity_bands.critical_min = read_band("critical", auto_alarms_.severity_bands.critical_min); + auto_alarms_.severity_bands.error_min = read_band("error", auto_alarms_.severity_bands.error_min); + auto_alarms_.severity_bands.warning_min = read_band("warning", auto_alarms_.severity_bands.warning_min); + warn_unknown_keys(bands_node, "auto_alarms.severity_bands", {"critical", "error", "warning"}); + } + if (!(auto_alarms_.severity_bands.critical_min >= auto_alarms_.severity_bands.error_min && + auto_alarms_.severity_bands.error_min >= auto_alarms_.severity_bands.warning_min)) { + RCLCPP_WARN(logger, + "auto_alarms.severity_bands must satisfy critical >= error >= warning - " + "resetting to the default bands (801/501/201)"); + auto_alarms_.severity_bands = AutoAlarmsSeverityBands{}; + } + } + + auto read_pattern_list = [&](const char * key) -> std::vector { + std::vector out; + const auto & list_node = auto_alarms_node[key]; + if (list_node && list_node.IsSequence()) { + for (const auto & p : list_node) { + try { + out.push_back(p.as()); + } catch (const YAML::Exception &) { + RCLCPP_WARN(logger, "auto_alarms.%s: non-string entry - skipping", key); + } + } + } else if (list_node && !list_node.IsNull()) { + RCLCPP_WARN(logger, "auto_alarms.%s: expected a sequence of strings - ignoring", key); + } + return out; + }; + auto_alarms_.include_patterns = read_pattern_list("include"); + auto_alarms_.exclude_patterns = read_pattern_list("exclude"); + + warn_unknown_keys( + auto_alarms_node, "auto_alarms", + {"enabled", "source_node_id", "entity_id", "auto_clear", "severity_bands", "include", "exclude"}); + } else { + RCLCPP_WARN(logger, "auto_alarms: expected a boolean or a map - ignoring"); + } + } + if (auto_alarms_.entity_id.empty()) { + // Default to a "_alarms" App so a bare ``auto_alarms: + // true`` still has somewhere to host faults whose SourceNode does not + // match a known node-map entry. Deliberately NOT the bare + // ``component_id_``: the PLC runtime already registers a Component + // entity under that exact id, and a second (App) entity_defs_ entry + // with the same id collides in the SOVD merge pipeline ("ID collision: + // '' used by both Component and App") - reproduced against a real + // Siemens S7-1500 during development. + auto_alarms_.entity_id = component_id_ + "_alarms"; + } + auto_alarms_.source_node_id = parse_node_id(auto_alarms_.source_node_id_str); + if (auto_alarms_.enabled && auto_alarms_.source_node_id_str.empty()) { + RCLCPP_ERROR(logger, "auto_alarms.source_node_id is empty - refusing to load"); + return false; + } + // Schema validation under ``nodes:``: ``alarm_source`` belongs in the // top-level ``event_alarms:`` section, never under ``nodes:``. Silently // ignoring a misplaced ``alarm_source`` (the previous behavior unless @@ -899,14 +1000,16 @@ bool NodeMap::load(const std::string & yaml_path) { } } - // A config that declares neither a 'nodes:' section, any event alarms, nor - // auto_browse carries no mappings and is almost always a mistake (wrong - // file / typo). A present-but-empty 'nodes:' section is still valid, and - // an auto_browse-only file (zero-config discovery: the whole tree is - // filled by the live address-space walk) is valid too. - if (!has_nodes && event_alarms_.empty() && !auto_browse_config_.enabled) { - RCLCPP_ERROR(rclcpp::get_logger("opcua.node_map"), - "Node map has neither 'nodes:', 'event_alarms:' nor 'auto_browse:' entries - nothing to map"); + // A config that declares neither a 'nodes:' section, any event alarms, + // auto_browse, nor auto_alarms carries no mappings and is almost always a + // mistake (wrong file / typo). A present-but-empty 'nodes:' section is + // still valid, and an auto_browse-only file (zero-config discovery: the + // whole tree is filled by the live address-space walk) or an + // auto_alarms-only file is valid too. + if (!has_nodes && event_alarms_.empty() && !auto_browse_config_.enabled && !auto_alarms_.enabled) { + RCLCPP_ERROR( + rclcpp::get_logger("opcua.node_map"), + "Node map has neither 'nodes:', 'event_alarms:', 'auto_browse:' nor 'auto_alarms:' entries - nothing to map"); entity_defs_.clear(); return false; } @@ -978,6 +1081,110 @@ ResolvedAlarm NodeMap::resolve_alarm(const AlarmEventConfig & cfg, const std::st return out; } +namespace { + +// Uppercase-alnum slug with underscore separators, matching the convention +// every hand-written fault_code in this codebase already uses (e.g. +// PLC_OVERPRESSURE, PLC_COMMS_LOST). Collapses runs of non-alnum characters +// to a single '_' and trims leading/trailing '_' so "Tank.Overpressure!!" +// becomes "TANK_OVERPRESSURE" rather than "TANK_OVERPRESSURE__". +std::string slugify(const std::string & s) { + std::string out; + out.reserve(s.size()); + bool last_was_underscore = false; + for (unsigned char c : s) { + if (std::isalnum(c) != 0) { + out += static_cast(std::toupper(c)); + last_was_underscore = false; + } else if (!out.empty() && !last_was_underscore) { + out += '_'; + last_was_underscore = true; + } + } + while (!out.empty() && out.back() == '_') { + out.pop_back(); + } + return out; +} + +// FNV-1a 32-bit, rendered as 8 uppercase hex digits. Not cryptographic - +// only needs to be a stable, low-collision fallback identity for alarms that +// carry neither a ConditionName nor a SourceName. +std::string short_hash_hex(const std::string & s) { + uint32_t h = 2166136261u; + for (unsigned char c : s) { + h ^= c; + h *= 16777619u; + } + static constexpr char kHex[] = "0123456789ABCDEF"; + std::string out(8, '0'); + for (int i = 7; i >= 0; --i) { + out[static_cast(i)] = kHex[h & 0xF]; + h >>= 4; + } + return out; +} + +} // namespace + +std::string NodeMap::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) { + if (!condition_name.empty()) { + const std::string slug = slugify(condition_name); + if (!slug.empty()) { + return "PLC_ALARM_" + slug; + } + } + if (!source_name.empty()) { + const std::string slug = slugify(source_name); + if (!slug.empty()) { + return "PLC_ALARM_" + slug; + } + } + // Neither identity field is usable. A real Siemens S7-1500 reports every + // Program_Alarm of one FB through a single SourceNode (e.g. i=1845) with + // no ConditionName/SourceName - Message is the ONLY thing that tells "pa" + // and "pa2" apart - so it must be part of the fallback hash, not just + // SourceNode+EventType, or distinct alarms collapse onto one fault_code. + return "PLC_ALARM_" + short_hash_hex(source_node_str + "|" + event_type_str + "|" + message); +} + +bool NodeMap::auto_alarm_passes_filters(const std::string & condition_name, const std::string & source_name, + const std::string & message, const std::vector & include_patterns, + const std::vector & exclude_patterns) { + auto matches_any = [&](const std::vector & patterns) { + return std::any_of(patterns.begin(), patterns.end(), [&](const std::string & p) { + if (p.empty()) { + return false; + } + return (!condition_name.empty() && condition_name.find(p) != std::string::npos) || + (!source_name.empty() && source_name.find(p) != std::string::npos) || + (!message.empty() && message.find(p) != std::string::npos); + }); + }; + if (!exclude_patterns.empty() && matches_any(exclude_patterns)) { + return false; + } + if (!include_patterns.empty() && !matches_any(include_patterns)) { + return false; + } + return true; +} + +std::string NodeMap::map_auto_severity(uint16_t severity, const AutoAlarmsSeverityBands & bands) { + if (severity >= bands.critical_min) { + return "CRITICAL"; + } + if (severity >= bands.error_min) { + return "ERROR"; + } + if (severity >= bands.warning_min) { + return "WARNING"; + } + return "INFO"; +} + std::vector NodeMap::entries_for_entity(const std::string & entity_id) const { std::vector result; auto it = entity_index_.find(entity_id); @@ -1105,6 +1312,19 @@ void NodeMap::build_entity_defs() { def.has_faults = true; } + // Zero-config native A&C: register the fallback entity so discovery shows + // it as fault-bearing even before any alarm has fired. Auto-derived + // faults whose SourceNode matches a node-map entry instead surface on + // that entry's (already-registered) entity - this is only the fallback. + if (auto_alarms_.enabled) { + auto & def = defs[auto_alarms_.entity_id]; + if (def.id.empty()) { + def.id = auto_alarms_.entity_id; + def.component_id = component_id_; + } + def.has_faults = true; + } + // Build human-readable names from IDs for entries without an explicit // (auto_browse) DisplayName path. for (auto & [id, def] : defs) { diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp index 64081d23..a0807f83 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp @@ -63,10 +63,14 @@ constexpr size_t kFieldConfirmedState = 6; constexpr size_t kFieldShelvingState = 7; constexpr size_t kFieldBranchId = 8; constexpr size_t kFieldConditionName = 9; // issue #389 (multi-alarm identity) -constexpr size_t kAlarmFieldCount = 9; // count of fixed alarm-state fields (indices 0-8) -// ConditionName is always appended at index kFieldConditionName; configured -// associated values follow at index kFieldConditionName + 1 onward. -constexpr size_t kFirstAssociatedValueField = kFieldConditionName + 1; +// SourceName: BaseEventType human-readable event source, used as the tier-2 +// auto_alarms fault_code fallback when ConditionName is empty (zero-config +// native A&C - see NodeMap::derive_auto_fault_code). +constexpr size_t kFieldSourceName = 10; +constexpr size_t kAlarmFieldCount = 9; // count of fixed alarm-state fields (indices 0-8) +// ConditionName / SourceName are always appended at their fixed indices; +// configured associated values follow from kFieldSourceName + 1 onward. +constexpr size_t kFirstAssociatedValueField = kFieldSourceName + 1; // Standard NodeIds for the types that *directly* define each field (open62541 // servers reject SAOs whose BrowsePath is inherited rather than direct). @@ -94,6 +98,8 @@ std::vector build_alarm_event_select_specs(const Al // Issue #389: ConditionName, used to route distinct conditions from one // source to distinct faults. {opcua::NodeId(0, kConditionType), {{0, "ConditionName"}}, UA_ATTRIBUTEID_VALUE}, + // auto_alarms tier-2 fault_code fallback (see kFieldSourceName above). + {opcua::NodeId(0, kBaseEventType), {{0, "SourceName"}}, UA_ATTRIBUTEID_VALUE}, }; // Issue #389: configured associated values (e.g. Siemens SD_1..SD_n) as // BaseEventType properties. @@ -196,8 +202,9 @@ void OpcuaPoller::start(const PollerConfig & config) { } // Issue #386: subscribe to native AlarmConditionType events. Independent - // of data-change subscriptions; runs whenever event_alarms are configured. - if (!node_map_.event_alarms().empty()) { + // of data-change subscriptions; runs whenever event_alarms and/or + // auto_alarms are configured. + if (has_alarm_sources()) { setup_event_subscriptions(); } @@ -286,6 +293,38 @@ void OpcuaPoller::setup_subscriptions() { } } +std::vector +OpcuaPoller::effective_alarm_sources(const std::vector & explicit_sources, + const AutoAlarmsConfig & auto_cfg) { + std::vector sources = explicit_sources; + if (!auto_cfg.enabled) { + return sources; + } + const bool already_covered = std::any_of(sources.begin(), sources.end(), [&](const AlarmEventConfig & c) { + return c.source_node_id_str == auto_cfg.source_node_id_str; + }); + if (already_covered) { + // An explicit event_alarms entry already targets this source; on_event() + // falls through to auto-derivation for whatever that entry's own + // mappings/fault_code do not match, so a second monitored item on the + // same source would be redundant (and would double-fire). + return sources; + } + AlarmEventConfig synth; + synth.source_node_id_str = auto_cfg.source_node_id_str; + synth.source_node_id = auto_cfg.source_node_id; + synth.entity_id = auto_cfg.entity_id; + // fault_code/mappings intentionally left empty: NodeMap::resolve_alarm() + // then always reports this source unmatched, which routes every event on + // it through on_event()'s auto-derivation branch. + sources.push_back(std::move(synth)); + return sources; +} + +bool OpcuaPoller::is_condition_event(const opcua::NodeId & condition_id) { + return !condition_id.isNull(); +} + void OpcuaPoller::setup_event_subscriptions() { // Issue #386: one dedicated subscription for AlarmCondition events; uses // a default no-op data callback because we wire MIs of EVENTNOTIFIER @@ -301,7 +340,7 @@ void OpcuaPoller::setup_event_subscriptions() { event_monitored_item_ids_.clear(); - for (const auto & cfg : node_map_.event_alarms()) { + for (const auto & cfg : effective_alarm_sources(node_map_.event_alarms(), node_map_.auto_alarms())) { // Per-source select specs so each source can carry its own associated // values (issue #389) in addition to the fixed alarm-state fields. const auto select_specs = build_alarm_event_select_specs(cfg); @@ -453,7 +492,8 @@ void OpcuaPoller::read_fallback_replay() { // before this path can be relied on there (use ConditionRefresh on Siemens). std::set seen; std::set failed_sources; - for (const auto & cfg : node_map_.event_alarms()) { + const auto & auto_cfg = node_map_.auto_alarms(); + for (const auto & cfg : effective_alarm_sources(node_map_.event_alarms(), node_map_.auto_alarms())) { bool scan_ok = false; auto conditions = client_.read_source_conditions(cfg.source_node_id, &scan_ok); if (!scan_ok) { @@ -523,15 +563,49 @@ void OpcuaPoller::read_fallback_replay() { // by condition_name / source_node / message still works. ResolvedAlarm resolved = NodeMap::resolve_alarm(cfg, snap.condition_name, cfg.source_node_id_str, /*event_type=*/"", snap.message); - if (!resolved.matched) { - continue; - } AlarmEventConfig eff = cfg; - eff.fault_code = resolved.fault_code; - eff.severity_override = resolved.severity_override; - eff.message_override = resolved.message_override; + bool require_confirm = config_.require_confirm_for_clear; + if (resolved.matched) { + eff.fault_code = resolved.fault_code; + eff.severity_override = resolved.severity_override; + eff.message_override = resolved.message_override; + } else { + // auto_alarms fallback (zero-config native A&C): only applies when + // this source is the one auto_alarms covers (see + // effective_alarm_sources()). A read-scan snapshot has no + // SourceName/SourceNode-per-condition of its own (it is implicitly + // the source we just browsed), so the derivation uses cfg's source id. + if (!auto_cfg.enabled || cfg.source_node_id_str != auto_cfg.source_node_id_str) { + continue; + } + if (!NodeMap::auto_alarm_passes_filters(snap.condition_name, /*source_name=*/"", snap.message, + auto_cfg.include_patterns, auto_cfg.exclude_patterns)) { + continue; + } + eff.fault_code = NodeMap::derive_auto_fault_code(snap.condition_name, /*source_name=*/"", + cfg.source_node_id_str, /*event_type_str=*/"", snap.message); + const auto * known_entry = node_map_.find_by_node_id(cfg.source_node_id_str); + eff.entity_id = known_entry != nullptr ? known_entry->entity_id : auto_cfg.entity_id; + eff.severity_override = NodeMap::map_auto_severity(snap.severity, auto_cfg.severity_bands); + eff.message_override.clear(); + if (auto_cfg.auto_clear) { + // AlarmStateMachine's clear rule is `acked && (confirmed || + // !require_confirm_for_clear)` - require_confirm_for_clear=false + // alone only drops the CONFIRMED half; ACKED is still required, and + // a zero-config alarm has no operator (or SOVD acknowledge_fault + // operation - that is only auto-registered for entities with an + // event_alarms entry) to ever set it. Without also forcing acked + // here, auto_clear alarms would latch in HEALED forever, defeating + // the "just works" zero-config promise. Force both gates open. + input.acked_state = true; + require_confirm = false; + } else { + require_confirm = config_.require_confirm_for_clear; + } + } - apply_condition_state(eff, snap.condition_id, input, snap.severity, snap.message, /*event_id=*/nullptr); + apply_condition_state(eff, snap.condition_id, input, snap.severity, snap.message, /*event_id=*/nullptr, + require_confirm); } } reconcile_after_read(seen, failed_sources, read_modeled_sources_); @@ -720,13 +794,79 @@ void OpcuaPoller::on_event(const AlarmEventConfig & cfg, const std::vector kFieldConditionName) { condition_name = variant_to_string_scalar(values[kFieldConditionName]); } + std::string source_name; + if (values.size() > kFieldSourceName) { + source_name = variant_to_string_scalar(values[kFieldSourceName]); + } ResolvedAlarm resolved = NodeMap::resolve_alarm(cfg, condition_name, source_node.toString(), event_type.toString(), message); - if (!resolved.matched) { - // No mapping and no source-level fault_code applies to this condition. - RCLCPP_DEBUG_STREAM(opcua_poller_logger(), - "on_event: no fault mapping for condition_name='" << condition_name << "' - ignoring"); - return; + + AlarmEventConfig eff = cfg; + bool require_confirm = config_.require_confirm_for_clear; + const auto & auto_cfg = node_map_.auto_alarms(); + if (resolved.matched) { + // Build the effective config for this specific event (resolved + // fault_code + overrides) so apply_condition_state tracks the right + // fault_code / entity. Explicit event_alarms mappings always win over + // auto_alarms (precedence, see effective_alarm_sources()). + eff.fault_code = resolved.fault_code; + eff.severity_override = resolved.severity_override; + eff.message_override = resolved.message_override; + } else { + // Zero-config native A&C (auto_alarms). Only covers events on the + // source auto_alarms actually subscribed (either this cfg's own source, + // when explicit event_alarms shares it, or the synthetic source + // effective_alarm_sources() adds); an unmatched event on any other + // explicit event_alarms source is ignored exactly as before. + if (!auto_cfg.enabled || cfg.source_node_id_str != auto_cfg.source_node_id_str) { + RCLCPP_DEBUG_STREAM(opcua_poller_logger(), + "on_event: no fault mapping for condition_name='" << condition_name << "' - ignoring"); + return; + } + // System-message filter (validated on a real Siemens S7-1500): the + // Server object's EventNotifier (i=2253) also emits plain BaseEvent / + // SystemEvent notifications (e.g. "CPU not in RUN") that are NOT + // AlarmConditionType instances. Per Part 9 §5.5.2.13 only a real + // Condition carries a ConditionId; a system message resolves it to + // NodeId.Null, so reject those here rather than auto-deriving a + // fault for a message that never raises/clears. + if (!is_condition_event(condition_id)) { + RCLCPP_DEBUG_STREAM(opcua_poller_logger(), + "on_event: dropping non-condition event (null ConditionId, likely a system " + "message) message='" + << message << "'"); + return; + } + if (!NodeMap::auto_alarm_passes_filters(condition_name, source_name, message, auto_cfg.include_patterns, + auto_cfg.exclude_patterns)) { + return; + } + eff.fault_code = NodeMap::derive_auto_fault_code(condition_name, source_name, source_node.toString(), + event_type.toString(), message); + // Host the fault on a known node-map entity when SourceNode resolves to + // one; otherwise fall back to auto_alarms.entity_id (default: the PLC + // root component). + const auto * known_entry = node_map_.find_by_node_id(source_node.toString()); + eff.entity_id = known_entry != nullptr ? known_entry->entity_id : auto_cfg.entity_id; + eff.severity_override = NodeMap::map_auto_severity(severity, auto_cfg.severity_bands); + eff.message_override.clear(); // description = the raw event Message, verbatim + if (auto_cfg.auto_clear) { + // AlarmStateMachine's clear rule is `acked && (confirmed || + // !require_confirm_for_clear)` - require_confirm_for_clear=false alone + // only drops the CONFIRMED half; ACKED is still required, and a + // zero-config alarm has no operator (or SOVD acknowledge_fault + // operation - that is only auto-registered for entities with an + // event_alarms entry) to ever set it. Without also forcing acked here, + // auto_clear alarms would latch in HEALED forever, defeating the "just + // works" zero-config promise (found via real-HW E2E: a Siemens + // Program_Alarm going inactive stayed latched until this fix). Force + // both gates open. + input.acked_state = true; + require_confirm = false; + } else { + // Explicit event_alarms keep the poller-wide setting. + require_confirm = config_.require_confirm_for_clear; + } } // Append configured associated values (e.g. SD_1..SD_n) to the description. @@ -745,13 +885,6 @@ void OpcuaPoller::on_event(const AlarmEventConfig & cfg, const std::vector same code. + EXPECT_EQ(code, NodeMap::derive_auto_fault_code("", "", "ns=3;i=1845", "ns=0;i=2915", "pa")); +} + +TEST(DeriveAutoFaultCodeTest, SharedSourceNodeDisambiguatedByMessage) { + // Real Siemens S7-1500 quirk: every Program_Alarm of one FB shares a single + // SourceNode (e.g. i=1845) with no ConditionName/SourceName, differing + // only by Message ("pa" vs "pa2"). Without folding Message into the hash + // fallback, both would collapse onto the same fault_code. + const std::string pa = NodeMap::derive_auto_fault_code("", "", "ns=3;i=1845", "ns=0;i=2915", "pa"); + const std::string pa2 = NodeMap::derive_auto_fault_code("", "", "ns=3;i=1845", "ns=0;i=2915", "pa2"); + EXPECT_NE(pa, pa2); +} + +TEST(DeriveAutoFaultCodeTest, EmptyConditionNameSlugFallsThroughToSourceName) { + // A ConditionName that slugifies to nothing (pure punctuation) must not + // stick with an empty-but-nonempty-input tier - it falls through exactly + // like an absent ConditionName. + EXPECT_EQ(NodeMap::derive_auto_fault_code("!!!", "PumpA", "ns=2;s=PumpA", "ns=0;i=2915", ""), "PLC_ALARM_PUMPA"); +} + +TEST(AutoAlarmPassesFiltersTest, NoPatternsAdmitsEverything) { + EXPECT_TRUE(NodeMap::auto_alarm_passes_filters("Overpressure", "Tank", "msg", {}, {})); +} + +TEST(AutoAlarmPassesFiltersTest, ExcludeDropsMatchingSystemMessage) { + EXPECT_FALSE(NodeMap::auto_alarm_passes_filters("", "Server", "CPU not in RUN", {}, {"CPU not in RUN"})); +} + +TEST(AutoAlarmPassesFiltersTest, ExcludeWinsOverInclude) { + EXPECT_FALSE(NodeMap::auto_alarm_passes_filters("Overpressure", "", "msg", {"Overpressure"}, {"Overpressure"})); +} + +TEST(AutoAlarmPassesFiltersTest, NonEmptyIncludeRequiresAMatch) { + EXPECT_FALSE(NodeMap::auto_alarm_passes_filters("Underpressure", "", "msg", {"Overpressure"}, {})); + EXPECT_TRUE(NodeMap::auto_alarm_passes_filters("Overpressure", "", "msg", {"Overpressure"}, {})); +} + +TEST(AutoAlarmPassesFiltersTest, IncludeMatchesAnyOfConditionNameSourceNameMessage) { + EXPECT_TRUE(NodeMap::auto_alarm_passes_filters("", "", "contains PumpA text", {"PumpA"}, {})); +} + +TEST(MapAutoSeverityTest, DefaultBandsMatchOpcuaPluginConvention) { + AutoAlarmsSeverityBands bands; + EXPECT_EQ(NodeMap::map_auto_severity(1000, bands), "CRITICAL"); + EXPECT_EQ(NodeMap::map_auto_severity(801, bands), "CRITICAL"); + EXPECT_EQ(NodeMap::map_auto_severity(800, bands), "ERROR"); + EXPECT_EQ(NodeMap::map_auto_severity(501, bands), "ERROR"); + EXPECT_EQ(NodeMap::map_auto_severity(500, bands), "WARNING"); + EXPECT_EQ(NodeMap::map_auto_severity(201, bands), "WARNING"); + EXPECT_EQ(NodeMap::map_auto_severity(200, bands), "INFO"); + EXPECT_EQ(NodeMap::map_auto_severity(1, bands), "INFO"); +} + +TEST(MapAutoSeverityTest, CustomBandsOverrideThresholds) { + AutoAlarmsSeverityBands bands; + bands.critical_min = 900; + bands.error_min = 700; + bands.warning_min = 400; + EXPECT_EQ(NodeMap::map_auto_severity(850, bands), "ERROR"); // below the raised critical bar + EXPECT_EQ(NodeMap::map_auto_severity(900, bands), "CRITICAL"); + EXPECT_EQ(NodeMap::map_auto_severity(399, bands), "INFO"); +} + +TEST_F(NodeMapTest, AutoAlarmsDefaultsOff) { + NodeMap map; + ASSERT_TRUE(map.load(yaml_path_)); + EXPECT_FALSE(map.auto_alarms().enabled); +} + +TEST_F(NodeMapTest, AutoAlarmsBareBooleanEnablesWithDefaults) { + std::string path = "/tmp/test_node_map_auto_alarms_bool.yaml"; + std::ofstream f(path); + f << R"( +area_id: test +component_id: plc_runtime +nodes: + - node_id: "ns=1;s=TankLevel" + entity_id: tank_process + data_name: tank_level +auto_alarms: true +)"; + f.close(); + + NodeMap map; + ASSERT_TRUE(map.load(path)); + const auto & cfg = map.auto_alarms(); + EXPECT_TRUE(cfg.enabled); + EXPECT_EQ(cfg.source_node_id_str, "i=2253"); + // Defaults to "_alarms" when not overridden - NOT the bare + // component_id, which already names the PLC runtime's own Component entity + // and would collide with it in the SOVD merge pipeline (reproduced against + // a real Siemens S7-1500 during development). + EXPECT_EQ(cfg.entity_id, "plc_runtime_alarms"); + EXPECT_TRUE(cfg.auto_clear); + EXPECT_EQ(cfg.severity_bands.critical_min, 801); + EXPECT_EQ(cfg.severity_bands.error_min, 501); + EXPECT_EQ(cfg.severity_bands.warning_min, 201); + EXPECT_TRUE(cfg.include_patterns.empty()); + EXPECT_TRUE(cfg.exclude_patterns.empty()); + // A file with only auto_alarms (no event_alarms) still gets its fallback + // entity registered as fault-bearing for SOVD discovery. + bool found = false; + for (const auto & def : map.entity_defs()) { + if (def.id == "plc_runtime_alarms") { + found = true; + EXPECT_TRUE(def.has_faults); + } + } + EXPECT_TRUE(found); +} + +TEST_F(NodeMapTest, AutoAlarmsOnlyNoNodesOrEventAlarmsIsValid) { + // A config that declares ONLY auto_alarms (the true zero-config case) must + // load successfully - the emptiness guard must not reject it. + std::string path = "/tmp/test_node_map_auto_alarms_only.yaml"; + std::ofstream f(path); + f << R"( +area_id: test +component_id: plc_runtime +auto_alarms: true +)"; + f.close(); + + NodeMap map; + EXPECT_TRUE(map.load(path)); +} + +TEST_F(NodeMapTest, AutoAlarmsMapFormOverridesEverything) { + std::string path = "/tmp/test_node_map_auto_alarms_map.yaml"; + std::ofstream f(path); + f << R"( +area_id: test +component_id: plc_runtime +auto_alarms: + enabled: true + source_node_id: "ns=3;i=1000" + entity_id: alarms_catchall + auto_clear: false + severity_bands: + critical: 900 + error: 700 + warning: 400 + include: ["Program_Alarm"] + exclude: ["CPU not in RUN"] +)"; + f.close(); + + NodeMap map; + ASSERT_TRUE(map.load(path)); + const auto & cfg = map.auto_alarms(); + EXPECT_TRUE(cfg.enabled); + EXPECT_EQ(cfg.source_node_id_str, "ns=3;i=1000"); + EXPECT_EQ(cfg.entity_id, "alarms_catchall"); + EXPECT_FALSE(cfg.auto_clear); + EXPECT_EQ(cfg.severity_bands.critical_min, 900); + EXPECT_EQ(cfg.severity_bands.error_min, 700); + EXPECT_EQ(cfg.severity_bands.warning_min, 400); + ASSERT_EQ(cfg.include_patterns.size(), 1u); + EXPECT_EQ(cfg.include_patterns[0], "Program_Alarm"); + ASSERT_EQ(cfg.exclude_patterns.size(), 1u); + EXPECT_EQ(cfg.exclude_patterns[0], "CPU not in RUN"); +} + +TEST_F(NodeMapTest, AutoAlarmsEnabledDefaultsTrueInMapForm) { + // ``enabled:`` may be omitted inside the map form - presence of the map + // itself implies intent to turn it on (matches the bare-boolean form). + std::string path = "/tmp/test_node_map_auto_alarms_map_no_enabled.yaml"; + std::ofstream f(path); + f << R"( +area_id: test +component_id: plc_runtime +auto_alarms: + entity_id: alarms_catchall +)"; + f.close(); + + NodeMap map; + ASSERT_TRUE(map.load(path)); + EXPECT_TRUE(map.auto_alarms().enabled); +} + +TEST_F(NodeMapTest, AutoAlarmsInvalidSeverityBandOrderResetsToDefaults) { + std::string path = "/tmp/test_node_map_auto_alarms_bad_bands.yaml"; + std::ofstream f(path); + f << R"( +area_id: test +component_id: plc_runtime +auto_alarms: + severity_bands: + critical: 300 + error: 500 + warning: 700 +)"; + f.close(); + + NodeMap map; + ASSERT_TRUE(map.load(path)); + const auto & bands = map.auto_alarms().severity_bands; + EXPECT_EQ(bands.critical_min, 801); + EXPECT_EQ(bands.error_min, 501); + EXPECT_EQ(bands.warning_min, 201); +} + +TEST_F(NodeMapTest, AutoAlarmsFalseStaysDisabled) { + std::string path = "/tmp/test_node_map_auto_alarms_false.yaml"; + std::ofstream f(path); + f << R"( +area_id: test +component_id: plc_runtime +nodes: + - node_id: "ns=1;s=TankLevel" + entity_id: tank_process + data_name: tank_level +auto_alarms: false +)"; + f.close(); + + NodeMap map; + ASSERT_TRUE(map.load(path)); + EXPECT_FALSE(map.auto_alarms().enabled); +} + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_poller.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_poller.cpp new file mode 100644 index 00000000..06d8da36 --- /dev/null +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_poller.cpp @@ -0,0 +1,117 @@ +// Copyright 2026 mfaferek93 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Zero-config native A&C (auto_alarms): pure-function coverage of the +// subscription-source precedence rule and the system-message filter, both +// exercised without a live OPC-UA server (injected fake AlarmEventConfig / +// AutoAlarmsConfig / NodeId values). + +#include "ros2_medkit_opcua/opcua_poller.hpp" + +#include + +#include + +namespace ros2_medkit_gateway { + +TEST(EffectiveAlarmSourcesTest, AutoDisabledReturnsExplicitSourcesUnchanged) { + std::vector explicit_sources(1); + explicit_sources[0].source_node_id_str = "ns=2;s=Alarms.A"; + AutoAlarmsConfig auto_cfg; // enabled = false by default + auto result = OpcuaPoller::effective_alarm_sources(explicit_sources, auto_cfg); + ASSERT_EQ(result.size(), 1u); + EXPECT_EQ(result[0].source_node_id_str, "ns=2;s=Alarms.A"); +} + +TEST(EffectiveAlarmSourcesTest, AutoEnabledAppendsSyntheticSourceWhenNotCovered) { + std::vector explicit_sources(1); + explicit_sources[0].source_node_id_str = "ns=2;s=Alarms.A"; + explicit_sources[0].entity_id = "line"; + explicit_sources[0].fault_code = "PLC_LINE_JAM"; + + AutoAlarmsConfig auto_cfg; + auto_cfg.enabled = true; + auto_cfg.source_node_id_str = "i=2253"; + auto_cfg.entity_id = "plc_runtime"; + + auto result = OpcuaPoller::effective_alarm_sources(explicit_sources, auto_cfg); + ASSERT_EQ(result.size(), 2u); + EXPECT_EQ(result[0].source_node_id_str, "ns=2;s=Alarms.A"); + EXPECT_EQ(result[1].source_node_id_str, "i=2253"); + // The synthetic entry carries no fault_code/mappings, so + // NodeMap::resolve_alarm() always reports it unmatched - that is exactly + // what routes every event on this source through auto-derivation. + EXPECT_TRUE(result[1].fault_code.empty()); + EXPECT_TRUE(result[1].mappings.empty()); + EXPECT_EQ(result[1].entity_id, "plc_runtime"); +} + +TEST(EffectiveAlarmSourcesTest, NoAutoSourceAddedWhenAutoDisabledEvenIfSourceWouldCollide) { + std::vector explicit_sources(1); + explicit_sources[0].source_node_id_str = "i=2253"; + AutoAlarmsConfig auto_cfg; + auto_cfg.enabled = false; + auto_cfg.source_node_id_str = "i=2253"; + auto result = OpcuaPoller::effective_alarm_sources(explicit_sources, auto_cfg); + EXPECT_EQ(result.size(), 1u); +} + +TEST(EffectiveAlarmSourcesTest, PrecedenceNoDuplicateWhenExplicitSourceAlreadyCoversAutoSource) { + // An explicit event_alarms entry on the SAME source auto_alarms would + // subscribe to must not get a second, redundant monitored item - on_event + // falls through to auto-derivation for whatever that entry's own + // mappings/fault_code do not match instead (explicit still wins for + // matched alarms - the precedence rule). + std::vector explicit_sources(1); + explicit_sources[0].source_node_id_str = "i=2253"; + explicit_sources[0].entity_id = "line"; + explicit_sources[0].fault_code = "PLC_GENERIC_ALARM"; + + AutoAlarmsConfig auto_cfg; + auto_cfg.enabled = true; + auto_cfg.source_node_id_str = "i=2253"; + auto_cfg.entity_id = "plc_runtime"; + + auto result = OpcuaPoller::effective_alarm_sources(explicit_sources, auto_cfg); + ASSERT_EQ(result.size(), 1u); + EXPECT_EQ(result[0].entity_id, "line"); + EXPECT_EQ(result[0].fault_code, "PLC_GENERIC_ALARM"); +} + +TEST(EffectiveAlarmSourcesTest, MultipleExplicitSourcesPreservedAlongsideSynthetic) { + std::vector explicit_sources(2); + explicit_sources[0].source_node_id_str = "ns=2;s=Alarms.A"; + explicit_sources[1].source_node_id_str = "ns=2;s=Alarms.B"; + + AutoAlarmsConfig auto_cfg; + auto_cfg.enabled = true; + auto_cfg.source_node_id_str = "i=2253"; + + auto result = OpcuaPoller::effective_alarm_sources(explicit_sources, auto_cfg); + ASSERT_EQ(result.size(), 3u); + EXPECT_EQ(result[2].source_node_id_str, "i=2253"); +} + +TEST(IsConditionEventTest, NullConditionIdIsRejected) { + // Part 9 §5.5.2.13: a non-condition event (e.g. a Siemens Server-object + // system message such as "CPU not in RUN") resolves the ConditionId SAO + // to NodeId.Null - the system-message filter for auto_alarms. + EXPECT_FALSE(OpcuaPoller::is_condition_event(opcua::NodeId())); +} + +TEST(IsConditionEventTest, RealConditionIdIsAccepted) { + EXPECT_TRUE(OpcuaPoller::is_condition_event(opcua::NodeId(3, static_cast(1845)))); +} + +} // namespace ros2_medkit_gateway From dbe21d2c36c5ae1543829ab6ee477853630c0a84 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Thu, 9 Jul 2026 21:48:24 +0200 Subject: [PATCH 2/5] docs(opcua): fix auto_alarms entity_id / require_confirm_for_clear comments - AutoAlarmsConfig::entity_id doc (node_map.hpp) and the poller comment describing its fallback (opcua_poller.cpp) both said the default was the node map's bare component_id / PLC root component; NodeMap::load() actually defaults it to "_alarms" to avoid an entity ID collision with the Component entity. Comments now match the code. - apply_condition_state()'s require_confirm_for_clear doc (opcua_poller.hpp) described the auto-derived-alarm caller as passing '!auto_clear complemented'; the actual on_event()/read_fallback_replay() logic forces false (plus acked_state=true) when auto_clear is true, and otherwise passes the poller-wide config_.require_confirm_for_clear unchanged - not a value derived from auto_clear at all. - README: auto_clear: false does not gate clearing through an event_alarms-mapping-provided acknowledge_fault operation (auto-derived alarms never get one); clearing is gated by the OPC-UA server's own Acked/Confirmed condition state, set by an HMI/SCADA system or another OPC-UA client. - clang-format-18 pass on touched files. --- src/ros2_medkit_plugins/ros2_medkit_opcua/README.md | 7 +++++-- .../include/ros2_medkit_opcua/node_map.hpp | 11 +++++++---- .../include/ros2_medkit_opcua/opcua_poller.hpp | 8 +++++--- .../ros2_medkit_opcua/src/opcua_poller.cpp | 5 +++-- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md b/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md index f7316ac1..8632d34a 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md @@ -482,8 +482,11 @@ on `i=2253`) no duplicate subscription is created - unmatched events on an 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 -Acknowledge (via the `event_alarms` mapping path) before it clears. +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 diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp index 24d6b917..a5494a54 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp @@ -149,10 +149,13 @@ struct AutoAlarmsConfig { 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 the node map's ``component_id`` at - /// load time (see ``NodeMap::load``). 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). + /// a known node-map entry. Defaults to ``"_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 diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp index 912a30de..f4d0e7f4 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp @@ -329,9 +329,11 @@ class OpcuaPoller { /// to the tracked condition map + state machine, dispatching the resulting /// fault action. ``event_id`` is the live EventId for ack/confirm (null for /// read-based observations). ``require_confirm_for_clear`` overrides - /// ``config_.require_confirm_for_clear`` for this one observation - - /// auto-derived alarms pass ``!auto_alarms.auto_clear`` complemented - /// (see on_event) instead of the poller-wide setting. + /// ``config_.require_confirm_for_clear`` for this one observation - for an + /// auto-derived alarm (see on_event), the caller passes ``false`` when + /// ``auto_alarms.auto_clear`` is true (both Acked and Confirmed forced + /// open so a zero-config alarm can clear without an operator), otherwise + /// it passes the poller-wide ``config_.require_confirm_for_clear`` as-is. void apply_condition_state(const AlarmEventConfig & cfg, const opcua::NodeId & condition_id, const AlarmEventInput & input, uint16_t severity, const std::string & message, const opcua::ByteString * event_id, bool require_confirm_for_clear); diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp index a0807f83..0c483c64 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp @@ -844,8 +844,9 @@ void OpcuaPoller::on_event(const AlarmEventConfig & cfg, const std::vector_alarms" - a separate App, not the PLC root Component; + // see AutoAlarmsConfig::entity_id). const auto * known_entry = node_map_.find_by_node_id(source_node.toString()); eff.entity_id = known_entry != nullptr ? known_entry->entity_id : auto_cfg.entity_id; eff.severity_override = NodeMap::map_auto_severity(severity, auto_cfg.severity_bands); From c6c0f5aefcc2c12770223c36ab5221e5ed27f6f1 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Fri, 10 Jul 2026 20:26:04 +0200 Subject: [PATCH 3/5] feat(opcua): expose auto_alarms on the plugins.opcua param surface Enables zero-config native A&C with no node_map file: endpoint (from discovery) + auto_alarms: true is enough. The JSON/ROS param overlays the node-map YAML auto_alarms config block (param wins, mirroring auto_browse); explicit event_alarms mappings still win over auto-derivation at the poller. finalize_auto_alarms_overlay re-derives the default entity + parsed source NodeId and rebuilds entity_defs so the alarms App is discoverable with no map. --- .../ros2_medkit_opcua/README.md | 21 ++- .../include/ros2_medkit_opcua/node_map.hpp | 23 ++- .../ros2_medkit_opcua/opcua_plugin.hpp | 13 ++ .../ros2_medkit_opcua/src/node_map.cpp | 23 +++ .../ros2_medkit_opcua/src/opcua_plugin.cpp | 141 +++++++++++++++++ .../ros2_medkit_opcua/test/test_node_map.cpp | 57 +++++++ .../test/test_opcua_plugin.cpp | 142 ++++++++++++++++++ 7 files changed, 418 insertions(+), 2 deletions(-) diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md b/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md index 8632d34a..9e81a18a 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md @@ -449,6 +449,23 @@ auto_alarms: 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_` from `ConditionName` when present, else from `SourceName`, else `PLC_ALARM_` of SourceNode + EventType + Message. @@ -499,13 +516,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` | diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp index a5494a54..ab2ec756 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp @@ -323,11 +323,32 @@ class NodeMap { 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:``. + /// 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) - the OPC-UA condition identity, when present. diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp index 351c869f..a43a9f72 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp @@ -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 & warn); + private: // Route handlers void handle_plc_data(const ros2_medkit_gateway::PluginRequest & req, ros2_medkit_gateway::PluginResponse & res); diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp index 0502baa6..ec5be995 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp @@ -1274,6 +1274,29 @@ std::vector NodeMap::detection_entries() const { return result; } +bool NodeMap::finalize_auto_alarms_overlay() { + // Mirror the tail of load() (see the ``auto_alarms:`` block there) so a + // config overlaid purely from the plugin param - with no node-map YAML ever + // parsed - ends up in the same derived state a loaded map would: a default + // fallback entity, a parsed source NodeId, and an entity_defs entry that + // makes the alarms App discoverable over SOVD. + if (auto_alarms_.entity_id.empty()) { + // Default to "_alarms" (not the bare component_id, which the + // PLC runtime already registers as a Component and would collide with in + // the SOVD merge pipeline - see load()). + auto_alarms_.entity_id = component_id_ + "_alarms"; + } + auto_alarms_.source_node_id = parse_node_id(auto_alarms_.source_node_id_str); + if (auto_alarms_.enabled && auto_alarms_.source_node_id_str.empty()) { + RCLCPP_ERROR(rclcpp::get_logger("opcua.node_map"), "auto_alarms.source_node_id is empty - disabling auto_alarms"); + auto_alarms_.enabled = false; + build_entity_defs(); + return false; + } + build_entity_defs(); + return true; +} + void NodeMap::build_entity_defs() { entity_defs_.clear(); diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp index 39d943a9..1e082721 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp @@ -529,6 +529,30 @@ void OpcuaPlugin::configure(const nlohmann::json & config) { log_warn("plugins.opcua.auto_browse must be a boolean or an object - ignoring"); } } + + // plugins.opcua.auto_alarms (ROS param / JSON config). Overlays on top of + // whatever the node-map YAML's ``auto_alarms:`` block set (or the default + // AutoAlarmsConfig when there is no node map at all) - the same "JSON/env + // wins over file" precedence the rest of this function uses. This is what + // makes zero-config native A&C possible with no node_map_path: just an + // ``endpoint_url`` (from discovery) + ``auto_alarms: true``. Explicit + // ``event_alarms:`` mappings still take precedence over auto-derivation at + // the poller (see OpcuaPoller::effective_alarm_sources) regardless. + if (config.contains("auto_alarms")) { + apply_auto_alarms_param(config["auto_alarms"], node_map_.mutable_auto_alarms(), [this](const std::string & msg) { + log_warn(msg); + }); + // Re-derive the fields load() fills in (default entity, parsed source + // NodeId) and rebuild entity_defs so a param-only deployment still + // surfaces the alarms App over SOVD. Non-fatal on an invalid overlay: + // finalize_auto_alarms_overlay disables auto_alarms and logs rather than + // taking the whole plugin down over one bad param. + node_map_.finalize_auto_alarms_overlay(); + if (node_map_.auto_alarms().enabled) { + log_info("auto_alarms enabled via plugins.opcua.auto_alarms param (source " + + node_map_.auto_alarms().source_node_id_str + ", entity " + node_map_.auto_alarms().entity_id + ")"); + } + } } void OpcuaPlugin::set_context(PluginContext & context) { @@ -960,6 +984,123 @@ std::string OpcuaPlugin::map_severity(uint16_t live_severity, const std::string return std::string("INFO"); } +void OpcuaPlugin::apply_auto_alarms_param(const nlohmann::json & value, AutoAlarmsConfig & cfg, + const std::function & warn) { + // Field-for-field mirror of node_map.cpp's YAML ``auto_alarms:`` loader, + // reading nlohmann::json instead of YAML::Node. Only keys actually present + // overwrite ``cfg`` so a partial param overlays cleanly on top of whatever + // the node-map YAML declared (the JSON param wins on collision). + if (value.is_boolean()) { + cfg.enabled = value.get(); + return; + } + if (!value.is_object()) { + warn("plugins.opcua.auto_alarms must be a boolean or an object - ignoring"); + return; + } + + // Presence of the map itself implies intent to enable (matches the bare + // boolean and the YAML map form); ``enabled: false`` still turns it off. + cfg.enabled = value.value("enabled", true); + + if (value.contains("source_node_id")) { + if (value["source_node_id"].is_string() && !value["source_node_id"].get().empty()) { + cfg.source_node_id_str = value["source_node_id"].get(); + } else { + warn("plugins.opcua.auto_alarms.source_node_id must be a non-empty string - keeping current value"); + } + } + if (value.contains("entity_id")) { + if (value["entity_id"].is_string() && !value["entity_id"].get().empty()) { + cfg.entity_id = value["entity_id"].get(); + } else { + warn("plugins.opcua.auto_alarms.entity_id must be a non-empty string - keeping current value"); + } + } + if (value.contains("auto_clear")) { + if (value["auto_clear"].is_boolean()) { + cfg.auto_clear = value["auto_clear"].get(); + } else { + warn("plugins.opcua.auto_alarms.auto_clear must be a boolean - keeping current value"); + } + } + + if (value.contains("severity_bands")) { + const auto & bands = value["severity_bands"]; + if (!bands.is_object()) { + warn("plugins.opcua.auto_alarms.severity_bands must be an object - ignoring"); + } else { + auto read_band = [&](const char * key, uint16_t def) -> uint16_t { + if (!bands.contains(key)) { + return def; + } + if (!bands[key].is_number_integer()) { + warn(std::string("plugins.opcua.auto_alarms.severity_bands.") + key + " must be an integer - using default"); + return def; + } + const auto raw = bands[key].get(); + if (raw < 0 || raw > 1000) { + warn(std::string("plugins.opcua.auto_alarms.severity_bands.") + key + + " out of range (0..1000) - using default"); + return def; + } + return static_cast(raw); + }; + cfg.severity_bands.critical_min = read_band("critical", cfg.severity_bands.critical_min); + cfg.severity_bands.error_min = read_band("error", cfg.severity_bands.error_min); + cfg.severity_bands.warning_min = read_band("warning", cfg.severity_bands.warning_min); + for (const auto & [key, band_val] : bands.items()) { + (void)band_val; + if (key != "critical" && key != "error" && key != "warning") { + warn("plugins.opcua.auto_alarms.severity_bands: unknown key '" + key + "' - ignored"); + } + } + if (!(cfg.severity_bands.critical_min >= cfg.severity_bands.error_min && + cfg.severity_bands.error_min >= cfg.severity_bands.warning_min)) { + warn( + "plugins.opcua.auto_alarms.severity_bands must satisfy critical >= error >= warning - " + "resetting to the default bands (801/501/201)"); + cfg.severity_bands = AutoAlarmsSeverityBands{}; + } + } + } + + auto read_pattern_list = [&](const char * key) -> std::vector { + std::vector out; + if (!value.contains(key)) { + return out; + } + if (!value[key].is_array()) { + warn(std::string("plugins.opcua.auto_alarms.") + key + " must be an array of strings - ignoring"); + return out; + } + for (const auto & p : value[key]) { + if (p.is_string()) { + out.push_back(p.get()); + } else { + warn(std::string("plugins.opcua.auto_alarms.") + key + ": non-string entry - skipping"); + } + } + return out; + }; + // Only replace the pattern lists when the param actually carries them, so a + // param that omits include/exclude does not wipe a YAML-declared list. + if (value.contains("include")) { + cfg.include_patterns = read_pattern_list("include"); + } + if (value.contains("exclude")) { + cfg.exclude_patterns = read_pattern_list("exclude"); + } + + for (const auto & [key, item] : value.items()) { + (void)item; + if (key != "enabled" && key != "source_node_id" && key != "entity_id" && key != "auto_clear" && + key != "severity_bands" && key != "include" && key != "exclude") { + warn("plugins.opcua.auto_alarms: unknown key '" + key + "' - ignored"); + } + } +} + void OpcuaPlugin::on_event_alarm(const AlarmEventDelivery & delivery) { if (shutdown_requested_.load()) { return; diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp index 7fa8d781..49a5237c 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp @@ -2107,4 +2107,61 @@ auto_alarms: false EXPECT_FALSE(map.auto_alarms().enabled); } +// ---- Param-only zero-config overlay (finalize_auto_alarms_overlay) -------- +// The true zero-config path: no node-map YAML is ever loaded; the plugin sets +// the AutoAlarmsConfig straight from the plugins.opcua.auto_alarms param via +// mutable_auto_alarms() and then finalize_auto_alarms_overlay() re-derives the +// fields load() normally fills in and rebuilds entity_defs. + +TEST(AutoAlarmsFinalizeOverlayTest, ParamOnlyNoNodeMapProducesActiveConfigAndEntity) { + NodeMap map; // never loaded - no YAML at all + ASSERT_FALSE(map.auto_alarms().enabled); + + auto & cfg = map.mutable_auto_alarms(); + cfg.enabled = true; // as the plugin would set from `auto_alarms: true` + + ASSERT_TRUE(map.finalize_auto_alarms_overlay()); + + const auto & finalized = map.auto_alarms(); + EXPECT_TRUE(finalized.enabled); + // Default source parsed to a real NodeId (not the null default) so the + // poller subscribes to the right EventNotifier in the no-node_map path. + EXPECT_EQ(finalized.source_node_id_str, "i=2253"); + EXPECT_EQ(finalized.source_node_id.toString(), NodeMap::parse_node_id("i=2253").toString()); + // Entity defaulted from the (default) component_id, and registered as + // fault-bearing so SOVD discovery surfaces the alarms App with no node map. + EXPECT_EQ(finalized.entity_id, "openplc_runtime_alarms"); + bool found = false; + for (const auto & def : map.entity_defs()) { + if (def.id == "openplc_runtime_alarms") { + found = true; + EXPECT_TRUE(def.has_faults); + } + } + EXPECT_TRUE(found); +} + +TEST(AutoAlarmsFinalizeOverlayTest, ParamOnlyRespectsOverriddenSourceAndEntity) { + NodeMap map; + auto & cfg = map.mutable_auto_alarms(); + cfg.enabled = true; + cfg.source_node_id_str = "ns=3;i=1000"; + cfg.entity_id = "custom_alarms"; + + ASSERT_TRUE(map.finalize_auto_alarms_overlay()); + + EXPECT_EQ(map.auto_alarms().source_node_id.toString(), NodeMap::parse_node_id("ns=3;i=1000").toString()); + EXPECT_EQ(map.auto_alarms().entity_id, "custom_alarms"); +} + +TEST(AutoAlarmsFinalizeOverlayTest, EmptySourceNodeDisablesRatherThanCrashing) { + NodeMap map; + auto & cfg = map.mutable_auto_alarms(); + cfg.enabled = true; + cfg.source_node_id_str = ""; // invalid + + EXPECT_FALSE(map.finalize_auto_alarms_overlay()); + EXPECT_FALSE(map.auto_alarms().enabled); +} + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_plugin.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_plugin.cpp index af397e48..4de543bc 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_plugin.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_plugin.cpp @@ -22,6 +22,7 @@ #include +#include #include #include #include @@ -565,4 +566,145 @@ TEST(MapSeverity, LiveSeverityBandBoundaries) { EXPECT_EQ(OpcuaPlugin::map_severity(1000, ""), "CRITICAL"); } +// -- OpcuaPlugin::apply_auto_alarms_param (plugins.opcua.auto_alarms param) --- +// The zero-config native A&C param surface: mirrors the node-map YAML +// ``auto_alarms:`` loader field-for-field but reads the plugin's JSON/ROS +// param, overlaying on top of (or standing in for) a node map. + +TEST(ApplyAutoAlarmsParam, BareBooleanTrueEnablesWithDefaults) { + AutoAlarmsConfig cfg; + int warns = 0; + OpcuaPlugin::apply_auto_alarms_param(nlohmann::json(true), cfg, [&](const std::string &) { + ++warns; + }); + EXPECT_TRUE(cfg.enabled); + EXPECT_EQ(cfg.source_node_id_str, "i=2253"); // untouched default + EXPECT_TRUE(cfg.auto_clear); + EXPECT_EQ(cfg.severity_bands.critical_min, 801); + EXPECT_EQ(warns, 0); +} + +TEST(ApplyAutoAlarmsParam, BareBooleanFalseStaysDisabled) { + AutoAlarmsConfig cfg; + OpcuaPlugin::apply_auto_alarms_param(nlohmann::json(false), cfg, [](const std::string &) {}); + EXPECT_FALSE(cfg.enabled); +} + +TEST(ApplyAutoAlarmsParam, MapFormOverridesEveryField) { + AutoAlarmsConfig cfg; + const nlohmann::json param = { + {"enabled", true}, + {"source_node_id", "ns=3;i=1000"}, + {"entity_id", "alarms_catchall"}, + {"auto_clear", false}, + {"severity_bands", {{"critical", 900}, {"error", 700}, {"warning", 400}}}, + {"include", {"Program_Alarm"}}, + {"exclude", {"CPU not in RUN"}}, + }; + int warns = 0; + OpcuaPlugin::apply_auto_alarms_param(param, cfg, [&](const std::string &) { + ++warns; + }); + EXPECT_TRUE(cfg.enabled); + EXPECT_EQ(cfg.source_node_id_str, "ns=3;i=1000"); + EXPECT_EQ(cfg.entity_id, "alarms_catchall"); + EXPECT_FALSE(cfg.auto_clear); + EXPECT_EQ(cfg.severity_bands.critical_min, 900); + EXPECT_EQ(cfg.severity_bands.error_min, 700); + EXPECT_EQ(cfg.severity_bands.warning_min, 400); + ASSERT_EQ(cfg.include_patterns.size(), 1u); + EXPECT_EQ(cfg.include_patterns[0], "Program_Alarm"); + ASSERT_EQ(cfg.exclude_patterns.size(), 1u); + EXPECT_EQ(cfg.exclude_patterns[0], "CPU not in RUN"); + EXPECT_EQ(warns, 0); +} + +TEST(ApplyAutoAlarmsParam, MapPresenceImpliesEnabledWhenEnabledKeyOmitted) { + AutoAlarmsConfig cfg; + OpcuaPlugin::apply_auto_alarms_param(nlohmann::json{{"entity_id", "alarms_catchall"}}, cfg, + [](const std::string &) {}); + EXPECT_TRUE(cfg.enabled); + EXPECT_EQ(cfg.entity_id, "alarms_catchall"); +} + +TEST(ApplyAutoAlarmsParam, PartialMapKeepsUnsetFields) { + // A param that only sets max-ish knobs must not wipe the rest (mirrors the + // "only overwrite keys actually present" contract of the YAML loader). + AutoAlarmsConfig cfg; + cfg.include_patterns = {"kept"}; + OpcuaPlugin::apply_auto_alarms_param(nlohmann::json{{"auto_clear", false}}, cfg, [](const std::string &) {}); + EXPECT_FALSE(cfg.auto_clear); + EXPECT_EQ(cfg.source_node_id_str, "i=2253"); + ASSERT_EQ(cfg.include_patterns.size(), 1u); + EXPECT_EQ(cfg.include_patterns[0], "kept"); // include omitted -> untouched +} + +TEST(ApplyAutoAlarmsParam, InvalidSeverityBandOrderResetsToDefaults) { + AutoAlarmsConfig cfg; + int warns = 0; + OpcuaPlugin::apply_auto_alarms_param( + nlohmann::json{{"severity_bands", {{"critical", 300}, {"error", 500}, {"warning", 700}}}}, cfg, + [&](const std::string &) { + ++warns; + }); + EXPECT_EQ(cfg.severity_bands.critical_min, 801); + EXPECT_EQ(cfg.severity_bands.error_min, 501); + EXPECT_EQ(cfg.severity_bands.warning_min, 201); + EXPECT_GE(warns, 1); +} + +TEST(ApplyAutoAlarmsParam, UnknownKeyWarns) { + AutoAlarmsConfig cfg; + int warns = 0; + OpcuaPlugin::apply_auto_alarms_param(nlohmann::json{{"severty", 900}}, cfg, [&](const std::string &) { + ++warns; + }); + EXPECT_EQ(warns, 1); +} + +TEST(ApplyAutoAlarmsParam, NonBooleanNonObjectIsIgnoredWithWarning) { + AutoAlarmsConfig cfg; + int warns = 0; + OpcuaPlugin::apply_auto_alarms_param(nlohmann::json("nonsense"), cfg, [&](const std::string &) { + ++warns; + }); + EXPECT_FALSE(cfg.enabled); + EXPECT_EQ(warns, 1); +} + +TEST(ApplyAutoAlarmsParam, JsonParamWinsOverNodeMapYamlConfigBlock) { + // Precedence: the JSON/ROS param overlays and WINS over the node-map YAML + // ``auto_alarms:`` block, identical to how plugins.opcua.auto_browse + // overlays its config. (The node map still wins at the ENTRY level - + // explicit ``event_alarms:`` mappings beat auto-derivation - but that is the + // poller's effective_alarm_sources precedence, tested separately.) + const std::string path = "/tmp/test_opcua_plugin_auto_alarms_overlay.yaml"; + std::ofstream f(path); + f << R"( +area_id: test +component_id: plc_runtime +auto_alarms: + enabled: true + source_node_id: "ns=1;i=100" + entity_id: yaml_entity + auto_clear: true +)"; + f.close(); + + NodeMap map; + ASSERT_TRUE(map.load(path)); + ASSERT_EQ(map.auto_alarms().source_node_id_str, "ns=1;i=100"); + + OpcuaPlugin::apply_auto_alarms_param(nlohmann::json{{"source_node_id", "ns=2;i=200"}, {"auto_clear", false}}, + map.mutable_auto_alarms(), [](const std::string &) {}); + ASSERT_TRUE(map.finalize_auto_alarms_overlay()); + + const auto & cfg = map.auto_alarms(); + EXPECT_TRUE(cfg.enabled); + EXPECT_EQ(cfg.source_node_id_str, "ns=2;i=200"); // param won + EXPECT_FALSE(cfg.auto_clear); // param won + EXPECT_EQ(cfg.entity_id, "yaml_entity"); // untouched by param -> YAML value survives + std::remove(path.c_str()); +} + } // namespace ros2_medkit_gateway From 08f341a96da0da916f098f2be990ac0ef155ca3c Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Sat, 11 Jul 2026 11:59:07 +0200 Subject: [PATCH 4/5] fix(opcua): stable auto fault_code per condition + canonical source dedup Derive and store the auto fault_code/entity once at first observation of a ConditionId and deliver the stored values, so a condition's raise and clear always carry one code across the live, replay and reconcile paths. Fold the SourceNode into fault_code tiers 1/2 so same-named conditions on different sources stay distinct, and compare NodeIds canonically so equivalent spellings dedup and auto events already handled by an explicit event_alarms source do not double-fire. --- .../ros2_medkit_opcua/README.md | 23 +++++-- .../include/ros2_medkit_opcua/node_map.hpp | 26 +++++--- .../ros2_medkit_opcua/opcua_poller.hpp | 10 +++ .../ros2_medkit_opcua/src/node_map.cpp | 33 ++++++++-- .../ros2_medkit_opcua/src/opcua_poller.cpp | 65 +++++++++++++++++-- .../ros2_medkit_opcua/test/test_node_map.cpp | 62 ++++++++++++++++-- .../test/test_opcua_poller.cpp | 47 ++++++++++++++ 7 files changed, 231 insertions(+), 35 deletions(-) diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md b/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md index 9e81a18a..e4dc4120 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md @@ -467,13 +467,22 @@ This completes the zero-config chain: discovery (endpoint) -> no node-map file required. **Fault derivation** (no config, per observed event): -- `fault_code`: `PLC_ALARM_` from `ConditionName` when present, else from - `SourceName`, else `PLC_ALARM_` of SourceNode + EventType + Message. - The hash tier 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. +- `fault_code`: `PLC_ALARM__` from `ConditionName` when + present, else from `SourceName`, else `PLC_ALARM_` 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 diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp index ab2ec756..f563ae0a 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/node_map.hpp @@ -351,17 +351,25 @@ class NodeMap { /// Derive a stable SOVD fault_code for an auto-derived alarm with NO /// per-alarm mapping. Tiered, in order: - /// 1. slug(ConditionName) - the OPC-UA condition identity, when present. - /// 2. slug(SourceName) - the human-readable event source, when - /// ConditionName is empty. + /// 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. - /// Tier 3 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"). Without the message in the hash, every - /// alarm on that FB would collapse onto one fault_code. Pure / static so - /// it is unit-testable without a server. + /// 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); diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp index f4d0e7f4..13081aca 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp @@ -252,6 +252,16 @@ class OpcuaPoller { static std::vector effective_alarm_sources(const std::vector & explicit_sources, const AutoAlarmsConfig & auto_cfg); + /// True when two OPC-UA NodeId strings denote the same node once parsed to + /// canonical form, so equivalent spellings are recognized as one node: + /// ``i=2253`` (the default numeric Server object) matches an explicit + /// ``ns=0;i=2253``, and a default vs explicit namespace agree. Used to + /// dedupe alarm subscriptions and to route the notifier hierarchy so a given + /// physical node is not subscribed / auto-derived twice. Falls back to raw + /// string equality when a spelling is unparseable (distinct raw strings stay + /// distinct). Pure and static so it is unit-testable without a server. + static bool node_ids_equivalent(const std::string & a, const std::string & b); + /// True only for a real OPC-UA Condition event. Per Part 9 §5.5.2.13 the /// ConditionId SAO resolves to a non-null NodeId only for AlarmConditionType /// (and subtype) instances; a plain BaseEvent / SystemEvent notification - diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp index ec5be995..88fe4ce5 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp @@ -1108,8 +1108,9 @@ std::string slugify(const std::string & s) { } // FNV-1a 32-bit, rendered as 8 uppercase hex digits. Not cryptographic - -// only needs to be a stable, low-collision fallback identity for alarms that -// carry neither a ConditionName nor a SourceName. +// only needs to be a stable, low-collision way to fold the SourceNode (and, +// in the fallback tier, EventType + Message) into an alarm's fault_code so +// distinct conditions never share one. std::string short_hash_hex(const std::string & s) { uint32_t h = 2166136261u; for (unsigned char c : s) { @@ -1125,21 +1126,43 @@ std::string short_hash_hex(const std::string & s) { return out; } +// Canonicalize an OPC-UA NodeId string so equivalent spellings (e.g. the +// default numeric ``i=2253`` and an explicit ``ns=0;i=2253``) fold to one +// hash. An unparseable spelling has no canonical form, so it is used verbatim +// (distinct raw strings stay distinct). +std::string canonical_node_id(const std::string & s) { + const opcua::NodeId id = NodeMap::parse_node_id(s); + if (id.isNull()) { + return s; + } + return id.toString(); +} + } // namespace std::string NodeMap::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) { + // Fold the SourceNode into EVERY tier. Two distinct conditions (different + // ConditionId, different SourceNode) that share a ConditionName/SourceName - + // e.g. two identical FB instances each raising "Overpressure" - must not + // collapse onto one fault_code: fault_manager keys and clears by code alone, + // so a collision would let one condition's clear wipe the other's still-active + // fault (or flap them). The SourceNode is stable across a single condition's + // whole lifecycle, so this stays consistent between raise and clear. Note + // Message is deliberately NOT part of tiers 1/2, so a condition whose Message + // differs between its active and inactive notifications still maps to one code. + const std::string source_key = canonical_node_id(source_node_str); if (!condition_name.empty()) { const std::string slug = slugify(condition_name); if (!slug.empty()) { - return "PLC_ALARM_" + slug; + return "PLC_ALARM_" + slug + "_" + short_hash_hex(source_key); } } if (!source_name.empty()) { const std::string slug = slugify(source_name); if (!slug.empty()) { - return "PLC_ALARM_" + slug; + return "PLC_ALARM_" + slug + "_" + short_hash_hex(source_key); } } // Neither identity field is usable. A real Siemens S7-1500 reports every @@ -1147,7 +1170,7 @@ std::string NodeMap::derive_auto_fault_code(const std::string & condition_name, // no ConditionName/SourceName - Message is the ONLY thing that tells "pa" // and "pa2" apart - so it must be part of the fallback hash, not just // SourceNode+EventType, or distinct alarms collapse onto one fault_code. - return "PLC_ALARM_" + short_hash_hex(source_node_str + "|" + event_type_str + "|" + message); + return "PLC_ALARM_" + short_hash_hex(source_key + "|" + event_type_str + "|" + message); } bool NodeMap::auto_alarm_passes_filters(const std::string & condition_name, const std::string & source_name, diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp index 0c483c64..9c31a0f8 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp @@ -293,6 +293,20 @@ void OpcuaPoller::setup_subscriptions() { } } +bool OpcuaPoller::node_ids_equivalent(const std::string & a, const std::string & b) { + if (a == b) { + return true; + } + const opcua::NodeId na = NodeMap::parse_node_id(a); + const opcua::NodeId nb = NodeMap::parse_node_id(b); + // An unparseable spelling has no canonical form; only the raw match above + // can equate it, so two distinct raw strings stay distinct. + if (na.isNull() || nb.isNull()) { + return false; + } + return na.toString() == nb.toString(); +} + std::vector OpcuaPoller::effective_alarm_sources(const std::vector & explicit_sources, const AutoAlarmsConfig & auto_cfg) { @@ -300,8 +314,13 @@ OpcuaPoller::effective_alarm_sources(const std::vector & expli if (!auto_cfg.enabled) { return sources; } + // Compare CANONICAL node ids: an explicit event_alarms source spelled + // ``ns=0;i=2253`` targets the same physical node as the auto default + // ``i=2253``, so a raw string compare would miss the overlap and add a + // second monitored item on one node - every event would then fire twice + // (one mapped fault + one auto fault). const bool already_covered = std::any_of(sources.begin(), sources.end(), [&](const AlarmEventConfig & c) { - return c.source_node_id_str == auto_cfg.source_node_id_str; + return node_ids_equivalent(c.source_node_id_str, auto_cfg.source_node_id_str); }); if (already_covered) { // An explicit event_alarms entry already targets this source; on_event() @@ -575,7 +594,7 @@ void OpcuaPoller::read_fallback_replay() { // effective_alarm_sources()). A read-scan snapshot has no // SourceName/SourceNode-per-condition of its own (it is implicitly // the source we just browsed), so the derivation uses cfg's source id. - if (!auto_cfg.enabled || cfg.source_node_id_str != auto_cfg.source_node_id_str) { + if (!auto_cfg.enabled || !node_ids_equivalent(cfg.source_node_id_str, auto_cfg.source_node_id_str)) { continue; } if (!NodeMap::auto_alarm_passes_filters(snap.condition_name, /*source_name=*/"", snap.message, @@ -818,11 +837,32 @@ void OpcuaPoller::on_event(const AlarmEventConfig & cfg, const std::vector_alarms" - a separate App, not the PLC root Component; // see AutoAlarmsConfig::entity_id). - const auto * known_entry = node_map_.find_by_node_id(source_node.toString()); + const auto * known_entry = node_map_.find_by_node_id(source_node_str); eff.entity_id = known_entry != nullptr ? known_entry->entity_id : auto_cfg.entity_id; eff.severity_override = NodeMap::map_auto_severity(severity, auto_cfg.severity_bands); eff.message_override.clear(); // description = the raw event Message, verbatim @@ -920,6 +960,17 @@ void OpcuaPoller::apply_condition_state(const AlarmEventConfig & cfg, const opcu fresh.last_status = SovdAlarmStatus::Suppressed; it = conditions_.emplace(condition_id_str, std::move(fresh)).first; } + // Pin the fault_code / entity_id derived at the FIRST observation of this + // ConditionId and reuse them for every subsequent event. The per-event + // ``cfg`` re-derives the auto fault_code (opcua_poller.cpp on_event, and + // the read-replay path with different SourceName/EventType inputs), so a + // raise and the matching clear of one condition could otherwise carry + // different codes - e.g. a Siemens Program_Alarm whose Message changes + // between the active and inactive notifications, or a condition replayed + // on connect and then cleared live. Because fault_manager keys/clears by + // code alone, that divergence would latch the original fault forever. The + // reconcile clears already deliver the stored code (runtime.fault_code); + // this makes the live delivery agree with them. const SovdAlarmStatus prev_status = it->second.last_status; if (event_id != nullptr) { @@ -941,8 +992,8 @@ void OpcuaPoller::apply_condition_state(const AlarmEventConfig & cfg, const opcu return; } - delivery.fault_code = cfg.fault_code; - delivery.entity_id = cfg.entity_id; + delivery.fault_code = it->second.fault_code; + delivery.entity_id = it->second.entity_id; delivery.next_status = outcome.next_status; delivery.action = outcome.action; delivery.severity = severity; diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp index 49a5237c..cd98e137 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp @@ -1863,21 +1863,26 @@ component_id: test // ---- Zero-config native A&C (auto_alarms) -------------------------------- TEST(DeriveAutoFaultCodeTest, UsesConditionNameWhenPresent) { - EXPECT_EQ(NodeMap::derive_auto_fault_code("Overpressure", "SourceX", "ns=2;s=Tank", "ns=0;i=2915", "msg"), - "PLC_ALARM_OVERPRESSURE"); + // Tier 1 = slug(ConditionName) + a canonical-SourceNode hash suffix (the + // suffix keeps same-named conditions on different sources distinct). + const std::string code = + NodeMap::derive_auto_fault_code("Overpressure", "SourceX", "ns=2;s=Tank", "ns=0;i=2915", "msg"); + EXPECT_EQ(code.rfind("PLC_ALARM_OVERPRESSURE_", 0), 0u); } TEST(DeriveAutoFaultCodeTest, ConditionNameSlugified) { // Dots, spaces and punctuation collapse to single underscores; case folds // to upper, matching every hand-written fault_code in this codebase // (PLC_OVERPRESSURE, PLC_COMMS_LOST, ...). - EXPECT_EQ(NodeMap::derive_auto_fault_code("Tank.Overpressure!!", "", "ns=2;s=Tank", "ns=0;i=2915", ""), - "PLC_ALARM_TANK_OVERPRESSURE"); + const std::string code = + NodeMap::derive_auto_fault_code("Tank.Overpressure!!", "", "ns=2;s=Tank", "ns=0;i=2915", ""); + EXPECT_EQ(code.rfind("PLC_ALARM_TANK_OVERPRESSURE_", 0), 0u); } TEST(DeriveAutoFaultCodeTest, FallsBackToSourceNameWhenConditionNameEmpty) { - EXPECT_EQ(NodeMap::derive_auto_fault_code("", "Pump A Fault", "ns=2;s=PumpA", "ns=0;i=2915", "msg"), - "PLC_ALARM_PUMP_A_FAULT"); + const std::string code = + NodeMap::derive_auto_fault_code("", "Pump A Fault", "ns=2;s=PumpA", "ns=0;i=2915", "msg"); + EXPECT_EQ(code.rfind("PLC_ALARM_PUMP_A_FAULT_", 0), 0u); } TEST(DeriveAutoFaultCodeTest, HashFallbackWhenBothNamesEmpty) { @@ -1902,7 +1907,50 @@ TEST(DeriveAutoFaultCodeTest, EmptyConditionNameSlugFallsThroughToSourceName) { // A ConditionName that slugifies to nothing (pure punctuation) must not // stick with an empty-but-nonempty-input tier - it falls through exactly // like an absent ConditionName. - EXPECT_EQ(NodeMap::derive_auto_fault_code("!!!", "PumpA", "ns=2;s=PumpA", "ns=0;i=2915", ""), "PLC_ALARM_PUMPA"); + const std::string code = NodeMap::derive_auto_fault_code("!!!", "PumpA", "ns=2;s=PumpA", "ns=0;i=2915", ""); + EXPECT_EQ(code.rfind("PLC_ALARM_PUMPA_", 0), 0u); +} + +TEST(DeriveAutoFaultCodeTest, SameConditionNameDifferentSourcesGetDistinctCodes) { + // Two identical FB instances each raising "Overpressure" from a DIFFERENT + // SourceNode must not collapse onto one code: fault_manager keys/clears by + // code alone, so a collision would let one condition's clear wipe the + // other's still-active fault. This is the whole point of folding the + // SourceNode into tiers 1/2. + const std::string a = NodeMap::derive_auto_fault_code("Overpressure", "", "ns=2;s=PumpA", "ns=0;i=2915", "msg"); + const std::string b = NodeMap::derive_auto_fault_code("Overpressure", "", "ns=2;s=PumpB", "ns=0;i=2915", "msg"); + EXPECT_EQ(a.rfind("PLC_ALARM_OVERPRESSURE_", 0), 0u); + EXPECT_EQ(b.rfind("PLC_ALARM_OVERPRESSURE_", 0), 0u); + EXPECT_NE(a, b); +} + +TEST(DeriveAutoFaultCodeTest, SameSourceNameDifferentSourcesGetDistinctCodes) { + // Tier 2 (SourceName fallback) mirrors tier 1: distinct sources sharing a + // SourceName stay distinct. + const std::string a = NodeMap::derive_auto_fault_code("", "Pump Fault", "ns=2;s=PumpA", "ns=0;i=2915", "msg"); + const std::string b = NodeMap::derive_auto_fault_code("", "Pump Fault", "ns=2;s=PumpB", "ns=0;i=2915", "msg"); + EXPECT_NE(a, b); +} + +TEST(DeriveAutoFaultCodeTest, StableAcrossMessageDriftForSameCondition) { + // Tiers 1/2 must NOT fold Message: OPC-UA lets a condition's Message differ + // between its active and inactive notifications, and the code must stay the + // same so the clear matches the raise (fault_manager keys by code). + const std::string active = + NodeMap::derive_auto_fault_code("Overpressure", "", "ns=2;s=PumpA", "ns=0;i=2915", "active text"); + const std::string cleared = + NodeMap::derive_auto_fault_code("Overpressure", "", "ns=2;s=PumpA", "ns=0;i=2915", "cleared text"); + EXPECT_EQ(active, cleared); +} + +TEST(DeriveAutoFaultCodeTest, EquivalentSourceSpellingsProduceSameCode) { + // The SourceNode is canonicalized before hashing, so the default numeric + // Server object and its explicit namespaced spelling map to one code. + const std::string numeric = + NodeMap::derive_auto_fault_code("Overpressure", "", "i=2253", "ns=0;i=2915", "msg"); + const std::string namespaced = + NodeMap::derive_auto_fault_code("Overpressure", "", "ns=0;i=2253", "ns=0;i=2915", "msg"); + EXPECT_EQ(numeric, namespaced); } TEST(AutoAlarmPassesFiltersTest, NoPatternsAdmitsEverything) { diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_poller.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_poller.cpp index 06d8da36..541f2629 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_poller.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_poller.cpp @@ -103,6 +103,53 @@ TEST(EffectiveAlarmSourcesTest, MultipleExplicitSourcesPreservedAlongsideSynthet EXPECT_EQ(result[2].source_node_id_str, "i=2253"); } +TEST(EffectiveAlarmSourcesTest, EquivalentSpellingOfAutoSourceIsRecognizedAsCovered) { + // An explicit event_alarms source spelled ``ns=0;i=2253`` targets the same + // physical node as the auto default ``i=2253``. A raw string compare would + // miss the overlap and add a second monitored item on one node, so every + // event would double-fire (one mapped fault + one auto fault). Canonical + // comparison must recognize them as the same source (no synthetic added). + std::vector explicit_sources(1); + explicit_sources[0].source_node_id_str = "ns=0;i=2253"; + explicit_sources[0].entity_id = "line"; + explicit_sources[0].fault_code = "PLC_GENERIC_ALARM"; + + AutoAlarmsConfig auto_cfg; + auto_cfg.enabled = true; + auto_cfg.source_node_id_str = "i=2253"; + auto_cfg.entity_id = "plc_runtime"; + + auto result = OpcuaPoller::effective_alarm_sources(explicit_sources, auto_cfg); + ASSERT_EQ(result.size(), 1u); + EXPECT_EQ(result[0].source_node_id_str, "ns=0;i=2253"); +} + +TEST(NodeIdsEquivalentTest, IdenticalStringsAreEquivalent) { + EXPECT_TRUE(OpcuaPoller::node_ids_equivalent("ns=2;s=Tank.Pressure", "ns=2;s=Tank.Pressure")); +} + +TEST(NodeIdsEquivalentTest, DefaultAndExplicitNamespaceZeroNumericAreEquivalent) { + // The default numeric Server object and its explicit ns=0 spelling denote + // one physical node. + EXPECT_TRUE(OpcuaPoller::node_ids_equivalent("i=2253", "ns=0;i=2253")); + EXPECT_TRUE(OpcuaPoller::node_ids_equivalent("ns=0;i=2253", "i=2253")); +} + +TEST(NodeIdsEquivalentTest, DifferentNumericIdsAreNotEquivalent) { + EXPECT_FALSE(OpcuaPoller::node_ids_equivalent("i=2253", "i=2254")); +} + +TEST(NodeIdsEquivalentTest, DifferentNamespacesAreNotEquivalent) { + EXPECT_FALSE(OpcuaPoller::node_ids_equivalent("ns=1;s=Pump", "ns=2;s=Pump")); +} + +TEST(NodeIdsEquivalentTest, UnparseableSpellingsFallBackToRawEquality) { + // Two distinct unparseable strings have no canonical form and must stay + // distinct; an identical unparseable string still matches itself. + EXPECT_FALSE(OpcuaPoller::node_ids_equivalent("not-a-node-id", "also-bad")); + EXPECT_TRUE(OpcuaPoller::node_ids_equivalent("not-a-node-id", "not-a-node-id")); +} + TEST(IsConditionEventTest, NullConditionIdIsRejected) { // Part 9 §5.5.2.13: a non-condition event (e.g. a Siemens Server-object // system message such as "CPU not in RUN") resolves the ConditionId SAO From 2d443960ad6d730faa1b3b05546a641413215768 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Sat, 11 Jul 2026 13:03:38 +0200 Subject: [PATCH 5/5] style(opcua): clang-format-18 the auto fault_code changes --- .../ros2_medkit_opcua/src/opcua_poller.cpp | 4 ++-- .../ros2_medkit_opcua/test/test_node_map.cpp | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp index 9c31a0f8..b02ba80e 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp @@ -881,8 +881,8 @@ void OpcuaPoller::on_event(const AlarmEventConfig & cfg, const std::vector_alarms" - a separate App, not the PLC root Component; diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp index cd98e137..5bcbe198 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp @@ -1874,14 +1874,12 @@ TEST(DeriveAutoFaultCodeTest, ConditionNameSlugified) { // Dots, spaces and punctuation collapse to single underscores; case folds // to upper, matching every hand-written fault_code in this codebase // (PLC_OVERPRESSURE, PLC_COMMS_LOST, ...). - const std::string code = - NodeMap::derive_auto_fault_code("Tank.Overpressure!!", "", "ns=2;s=Tank", "ns=0;i=2915", ""); + const std::string code = NodeMap::derive_auto_fault_code("Tank.Overpressure!!", "", "ns=2;s=Tank", "ns=0;i=2915", ""); EXPECT_EQ(code.rfind("PLC_ALARM_TANK_OVERPRESSURE_", 0), 0u); } TEST(DeriveAutoFaultCodeTest, FallsBackToSourceNameWhenConditionNameEmpty) { - const std::string code = - NodeMap::derive_auto_fault_code("", "Pump A Fault", "ns=2;s=PumpA", "ns=0;i=2915", "msg"); + const std::string code = NodeMap::derive_auto_fault_code("", "Pump A Fault", "ns=2;s=PumpA", "ns=0;i=2915", "msg"); EXPECT_EQ(code.rfind("PLC_ALARM_PUMP_A_FAULT_", 0), 0u); } @@ -1946,8 +1944,7 @@ TEST(DeriveAutoFaultCodeTest, StableAcrossMessageDriftForSameCondition) { TEST(DeriveAutoFaultCodeTest, EquivalentSourceSpellingsProduceSameCode) { // The SourceNode is canonicalized before hashing, so the default numeric // Server object and its explicit namespaced spelling map to one code. - const std::string numeric = - NodeMap::derive_auto_fault_code("Overpressure", "", "i=2253", "ns=0;i=2915", "msg"); + const std::string numeric = NodeMap::derive_auto_fault_code("Overpressure", "", "i=2253", "ns=0;i=2915", "msg"); const std::string namespaced = NodeMap::derive_auto_fault_code("Overpressure", "", "ns=0;i=2253", "ns=0;i=2915", "msg"); EXPECT_EQ(numeric, namespaced);