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..e4dc4120 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md @@ -418,6 +418,102 @@ The JSON/ROS-param form takes precedence over whatever the node-map YAML's `auto_browse:` block set, mirroring how environment variables override the rest of the plugin's YAML config. +### Zero-config native A&C (`auto_alarms`) + +`event_alarms:` above requires a hand-written mapping per alarm. `auto_alarms` +needs none: enable it and every AlarmConditionType event on the subscribed +source (default the Server object, `i=2253` - the same system-wide catch-all +used in the `event_alarms` examples) becomes a fault automatically, with no +`condition_name` / `source_node` / `fault_code` mapping to write. + +```yaml +# Bare boolean - all defaults. +auto_alarms: true +``` + +```yaml +# Map form - every key optional. +auto_alarms: + enabled: true + source_node_id: "i=2253" # EventNotifier to subscribe (default: Server object) + entity_id: "plc_alarms" # fallback SOVD entity (default: "_alarms") + auto_clear: true # clear immediately on ActiveState=false, bypassing + # Acknowledge/Confirm (see below) + severity_bands: # OPC-UA Severity (1-1000) -> SOVD bucket + critical: 801 + error: 501 + warning: 201 + include: [] # substring allow-list on ConditionName/SourceName/Message + exclude: ["CPU not in RUN"] # substring deny-list, checked first +``` + +Default `off`. Unknown keys are warned and ignored, not silently dropped. + +Instead of (or on top of) the node-map YAML, `auto_alarms` can be set purely +via the plugin's ROS param / JSON config, which is what lets a discovered +endpoint go straight to native faults with no node-map file at all: + +```yaml +plugins.opcua.endpoint_url: "opc.tcp://192.168.1.10:4840" +plugins.opcua.auto_alarms: true # or the same map form as above +``` + +The JSON/ROS-param form takes precedence over whatever the node-map YAML's +`auto_alarms:` block set (same precedence env vars use for the rest of the +plugin config). Explicit `event_alarms:` mappings still win over +auto-derivation at the poller regardless of how `auto_alarms` was enabled. +This completes the zero-config chain: discovery (endpoint) -> +`auto_browse` (SOVD tree) -> `auto_alarms` (native faults), each param-driven, +no node-map file required. + +**Fault derivation** (no config, per observed event): +- `fault_code`: `PLC_ALARM__` 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 + `severity_bands`. +- `description`: the event's `Message`, verbatim. + +**System messages:** some servers deliver non-alarm notifications on the same +EventNotifier as real alarms - a Siemens Server object also emits SIMATIC +system messages (e.g. `"CPU not in RUN"`) over `i=2253`. Per OPC-UA Part 9 +§5.5.2.13 only a true Condition instance carries a ConditionId; a system +message resolves it to `NodeId.Null`, and `auto_alarms` drops those events +before deriving a fault. Use `exclude` for anything that still needs +filtering by text (e.g. a server that does set a ConditionId on system +events). + +**Precedence:** explicit `event_alarms` mappings are tried first; `auto_alarms` +covers whatever they do not match. On the same source (e.g. both configured +on `i=2253`) no duplicate subscription is created - unmatched events on an +`event_alarms` source simply fall through to auto-derivation when +`auto_alarms` is enabled for that source. + +**Acknowledge/Confirm:** `auto_clear` (default `true`) clears an auto-derived +alarm as soon as it goes inactive, bypassing the Acknowledge/Confirm workflow +entirely - there is no `acknowledge_fault` SOVD operation for a zero-config +alarm to receive an Ack through, so without this it would latch forever on a +server that requires one (as most do). Set `auto_clear: false` to require the +poller's normal Acknowledge/Confirm gating before it clears - since a +zero-config alarm has no SOVD `acknowledge_fault` operation of its own, +Acked/Confirmed must come from the OPC-UA server side instead (an HMI/SCADA +system or another OPC-UA client acknowledging the condition directly). + ### Gateway Parameters ```yaml @@ -429,13 +525,15 @@ ros2_medkit_gateway: plugins.opcua.poll_interval_ms: 1000 plugins.opcua.prefer_subscriptions: false plugins.opcua.auto_browse: true + plugins.opcua.auto_alarms: true ``` | Parameter | Default | Description | |-----------|---------|-------------| | `endpoint_url` | `opc.tcp://localhost:4840` | OPC-UA server endpoint | -| `node_map_path` | (none) | Path to node map YAML (optional when `auto_browse` is enabled - see above) | +| `node_map_path` | (none) | Path to node map YAML (optional when `auto_browse` or `auto_alarms` is enabled - see above) | | `auto_browse` | `false` | Boolean or object; recursively discover the SOVD tree from the live address space instead of (or in addition to) `node_map_path` - see above | +| `auto_alarms` | `false` | Boolean or object; subscribe a server EventNotifier and auto-derive a fault per AlarmCondition with no per-alarm `event_alarms` mapping - the zero-config native A&C surface (see above) | | `poll_interval_ms` | `1000` | Polling interval in ms (clamped to [100, 60000]) | | `prefer_subscriptions` | `false` | Use OPC-UA subscriptions instead of polling | | `subscription_interval_ms` | `500` | Publishing interval for OPC-UA subscriptions when `prefer_subscriptions: true` | 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..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 @@ -118,6 +118,72 @@ struct ResolvedAlarm { bool matched{false}; }; +/// OPC-UA Severity (1-1000) -> SOVD severity bucket thresholds for +/// ``auto_alarms`` (zero-config native A&C). Mirrors the fixed bands +/// ``OpcuaPlugin::map_severity`` uses for explicit ``event_alarms`` +/// (>=801 CRITICAL, >=501 ERROR, >=201 WARNING, else INFO) but is +/// per-deployment overridable via ``auto_alarms.severity_bands`` since a PLC +/// vendor's own severity convention need not match ours. +struct AutoAlarmsSeverityBands { + uint16_t critical_min{801}; + uint16_t error_min{501}; + uint16_t warning_min{201}; +}; + +/// Zero-config native OPC-UA Alarms & Conditions (completes the #509 +/// discovery -> #510 auto_browse -> native faults story with no per-alarm +/// ``event_alarms`` mapping). When enabled, the plugin subscribes to +/// ``source_node_id`` (default the Server object, ``i=2253``) for +/// AlarmConditionType events and derives a fault for every condition it does +/// not already know from an explicit ``event_alarms`` mapping (issue #389 +/// mappings always take precedence - see ``NodeMap::resolve_alarm``). +struct AutoAlarmsConfig { + bool enabled{false}; + + /// EventNotifier source to subscribe. Default is the Server object + /// (``i=2253``, "system-wide" catch-all): the natural zero-config choice + /// since Program_Alarm / ProDiag style conditions on Siemens, Beckhoff and + /// CodeSys controllers surface there without the operator having to find + /// the owning Object first. + std::string source_node_id_str = "i=2253"; + opcua::NodeId source_node_id; + + /// SOVD entity that hosts an auto-derived fault when its SourceNode is not + /// a known node-map entry. Defaults to ``"_alarms"`` at load + /// time (see ``NodeMap::load``) - deliberately not the bare + /// ``component_id``, which the PLC runtime already registers as a + /// Component entity (a second App entity with the same id collides in the + /// SOVD merge pipeline). When the event's SourceNode matches an existing + /// ``nodes:`` entry, the fault is hosted on THAT entry's entity instead (a + /// more specific home than this fallback). + std::string entity_id; + + /// When true (default), an auto-derived alarm clears as soon as + /// ActiveState becomes false, bypassing BOTH halves of the + /// Acknowledge/Confirm gate that otherwise applies to native alarms + /// (``AlarmStateMachine``'s clear rule is ``acked && (confirmed || + /// !require_confirm_for_clear)`` - dropping only the Confirm half is not + /// enough, since a zero-config alarm has no SOVD ``acknowledge_fault`` + /// operation registered to ever set Acked either). Zero-config is meant to + /// "just work" without an operator workflow, and several PLCs (e.g. + /// Siemens S7-1500) do not implement the optional Confirm transition at + /// all - without this, such an alarm would latch forever. Set false to + /// inherit the poller's normal ``require_confirm_for_clear`` gating + /// (Acknowledge still required) for auto-derived alarms too. + bool auto_clear{true}; + + AutoAlarmsSeverityBands severity_bands; + + /// Case-sensitive substring filters against ConditionName / SourceName / + /// Message. ``exclude`` is checked first: any match drops the event + /// entirely (used to filter Siemens Server-object system messages such as + /// "CPU not in RUN" that are not real alarms). ``include`` is then checked + /// only if non-empty: the event is dropped unless at least one pattern + /// matches. Both empty (the default) admits every event. + std::vector 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 +322,71 @@ class NodeMap { const std::string & source_node, const std::string & event_type, const std::string & message); + /// Get the zero-config native A&C configuration. ``enabled`` is false + /// unless the node map YAML declares a truthy ``auto_alarms:`` or the + /// plugin overlays a truthy ``plugins.opcua.auto_alarms`` param. + const AutoAlarmsConfig & auto_alarms() const { + return auto_alarms_; + } + + /// Mutable access so ``OpcuaPlugin::configure`` can overlay the plugin's + /// ``plugins.opcua.auto_alarms`` JSON/ROS param on top of whatever the + /// node-map YAML declared (JSON wins - same precedence the env-var + /// overrides use for the rest of the plugin config). Call + /// ``finalize_auto_alarms_overlay`` afterwards to re-derive the fields + /// ``load`` normally fills in and rebuild the entity defs. + AutoAlarmsConfig & mutable_auto_alarms() { + return auto_alarms_; + } + + /// Re-derive the ``auto_alarms`` fields ``load`` normally computes after the + /// plugin overlays a ``plugins.opcua.auto_alarms`` param (default + /// ``entity_id`` from ``component_id``, parsed ``source_node_id``) and + /// rebuild ``entity_defs_`` so a param-only zero-config deployment (no + /// node-map file, so ``load`` never ran) still surfaces the alarms App in + /// SOVD discovery. Idempotent - safe to call whether or not ``load`` ran. + /// @return false when the overlaid config is invalid (enabled with an empty + /// ``source_node_id``); the caller disables auto_alarms in that case. + bool finalize_auto_alarms_overlay(); + + /// Derive a stable SOVD fault_code for an auto-derived alarm with NO + /// per-alarm mapping. Tiered, in order: + /// 1. slug(ConditionName) + hash(SourceNode) - the OPC-UA condition + /// identity, when present. + /// 2. slug(SourceName) + hash(SourceNode) - the human-readable event + /// source, when ConditionName is empty. + /// 3. a hash of SourceNode + EventType + Message, when neither name is + /// available. + /// The SourceNode is folded into EVERY tier so two distinct conditions that + /// share a ConditionName/SourceName but sit on different sources (e.g. two + /// identical FB instances each raising "Overpressure") never collapse onto + /// one fault_code - fault_manager keys/clears by code alone, so a collision + /// would let one condition's clear wipe the other's still-active fault. The + /// SourceNode is canonicalized first, so ``i=2253`` and ``ns=0;i=2253`` fold + /// to one code. Tier 3 additionally folds in ``message`` deliberately: a + /// real Siemens S7-1500 multiplexes every Program_Alarm of one FB through a + /// single SourceNode (e.g. ``i=1845``) with no ConditionName/SourceName, + /// distinguished only by Message text ("pa" vs "pa2"). Message is NOT in + /// tiers 1/2, so a condition whose Message differs between its active and + /// inactive notifications still maps to one code. Pure / static so it is + /// unit-testable without a server. + static std::string derive_auto_fault_code(const std::string & condition_name, const std::string & source_name, + const std::string & source_node_str, const std::string & event_type_str, + const std::string & message); + + /// Apply ``auto_alarms.include``/``exclude`` substring filters to an + /// observed event's identity fields. ``exclude`` wins first (drops the + /// event on any match); a non-empty ``include`` then requires at least one + /// match. Pure / static so it is unit-testable without a server. + static bool auto_alarm_passes_filters(const std::string & condition_name, const std::string & source_name, + const std::string & message, const std::vector & 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 +466,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_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/include/ros2_medkit_opcua/opcua_poller.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp index f9dcab78..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 @@ -237,6 +237,40 @@ 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 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 - + /// 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 +288,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 +338,15 @@ 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 - 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); + 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..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 @@ -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,133 @@ 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 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) { + 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; +} + +// 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 + "_" + short_hash_hex(source_key); + } + } + if (!source_name.empty()) { + const std::string slug = slugify(source_name); + if (!slug.empty()) { + return "PLC_ALARM_" + slug + "_" + short_hash_hex(source_key); + } + } + // 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_key + "|" + 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); @@ -1067,6 +1297,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(); @@ -1105,6 +1358,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_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/src/opcua_poller.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp index 64081d23..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 @@ -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,57 @@ 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) { + std::vector sources = explicit_sources; + 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 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() + // 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 +359,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 +511,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 +582,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 || !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, + 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 +813,101 @@ 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 || !node_ids_equivalent(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; + } + // Notifier hierarchy: the auto source can be a root notifier (e.g. the + // Server object i=2253) that ALSO receives events whose real SourceNode has + // its own explicit event_alarms subscription. That explicit monitored item + // already delivered - and mapped - this event, so auto-deriving it here + // would double-fire (explicit fault + auto fault) and defeat the documented + // "explicit event_alarms take precedence". Drop the event when its real + // SourceNode matches an explicit event_alarms source OTHER than this + // monitored item's own (the shared-source fall-through, where cfg IS that + // explicit source, must still auto-derive its own unmatched events). + const std::string source_node_str = source_node.toString(); + for (const auto & explicit_cfg : node_map_.event_alarms()) { + if (node_ids_equivalent(explicit_cfg.source_node_id_str, cfg.source_node_id_str)) { + continue; // this monitored item's own source (shared-source case) + } + if (node_ids_equivalent(explicit_cfg.source_node_id_str, source_node_str)) { + RCLCPP_DEBUG_STREAM(opcua_poller_logger(), + "on_event: dropping auto event already handled by explicit event_alarms source '" + << explicit_cfg.source_node_id_str << "'"); + 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_str, 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: + // "_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_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 + 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 +926,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. + 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) { + 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); +} + +// ---- 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 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..541f2629 --- /dev/null +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_poller.cpp @@ -0,0 +1,164 @@ +// 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(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 + // 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