Skip to content

Commit a2a2faa

Browse files
committed
fix(opcua,#386): Copilot review feedback batch (observability + hygiene)
Eight Copilot findings on PR #387 addressed in one batch: Observability (#6/#7/#8/#10): every per-event / per-method-call ``std::cerr`` trace is now gated by the ``ROS2_MEDKIT_OPCUA_TRACE`` env-var (off by default). Covered the trampoline, ``add_event_monitored_item``, ``call_method``, ``on_event``, EventId capture, state-machine classification, dispatch trace, and the SOVD ack/confirm EventId hex dump. Production logs now stay clean; integration-test debugging re-enables verbose mode with one env-var. Operator-visible ``[opcua_poller WARN] ConditionRefresh rejected`` stays unconditional. Comment / code mismatches (#1/#11/#12): - ``OpcuaPlugin::on_event_alarm`` ReportHealed branch now explicitly documents the intentional no-op and explains why pushing EVENT_PASSED to fault_manager would defeat the OPC-UA Part 9 ack/confirm contract. - ``alarm_state_machine.hpp`` Retain comment matches reality - we do not currently strip Retain=false events because the EventFilter doesn't include Retain in its select clauses (followup issue #389). Test fixes: - (#3) ``DisabledNoOpWhenAlreadyCleared`` renamed to ``DisabledTransitionsClearedToSuppressedNoOp`` so the name reflects both the status transition (Cleared -> Suppressed) and the no-op action. - (#13) New ``NodeMapTest.RejectsAlarmSourceUnderNodes``: locks the schema validation that rejects misplaced ``alarm_source`` keys under ``nodes:`` (was silently ignored), pointing the user at ``event_alarms:``. Schema validation (#13): ``NodeMap::load`` now rejects any ``alarm_source`` key under ``nodes:`` with an actionable error message. Previous behaviour silently ignored the typo unless paired with ``alarm.threshold``, leaving "subscribed alarm that never fires" cases impossible to diagnose at runtime. Test/script hygiene: - (#9) ``run_alarm_tests.sh`` replaces the fixed ``sleep 3`` between ``fault_manager_node`` start and ``gateway_node`` start with a 30-try poll on ``ros2 service list | grep /fault_manager/report_fault``. Matches the project rule "no fixed sleeps". - (#2) ``run_ctest.py`` smoke-test runner: skips on missing ``asyncua`` by default (preserves local dev iteration), but treats the env-var ``ROS2_MEDKIT_OPCUA_SMOKE_REQUIRE=1`` as "fail hard" so a CI step that drops the ``asyncua`` install cannot silently bypass the smoke check. Local verify: 27/27 test_opcua_client + 27/27 test_alarm_state_machine + 1/1 new RejectsAlarmSourceUnderNodes.
1 parent b95b218 commit a2a2faa

9 files changed

Lines changed: 187 additions & 56 deletions

File tree

src/ros2_medkit_plugins/ros2_medkit_opcua/docker/scripts/run_alarm_tests.sh

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,17 @@ docker run -d --name "${GATEWAY_NAME}" --network "${NET_NAME}" \
267267
# the gateway opcua plugin tries to call /fault_manager/report_fault.
268268
ros2 run ros2_medkit_fault_manager fault_manager_node \
269269
> /var/lib/ros2_medkit/fault_manager.log 2>&1 &
270-
sleep 3
270+
# Poll for service advertisement instead of a fixed sleep (Copilot
271+
# review on PR #387). On slow CI runners the previous ``sleep 3`` was
272+
# sometimes too short, leaving the gateway to come up before the
273+
# service was discoverable. ``ros2 service list`` is the cheapest
274+
# ROS-native availability signal.
275+
for i in $(seq 1 30); do
276+
if ros2 service list 2>/dev/null | grep -q "/fault_manager/report_fault"; then
277+
break
278+
fi
279+
sleep 0.2
280+
done
271281
PLUGIN_PATH=$(find /root/ws/install -name "libros2_medkit_opcua_plugin.so" | head -1)
272282
exec ros2 run ros2_medkit_gateway gateway_node \
273283
--ros-args --params-file /config/gateway_params.yaml \

src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/alarm_state_machine.hpp

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,13 @@ struct AlarmEventInput {
6868
/// 5. ActiveState == false -> Healed or Cleared based on
6969
/// Acked + Confirmed
7070
///
71-
/// Retain is intentionally NOT used here. Per Part 9 §5.5.2.10 it controls
72-
/// visibility during ConditionRefresh bursts, not lifecycle - the poller
73-
/// strips Retain=false events delivered between RefreshStartEvent and
74-
/// RefreshEndEvent before invoking compute().
71+
/// Retain is intentionally not modeled by this state machine and does not
72+
/// affect ``compute()``. Per Part 9 §5.5.2.10 it controls visibility during
73+
/// ConditionRefresh bursts rather than the lifecycle mapping implemented
74+
/// here. The current EventFilter does not include Retain in its select
75+
/// clauses; if/when ConditionRefresh-with-Retain filtering is added (issue
76+
/// #389), it will live in the poller's pre-compute path, not in this
77+
/// pure-function table. (Copilot review on PR #387.)
7578
class AlarmStateMachine {
7679
public:
7780
struct Outcome {

src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -334,14 +334,18 @@ bool NodeMap::load(const std::string & yaml_path) {
334334
}
335335
}
336336

337-
// Mutual-exclusion check: an entry under ``nodes:`` carrying both a
338-
// ``threshold`` alarm and an ``alarm_source`` is a configuration error
339-
// (the threshold path polls scalar values; the alarm_source path
340-
// subscribes to native events). Reject the whole file rather than guess.
337+
// Schema validation under ``nodes:``: ``alarm_source`` belongs in the
338+
// top-level ``event_alarms:`` section, never under ``nodes:``. Silently
339+
// ignoring a misplaced ``alarm_source`` (the previous behavior unless
340+
// also paired with ``alarm.threshold``) lets a configuration typo land
341+
// a "subscribed alarm that never fires", which is impossible to
342+
// diagnose from runtime logs. Reject the whole file with an actionable
343+
// error pointing at the right place. (Copilot review on PR #387.)
341344
for (const auto & node : (nodes ? nodes : YAML::Node{})) {
342-
if (node["alarm_source"] && node["alarm"] && node["alarm"]["threshold"]) {
345+
if (node["alarm_source"]) {
343346
RCLCPP_ERROR(rclcpp::get_logger("opcua.node_map"),
344-
"Entry node_id=%s declares both threshold alarm and alarm_source - mutually exclusive",
347+
"Entry node_id=%s uses ``alarm_source`` under ``nodes:``, which is invalid; "
348+
"move this configuration to top-level ``event_alarms:`` (see README §event_alarms)",
345349
node["node_id"] ? node["node_id"].as<std::string>().c_str() : "<unknown>");
346350
return false;
347351
}

src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
#include <algorithm>
1818
#include <atomic>
19+
#include <cstdlib>
1920
#include <cstring>
2021
#include <deque>
2122
#include <iostream>
@@ -29,6 +30,20 @@
2930

3031
namespace ros2_medkit_gateway {
3132

33+
// Env-var gate for verbose per-event / per-method-call diagnostics.
34+
// Set ``ROS2_MEDKIT_OPCUA_TRACE=1`` to enable trampoline / EventId /
35+
// call_method stderr logging. Off by default to keep production logs sane
36+
// (Copilot review on PR #387: per-event std::cerr would flood under high
37+
// alarm rates and bypass the gateway's normal logging path). Resolved once
38+
// at process start so toggling requires a restart.
39+
inline bool opcua_trace_enabled() {
40+
static const bool enabled = []() {
41+
const char * v = std::getenv("ROS2_MEDKIT_OPCUA_TRACE");
42+
return v != nullptr && v[0] != '\0' && std::string(v) != "0";
43+
}();
44+
return enabled;
45+
}
46+
3247
// Forward declaration - defined after Impl so the trampoline can call back into
3348
// the client. Static linkage keeps the symbol private to this translation unit.
3449
struct EventCallbackContext;
@@ -599,11 +614,15 @@ UA_EventFilter make_event_filter(const std::vector<OpcuaClient::EventFieldSpec>
599614
// Defined at namespace scope so its address is a stable function pointer.
600615
static void on_event_trampoline_c(UA_Client * /*client*/, UA_UInt32 sub_id, void * /*sub_ctx*/, UA_UInt32 mon_id,
601616
void * mon_ctx, size_t n_fields, UA_Variant * fields) {
602-
std::cerr << "[opcua_client] TRAMPOLINE FIRED sub=" << sub_id << " mon=" << mon_id << " n_fields=" << n_fields
603-
<< std::endl;
617+
if (opcua_trace_enabled()) {
618+
std::cerr << "[opcua_client] TRAMPOLINE FIRED sub=" << sub_id << " mon=" << mon_id << " n_fields=" << n_fields
619+
<< std::endl;
620+
}
604621
auto * ctx = static_cast<EventCallbackContext *>(mon_ctx);
605622
if (ctx == nullptr || ctx->owner == nullptr) {
606-
std::cerr << "[opcua_client] TRAMPOLINE: ctx null" << std::endl;
623+
if (opcua_trace_enabled()) {
624+
std::cerr << "[opcua_client] TRAMPOLINE: ctx null" << std::endl;
625+
}
607626
return;
608627
}
609628
// Stale callback from a defunct subscription - ctx is still valid (we only
@@ -710,11 +729,13 @@ uint32_t OpcuaClient::add_event_monitored_item(uint32_t subscription_id, const o
710729
item.requestedParameters.queueSize = 100;
711730
UA_ExtensionObject_setValueNoDelete(&item.requestedParameters.filter, &filter, &UA_TYPES[UA_TYPES_EVENTFILTER]);
712731

713-
// Debug log so integration-test failures surface the exact NodeId we
714-
// hand to the server. Trace-level diagnostic; can be tightened to a
715-
// ROS RCLCPP_DEBUG once the issue #386 server interop is stable.
716-
std::cerr << "[opcua_client] add_event_monitored_item: subId=" << subscription_id
717-
<< " nodeId=" << source_node.toString() << " selectClauses=" << (select_specs.size() + 3) << std::endl;
732+
// Trace-level diagnostic gated by ROS2_MEDKIT_OPCUA_TRACE so integration
733+
// test failures can surface the exact NodeId / select-clause count we
734+
// hand to the server, without flooding production stderr.
735+
if (opcua_trace_enabled()) {
736+
std::cerr << "[opcua_client] add_event_monitored_item: subId=" << subscription_id
737+
<< " nodeId=" << source_node.toString() << " selectClauses=" << (select_specs.size() + 3) << std::endl;
738+
}
718739

719740
UA_MonitoredItemCreateResult result =
720741
UA_Client_MonitoredItems_createEvent(impl_->client.handle(), subscription_id, UA_TIMESTAMPSTORETURN_BOTH, item,
@@ -731,8 +752,10 @@ uint32_t OpcuaClient::add_event_monitored_item(uint32_t subscription_id, const o
731752
UA_MonitoredItemCreateRequest_clear(&item);
732753
UA_EventFilter_clear(&filter);
733754

734-
std::cerr << "[opcua_client] createEvent result: status=" << UA_StatusCode_name(result.statusCode)
735-
<< " miId=" << result.monitoredItemId << std::endl;
755+
if (opcua_trace_enabled()) {
756+
std::cerr << "[opcua_client] createEvent result: status=" << UA_StatusCode_name(result.statusCode)
757+
<< " miId=" << result.monitoredItemId << std::endl;
758+
}
736759

737760
if (result.statusCode != UA_STATUSCODE_GOOD) {
738761
UA_MonitoredItemCreateResult_clear(&result);
@@ -836,16 +859,19 @@ OpcuaClient::call_method(const opcua::NodeId & object_id, const opcua::NodeId &
836859
opcua::services::call(impl_->client, object_id, method_id, opcua::Span<const opcua::Variant>(input_args));
837860
UA_StatusCode code = result.getStatusCode().get();
838861
auto arg_results = result.getInputArgumentResults();
839-
std::cerr << "[opcua_client] call_method object=" << object_id.toString() << " method=" << method_id.toString()
840-
<< " statusCode=" << UA_StatusCode_name(code);
841862
std::vector<uint32_t> arg_codes;
842863
arg_codes.reserve(arg_results.size());
843864
for (size_t i = 0; i < arg_results.size(); ++i) {
844-
uint32_t arg_code = arg_results[i].get();
845-
arg_codes.push_back(arg_code);
846-
std::cerr << " arg" << i << "=" << UA_StatusCode_name(arg_code);
865+
arg_codes.push_back(arg_results[i].get());
866+
}
867+
if (opcua_trace_enabled()) {
868+
std::cerr << "[opcua_client] call_method object=" << object_id.toString() << " method=" << method_id.toString()
869+
<< " statusCode=" << UA_StatusCode_name(code);
870+
for (size_t i = 0; i < arg_codes.size(); ++i) {
871+
std::cerr << " arg" << i << "=" << UA_StatusCode_name(arg_codes[i]);
872+
}
873+
std::cerr << std::endl;
847874
}
848-
std::cerr << std::endl;
849875

850876
auto classified = classify_call_result(code, arg_codes);
851877
if (!classified.has_value()) {

src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ namespace ros2_medkit_gateway {
3333

3434
namespace {
3535

36+
// Env-var gate for verbose per-event / per-method-call diagnostics.
37+
// See opcua_client.cpp for rationale (Copilot review on PR #387). Duplicated
38+
// to keep traces local to their dispatch sites; promoting to a public header
39+
// would expose an internal trace knob.
40+
inline bool opcua_trace_enabled() {
41+
static const bool enabled = []() {
42+
const char * v = std::getenv("ROS2_MEDKIT_OPCUA_TRACE");
43+
return v != nullptr && v[0] != '\0' && std::string(v) != "0";
44+
}();
45+
return enabled;
46+
}
47+
3648
/// Parse a JSON "value" field, coerce to the node's declared data_type, and
3749
/// validate against the optional min/max range. Shared by handle_plc_operations,
3850
/// DataProvider::write_data, and OperationProvider::execute_operation to keep
@@ -534,14 +546,27 @@ void OpcuaPlugin::on_event_alarm(const AlarmEventDelivery & delivery) {
534546
send_report_fault(delivery.entity_id, delivery.fault_code, severity_str, delivery.message);
535547
break;
536548
case AlarmAction::ReportHealed:
537-
// Fault is latched: condition is no longer active but not yet
538-
// confirmed. We don't have a dedicated HEALED reporting verb in
539-
// ReportFault.srv (only FAILED/PASSED), so we mark this as a PASSED
540-
// event - fault_manager keeps the entry in HEALED state until
541-
// confirmed, mirroring the lifecycle.
549+
// Intentional no-op (Copilot review on PR #387).
550+
//
551+
// OPC-UA AlarmConditionType HEALED means "alarm physically cleared
552+
// (ActiveState=false) but operator workflow incomplete (ack and/or
553+
// confirm pending)". Per Part 9 §5.7 the Cleared transition is
554+
// operator-driven, not statistical.
555+
//
556+
// ros2_medkit_msgs/srv/ReportFault has only FAILED/PASSED verbs and
557+
// fault_manager treats PASSED through a debounce engine. Sending
558+
// EVENT_PASSED on every latch would let fault_manager auto-clear
559+
// the fault via healing_threshold debounce, defeating the spec
560+
// contract that requires explicit operator Acknowledge + Confirm.
561+
// Conversely, healing_enabled=false would silently lose the HEALED
562+
// signal entirely.
563+
//
564+
// Until we add STATUS_LATCHED (or a similar lifecycle-distinguishing
565+
// status) to ros2_medkit_msgs/msg/Fault we keep status=CONFIRMED
566+
// until the next ClearFault fires. The operator-side gap (cannot
567+
// see "physically cleared, awaiting confirm" in the UI) is tracked
568+
// separately; see PR #387 review thread.
542569
log_info("AlarmCondition HEALED (latched, awaiting ack/confirm): " + delivery.fault_code);
543-
// No-op for now; fault_manager will keep the fault HEALED until
544-
// CLEARED. The state transition is observable via /faults/stream.
545570
break;
546571
case AlarmAction::ClearFault:
547572
log_info("AlarmCondition CLEARED: " + delivery.fault_code);
@@ -898,7 +923,7 @@ OpcuaPlugin::execute_operation(const std::string & entity_id, const std::string
898923
args.push_back(opcua::Variant::fromScalar(runtime->latest_event_id));
899924
args.push_back(opcua::Variant::fromScalar(opcua::LocalizedText("", comment)));
900925

901-
{
926+
if (opcua_trace_enabled()) {
902927
const auto * bytes = runtime->latest_event_id.data();
903928
std::cerr << "[opcua_plugin] " << operation_name << " EventId len=" << runtime->latest_event_id.length()
904929
<< " hex=";

src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include <algorithm>
1818
#include <cmath>
1919
#include <cstdio>
20+
#include <cstdlib>
2021
#include <iostream>
2122
#include <optional>
2223
#include <stdexcept>
@@ -26,6 +27,19 @@
2627

2728
namespace ros2_medkit_gateway {
2829

30+
namespace {
31+
// See opcua_client.cpp for the canonical helper. Duplicated here so the
32+
// poller's per-event traces stay together with the dispatch they describe;
33+
// promoting to a public header would expose an internal trace knob.
34+
inline bool opcua_trace_enabled() {
35+
static const bool enabled = []() {
36+
const char * v = std::getenv("ROS2_MEDKIT_OPCUA_TRACE");
37+
return v != nullptr && v[0] != '\0' && std::string(v) != "0";
38+
}();
39+
return enabled;
40+
}
41+
} // namespace
42+
2943
namespace {
3044

3145
// EventType NodeIds for ConditionRefresh bracketing per OPC-UA Part 9 §5.5.7
@@ -300,8 +314,10 @@ void OpcuaPoller::condition_refresh() {
300314
void OpcuaPoller::on_event(const AlarmEventConfig & cfg, const std::vector<opcua::Variant> & values,
301315
const opcua::NodeId & /*source_node*/, const opcua::NodeId & event_type,
302316
const opcua::NodeId & condition_id) {
303-
std::cerr << "[opcua_poller] on_event fault=" << cfg.fault_code << " event_type=" << event_type.toString()
304-
<< " condition=" << condition_id.toString() << " values=" << values.size() << std::endl;
317+
if (opcua_trace_enabled()) {
318+
std::cerr << "[opcua_poller] on_event fault=" << cfg.fault_code << " event_type=" << event_type.toString()
319+
<< " condition=" << condition_id.toString() << " values=" << values.size() << std::endl;
320+
}
305321
// Detect ConditionRefresh bracketing per Part 9 §5.5.7. The flag is for
306322
// diagnostics only; the state machine itself does not need to know
307323
// because RefreshStart / RefreshEnd notifications carry no condition
@@ -386,24 +402,28 @@ void OpcuaPoller::on_event(const AlarmEventConfig & cfg, const std::vector<opcua
386402
// Track the latest EventId for spec-compliant Acknowledge calls.
387403
if (values[kFieldEventId].isType<opcua::ByteString>()) {
388404
it->second.latest_event_id = values[kFieldEventId].getScalarCopy<opcua::ByteString>();
389-
std::cerr << "[opcua_poller] captured EventId len=" << it->second.latest_event_id.length() << " hex=";
390-
const auto * bytes = it->second.latest_event_id.data();
391-
for (size_t i = 0; i < std::min<size_t>(it->second.latest_event_id.length(), 16); ++i) {
392-
char buf[3];
393-
std::snprintf(buf, sizeof(buf), "%02x", static_cast<unsigned>(bytes[i]) & 0xffu);
394-
std::cerr << buf;
405+
if (opcua_trace_enabled()) {
406+
std::cerr << "[opcua_poller] captured EventId len=" << it->second.latest_event_id.length() << " hex=";
407+
const auto * bytes = it->second.latest_event_id.data();
408+
for (size_t i = 0; i < std::min<size_t>(it->second.latest_event_id.length(), 16); ++i) {
409+
char buf[3];
410+
std::snprintf(buf, sizeof(buf), "%02x", static_cast<unsigned>(bytes[i]) & 0xffu);
411+
std::cerr << buf;
412+
}
413+
std::cerr << std::endl;
395414
}
396-
std::cerr << std::endl;
397-
} else {
415+
} else if (opcua_trace_enabled()) {
398416
std::cerr << "[opcua_poller] EventId field not a ByteString" << std::endl;
399417
}
400418

401419
auto outcome = AlarmStateMachine::compute(prev_status, input);
402-
std::cerr << "[opcua_poller] state machine: enabled=" << input.enabled_state << " active=" << input.active_state
403-
<< " acked=" << input.acked_state << " confirmed=" << input.confirmed_state
404-
<< " shelved=" << input.shelved << " branch=" << input.branch_id_present
405-
<< " prev=" << static_cast<int>(prev_status) << " action=" << static_cast<int>(outcome.action)
406-
<< std::endl;
420+
if (opcua_trace_enabled()) {
421+
std::cerr << "[opcua_poller] state machine: enabled=" << input.enabled_state << " active=" << input.active_state
422+
<< " acked=" << input.acked_state << " confirmed=" << input.confirmed_state
423+
<< " shelved=" << input.shelved << " branch=" << input.branch_id_present
424+
<< " prev=" << static_cast<int>(prev_status) << " action=" << static_cast<int>(outcome.action)
425+
<< std::endl;
426+
}
407427
it->second.last_status = outcome.next_status;
408428
runtime_snapshot = it->second;
409429

@@ -436,8 +456,10 @@ void OpcuaPoller::on_event(const AlarmEventConfig & cfg, const std::vector<opcua
436456
std::lock_guard cb_lock(event_alarm_callback_mutex_);
437457
cb_copy = event_alarm_callback_;
438458
}
439-
std::cerr << "[opcua_poller] dispatching action=" << static_cast<int>(delivery.action)
440-
<< " cb_set=" << (cb_copy ? 1 : 0) << std::endl;
459+
if (opcua_trace_enabled()) {
460+
std::cerr << "[opcua_poller] dispatching action=" << static_cast<int>(delivery.action)
461+
<< " cb_set=" << (cb_copy ? 1 : 0) << std::endl;
462+
}
441463
if (cb_copy) {
442464
cb_copy(delivery);
443465
}

src/ros2_medkit_plugins/ros2_medkit_opcua/test/fixtures/test_alarm_server/run_ctest.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@
2121
against it via ``asyncua``, then terminates the binary cleanly.
2222
2323
Skips (exits 77 - the CTest convention for a skipped test) when
24-
``asyncua`` is not importable, so contributors who only iterate on the
25-
plugin sources do not need a Python pip install. CI installs ``asyncua``
26-
in the integration job and observes the test as a real pass / fail.
24+
``asyncua`` is not importable AND the env var
25+
``ROS2_MEDKIT_OPCUA_SMOKE_REQUIRE`` is unset / 0. Contributors iterating
26+
on the plugin sources can keep the skip; CI sets the env var so a
27+
missing ``asyncua`` becomes a hard failure (exit 1) instead of a silent
28+
skip. Closes the regression gap Copilot flagged on PR #387 where a CI
29+
step that drops the ``asyncua`` install would silently mask smoke
30+
regressions.
2731
"""
2832

2933
import os
@@ -94,6 +98,16 @@ def main():
9498
try:
9599
import asyncua # noqa: F401 pylint: disable=unused-import,import-outside-toplevel
96100
except ImportError:
101+
# Default: skip with CTest convention (77) so local dev iteration
102+
# without pip install just passes the build.
103+
# CI sets ROS2_MEDKIT_OPCUA_SMOKE_REQUIRE=1 to turn the skip into
104+
# a hard failure - so a CI job that loses its asyncua install
105+
# cannot silently bypass the smoke check.
106+
require = os.environ.get('ROS2_MEDKIT_OPCUA_SMOKE_REQUIRE', '0')
107+
if require not in ('', '0'):
108+
print('asyncua not installed and ROS2_MEDKIT_OPCUA_SMOKE_REQUIRE set - failing',
109+
file=sys.stderr)
110+
return 1
97111
print('asyncua not installed - skipping smoke test (CTest skip code)')
98112
return CTEST_SKIP
99113

src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_alarm_state_machine.cpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,10 @@ TEST(AlarmStateMachineTest, DisabledClearsHealedAlarm) {
223223
EXPECT_EQ(out.action, AlarmAction::ClearFault);
224224
}
225225

226-
TEST(AlarmStateMachineTest, DisabledNoOpWhenAlreadyCleared) {
226+
TEST(AlarmStateMachineTest, DisabledTransitionsClearedToSuppressedNoOp) {
227+
// Disabled-while-Cleared: status DOES change (Cleared -> Suppressed) but
228+
// no callback fires (NoOp action). Naming reflects both halves so a
229+
// future reader does not misread "NoOp" as "no transition".
227230
AlarmEventInput in;
228231
in.enabled_state = false;
229232
auto out = AlarmStateMachine::compute(SovdAlarmStatus::Cleared, in);

0 commit comments

Comments
 (0)