783 lines
30 KiB
C++
783 lines
30 KiB
C++
#include "manager/safety_manager/include/safety_manager.h"
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <memory>
|
|
#include <string>
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
namespace cmvr::safety {
|
|
namespace {
|
|
|
|
DeviceSafetyDescriptor controlDescriptor(std::string device_id = "arm")
|
|
{
|
|
DeviceSafetyDescriptor descriptor;
|
|
descriptor.device_id = std::move(device_id);
|
|
descriptor.kind = device::DeviceKind::Arm;
|
|
descriptor.default_policy = SafetyPolicyFamily::Control;
|
|
descriptor.maximum_snapshot_age = std::chrono::seconds(1);
|
|
descriptor.requires_safe_stop = true;
|
|
descriptor.supports_active_refresh = true;
|
|
return descriptor;
|
|
}
|
|
|
|
class FakeEndpoint final : public DeviceSafetyEndpoint {
|
|
public:
|
|
explicit FakeEndpoint(DeviceSafetyDescriptor descriptor)
|
|
: descriptor_(std::move(descriptor))
|
|
{
|
|
}
|
|
|
|
DeviceSafetyDescriptor descriptor() const override
|
|
{
|
|
return descriptor_;
|
|
}
|
|
|
|
void bindPublisher(SafetySnapshotPublisher publisher) override
|
|
{
|
|
publisher_ = std::move(publisher);
|
|
}
|
|
|
|
void requestSafetyRefresh() noexcept override
|
|
{
|
|
if (!publisher_ || !publish_on_refresh) {
|
|
return;
|
|
}
|
|
DeviceSafetySnapshot snapshot;
|
|
snapshot.device_id = descriptor_.device_id;
|
|
snapshot.condition = condition;
|
|
snapshot.device_generation = generation;
|
|
snapshot.sample_sequence = ++sequence;
|
|
snapshot.observed_at = SafetyClock::now();
|
|
snapshot.connected = connected;
|
|
snapshot.operational_ready = ready;
|
|
snapshot.quiescent = quiescent;
|
|
snapshot.motion_active =
|
|
quiescent == TriState::True ? TriState::False : TriState::Unknown;
|
|
snapshot.emergency_stop_active = emergency_stop;
|
|
snapshot.protective_stop_active = protective_stop;
|
|
snapshot.fault_active = fault;
|
|
(void)publisher_(std::move(snapshot));
|
|
}
|
|
|
|
HardwareCheckResult validateBeforeDispatch(
|
|
const AdmissionPermit&) override
|
|
{
|
|
++hardware_checks;
|
|
return final_check;
|
|
}
|
|
|
|
RecoveryCheckResult reconcileAdmissionState(
|
|
const RecoveryContext&) override
|
|
{
|
|
++recoveries;
|
|
return recovery_check;
|
|
}
|
|
|
|
RecoveryCheckResult restoreOperationalState(
|
|
const RecoveryContext&) override
|
|
{
|
|
++operational_restores;
|
|
if (operational_restore_check.reconciled &&
|
|
restore_to_nominal_on_success) {
|
|
condition = SafetyCondition::Nominal;
|
|
connected = TriState::True;
|
|
ready = TriState::True;
|
|
quiescent = TriState::True;
|
|
emergency_stop = TriState::False;
|
|
protective_stop = TriState::False;
|
|
fault = TriState::False;
|
|
}
|
|
return operational_restore_check;
|
|
}
|
|
|
|
DeviceSafetyDescriptor descriptor_;
|
|
SafetySnapshotPublisher publisher_;
|
|
SafetyCondition condition{SafetyCondition::Nominal};
|
|
TriState connected{TriState::True};
|
|
TriState ready{TriState::True};
|
|
TriState quiescent{TriState::True};
|
|
TriState emergency_stop{TriState::False};
|
|
TriState protective_stop{TriState::False};
|
|
TriState fault{TriState::False};
|
|
HardwareCheckResult final_check{true, SafetyReason::None, {}};
|
|
RecoveryCheckResult recovery_check{true, SafetyReason::None, {}};
|
|
RecoveryCheckResult operational_restore_check{
|
|
true, SafetyReason::None, {}};
|
|
std::uint64_t generation{1};
|
|
std::uint64_t sequence{0};
|
|
bool publish_on_refresh{true};
|
|
std::atomic<int> hardware_checks{0};
|
|
std::atomic<int> recoveries{0};
|
|
std::atomic<int> operational_restores{0};
|
|
bool restore_to_nominal_on_success{true};
|
|
};
|
|
|
|
class FakeParticipant final : public SafetyParticipant {
|
|
public:
|
|
ParticipantDescriptor descriptor() const override
|
|
{
|
|
return {"arm", ParticipantPhase::Actuator, true,
|
|
std::chrono::milliseconds(100)};
|
|
}
|
|
|
|
BarrierToken beginBarrier(
|
|
const SafetyOperationContext& context) override
|
|
{
|
|
++barriers;
|
|
return {"arm", context.operation_id, context.safety_epoch,
|
|
static_cast<std::uint64_t>(barriers.load())};
|
|
}
|
|
|
|
ParticipantResult requestQuiesce(
|
|
const BarrierToken&,
|
|
const SafetyOperationContext&) override
|
|
{
|
|
++stop_requests;
|
|
return stop_result;
|
|
}
|
|
|
|
ParticipantResult verifyQuiescent(
|
|
const BarrierToken&,
|
|
const SafetyOperationContext&) override
|
|
{
|
|
++verifications;
|
|
return verify_result;
|
|
}
|
|
|
|
RecoveryCheckResult recoverAdmission(
|
|
const BarrierToken&,
|
|
const RecoveryContext&) override
|
|
{
|
|
++recoveries;
|
|
return recovery_result;
|
|
}
|
|
|
|
ParticipantResult releaseBarrier(
|
|
const BarrierToken&) noexcept override
|
|
{
|
|
++releases;
|
|
return release_result;
|
|
}
|
|
|
|
ParticipantResult stop_result{true, SafetyReason::None, {}};
|
|
ParticipantResult verify_result{true, SafetyReason::None, {}};
|
|
RecoveryCheckResult recovery_result{true, SafetyReason::None, {}};
|
|
ParticipantResult release_result{true, SafetyReason::None, {}};
|
|
std::atomic<int> barriers{0};
|
|
std::atomic<int> stop_requests{0};
|
|
std::atomic<int> verifications{0};
|
|
std::atomic<int> recoveries{0};
|
|
std::atomic<int> releases{0};
|
|
};
|
|
|
|
AdmissionRequest actuateRequest()
|
|
{
|
|
AdmissionRequest request;
|
|
request.command = {
|
|
"/cmvr.api.ArmService/moveJ",
|
|
CommandIntent::Actuate,
|
|
SafetyPolicyFamily::Control,
|
|
true,
|
|
false};
|
|
request.device_id = "arm";
|
|
request.command_id = "command-1";
|
|
request.deadline = SafetyClock::now() + std::chrono::seconds(1);
|
|
return request;
|
|
}
|
|
|
|
TEST(SafetyManagerTest, ShadowReportsDenyWithoutChangingLegacyBehavior)
|
|
{
|
|
SafetyManager coordinator;
|
|
ASSERT_TRUE(coordinator.registerDevice({controlDescriptor(), {}, {}}));
|
|
coordinator.markStartupComplete();
|
|
|
|
const auto result = coordinator.admit(actuateRequest());
|
|
EXPECT_TRUE(result.decision.allowed);
|
|
EXPECT_FALSE(result.decision.policy_allowed);
|
|
EXPECT_FALSE(result.decision.enforced);
|
|
EXPECT_EQ(result.decision.reason, SafetyReason::SafetyStateMissing);
|
|
EXPECT_TRUE(result.permit.has_value());
|
|
}
|
|
|
|
TEST(SafetyManagerTest,
|
|
EnforceSelectedStartupRejectsEmptyOrUnknownCoverage)
|
|
{
|
|
SafetyManagerConfig empty_config;
|
|
empty_config.enforcement_mode = EnforcementMode::EnforceSelected;
|
|
SafetyManager empty(empty_config);
|
|
const auto empty_result = empty.validateStartupCoverage(
|
|
SafetyClock::now() + std::chrono::milliseconds(10));
|
|
ASSERT_FALSE(empty_result.ready);
|
|
ASSERT_EQ(empty_result.issues.size(), 1U);
|
|
EXPECT_EQ(empty_result.issues.front().reason, SafetyReason::InvalidArgument);
|
|
|
|
SafetyManagerConfig missing_config;
|
|
missing_config.enforcement_mode = EnforcementMode::EnforceSelected;
|
|
missing_config.enforced_device_ids.insert("missing-arm");
|
|
SafetyManager missing(missing_config);
|
|
const auto missing_result = missing.validateStartupCoverage(
|
|
SafetyClock::now() + std::chrono::milliseconds(10));
|
|
ASSERT_FALSE(missing_result.ready);
|
|
ASSERT_EQ(missing_result.issues.size(), 1U);
|
|
EXPECT_EQ(missing_result.issues.front().target_id, "missing-arm");
|
|
EXPECT_EQ(missing_result.issues.front().reason, SafetyReason::DeviceNotFound);
|
|
}
|
|
|
|
TEST(SafetyManagerTest,
|
|
EnforceAllStartupRequiresEndpointParticipantAndFreshSnapshot)
|
|
{
|
|
SafetyManagerConfig config;
|
|
config.enforcement_mode = EnforcementMode::EnforceAll;
|
|
|
|
SafetyManager missing_capability(config);
|
|
ASSERT_TRUE(missing_capability.registerDevice(
|
|
{controlDescriptor(), {}, {}}));
|
|
const auto structural = missing_capability.validateStartupCoverage(
|
|
SafetyClock::now() + std::chrono::milliseconds(10));
|
|
EXPECT_FALSE(structural.ready);
|
|
EXPECT_EQ(structural.issues.size(), 2U);
|
|
|
|
SafetyManager missing_sample(config);
|
|
auto silent_endpoint =
|
|
std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
silent_endpoint->publish_on_refresh = false;
|
|
ASSERT_TRUE(missing_sample.registerDevice({
|
|
controlDescriptor(),
|
|
silent_endpoint,
|
|
std::make_shared<FakeParticipant>()}));
|
|
const auto stale = missing_sample.validateStartupCoverage(
|
|
SafetyClock::now() + std::chrono::milliseconds(10));
|
|
ASSERT_FALSE(stale.ready);
|
|
ASSERT_EQ(stale.issues.size(), 1U);
|
|
EXPECT_EQ(stale.issues.front().reason, SafetyReason::SafetyStateMissing);
|
|
}
|
|
|
|
TEST(SafetyManagerTest,
|
|
HardwareUnsafeSnapshotBlocksAdmissionButNotStructuralStartup)
|
|
{
|
|
SafetyManagerConfig config;
|
|
config.enforcement_mode = EnforcementMode::EnforceAll;
|
|
SafetyManager coordinator(config);
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
endpoint->condition = SafetyCondition::Unsafe;
|
|
endpoint->emergency_stop = TriState::True;
|
|
ASSERT_TRUE(coordinator.registerDevice({
|
|
controlDescriptor(), endpoint, std::make_shared<FakeParticipant>()}));
|
|
coordinator.updateDeviceRuntimeState(
|
|
"arm", device::ManagedDeviceState::Running,
|
|
{device::DeviceHealthState::Healthy, {}});
|
|
|
|
const auto coverage = coordinator.validateStartupCoverage(
|
|
SafetyClock::now() + std::chrono::milliseconds(50));
|
|
EXPECT_TRUE(coverage.ready);
|
|
coordinator.markStartupComplete();
|
|
const auto admission = coordinator.admit(actuateRequest());
|
|
EXPECT_FALSE(admission.decision.allowed);
|
|
EXPECT_EQ(
|
|
admission.decision.reason, SafetyReason::EmergencyStopActive);
|
|
}
|
|
|
|
TEST(SafetyManagerTest, EnforceAllFailsClosedOnUnknownControlState)
|
|
{
|
|
SafetyManagerConfig config;
|
|
config.enforcement_mode = EnforcementMode::EnforceAll;
|
|
SafetyManager coordinator(config);
|
|
ASSERT_TRUE(coordinator.registerDevice({controlDescriptor(), {}, {}}));
|
|
coordinator.markStartupComplete();
|
|
|
|
const auto result = coordinator.admit(actuateRequest());
|
|
EXPECT_FALSE(result.decision.allowed);
|
|
EXPECT_FALSE(result.decision.policy_allowed);
|
|
EXPECT_TRUE(result.decision.enforced);
|
|
EXPECT_FALSE(result.permit.has_value());
|
|
}
|
|
|
|
TEST(SafetyManagerTest, ControlSafetyBitsMustBeExplicitlyFalse)
|
|
{
|
|
SafetyManagerConfig config;
|
|
config.enforcement_mode = EnforcementMode::EnforceAll;
|
|
SafetyManager coordinator(config);
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
endpoint->protective_stop = TriState::Unknown;
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, {}}));
|
|
coordinator.updateDeviceRuntimeState(
|
|
"arm", device::ManagedDeviceState::Running,
|
|
{device::DeviceHealthState::Healthy, {}});
|
|
coordinator.markStartupComplete();
|
|
|
|
const auto result = coordinator.admit(actuateRequest());
|
|
EXPECT_FALSE(result.decision.allowed);
|
|
EXPECT_EQ(result.decision.reason, SafetyReason::ProtectiveStopActive);
|
|
ASSERT_EQ(coordinator.snapshot().devices.size(), 1U);
|
|
EXPECT_EQ(
|
|
coordinator.snapshot().devices.front().admission_state,
|
|
DeviceAdmissionState::Blocked);
|
|
}
|
|
|
|
TEST(SafetyManagerTest, EnforcedDispatchRunsFinalHardwareCheck)
|
|
{
|
|
SafetyManagerConfig config;
|
|
config.enforcement_mode = EnforcementMode::EnforceAll;
|
|
SafetyManager coordinator(config);
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, {}}));
|
|
coordinator.updateDeviceRuntimeState(
|
|
"arm", device::ManagedDeviceState::Running,
|
|
{device::DeviceHealthState::Healthy, {}});
|
|
coordinator.markStartupComplete();
|
|
|
|
auto admission = coordinator.admit(actuateRequest());
|
|
ASSERT_TRUE(admission.decision.allowed) << admission.decision.detail;
|
|
ASSERT_TRUE(admission.permit.has_value());
|
|
auto dispatch = coordinator.beginDispatch(*admission.permit);
|
|
EXPECT_TRUE(dispatch.acquired());
|
|
EXPECT_EQ(endpoint->hardware_checks.load(), 1);
|
|
}
|
|
|
|
TEST(SafetyManagerTest,
|
|
StartActivityMayEnterFromRestrictedButActuationMayNot)
|
|
{
|
|
SafetyManagerConfig config;
|
|
config.enforcement_mode = EnforcementMode::EnforceAll;
|
|
SafetyManager coordinator(config);
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
endpoint->condition = SafetyCondition::Restricted;
|
|
endpoint->ready = TriState::False;
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, {}}));
|
|
coordinator.updateDeviceRuntimeState(
|
|
"arm", device::ManagedDeviceState::Running,
|
|
{device::DeviceHealthState::Healthy, {}});
|
|
coordinator.markStartupComplete();
|
|
|
|
auto start = actuateRequest();
|
|
start.command.intent = CommandIntent::StartActivity;
|
|
const auto start_result = coordinator.admit(start);
|
|
ASSERT_TRUE(start_result.decision.allowed)
|
|
<< start_result.decision.detail;
|
|
ASSERT_TRUE(start_result.permit.has_value());
|
|
EXPECT_TRUE(coordinator.beginDispatch(*start_result.permit).acquired());
|
|
|
|
const auto actuation = coordinator.admit(actuateRequest());
|
|
EXPECT_FALSE(actuation.decision.allowed);
|
|
EXPECT_EQ(actuation.decision.reason, SafetyReason::HardwareUnsafe);
|
|
}
|
|
|
|
TEST(SafetyManagerTest, SuccessfulStopInvalidatesOldPermitAndReopens)
|
|
{
|
|
SafetyManagerConfig config;
|
|
config.enforcement_mode = EnforcementMode::EnforceAll;
|
|
SafetyManager coordinator(config);
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
auto participant = std::make_shared<FakeParticipant>();
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, participant}));
|
|
coordinator.updateDeviceRuntimeState(
|
|
"arm", device::ManagedDeviceState::Running,
|
|
{device::DeviceHealthState::Healthy, {}});
|
|
coordinator.markStartupComplete();
|
|
|
|
auto admission = coordinator.admit(actuateRequest());
|
|
ASSERT_TRUE(admission.permit.has_value());
|
|
const auto old_epoch = admission.permit->safety_epoch;
|
|
const auto stopped = coordinator.stopAll(
|
|
"stop-1", SafetyClock::now() + std::chrono::seconds(1));
|
|
ASSERT_TRUE(stopped.success);
|
|
EXPECT_EQ(stopped.system_state, SystemAdmissionState::Open);
|
|
EXPECT_GT(stopped.current_safety_epoch, old_epoch);
|
|
const auto revalidated =
|
|
coordinator.revalidatePermit(*admission.permit);
|
|
EXPECT_FALSE(revalidated.safe);
|
|
EXPECT_EQ(revalidated.reason, SafetyReason::SafetyLatched);
|
|
EXPECT_FALSE(coordinator.beginDispatch(*admission.permit).acquired());
|
|
EXPECT_EQ(participant->stop_requests.load(), 1);
|
|
EXPECT_EQ(participant->verifications.load(), 1);
|
|
const auto snapshot = coordinator.snapshot();
|
|
ASSERT_EQ(snapshot.participants.size(), 1U);
|
|
EXPECT_EQ(snapshot.participants.front().descriptor.participant_id, "arm");
|
|
EXPECT_FALSE(snapshot.participants.front().barrier_active);
|
|
EXPECT_FALSE(snapshot.participants.front().barrier_retained);
|
|
EXPECT_TRUE(snapshot.participants.front().last_request.recorded);
|
|
EXPECT_TRUE(snapshot.participants.front().last_request.success);
|
|
EXPECT_TRUE(snapshot.participants.front().last_verify.recorded);
|
|
EXPECT_TRUE(snapshot.participants.front().last_verify.success);
|
|
EXPECT_TRUE(snapshot.participants.front().last_release.recorded);
|
|
EXPECT_TRUE(snapshot.participants.front().last_release.success);
|
|
}
|
|
|
|
TEST(SafetyManagerTest, FailedStopRequiresVerifiedRecovery)
|
|
{
|
|
SafetyManagerConfig config;
|
|
config.enforcement_mode = EnforcementMode::EnforceAll;
|
|
SafetyManager coordinator(config);
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
auto participant = std::make_shared<FakeParticipant>();
|
|
participant->verify_result = {
|
|
false, SafetyReason::StopUnconfirmed, "motion not confirmed"};
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, participant}));
|
|
coordinator.updateDeviceRuntimeState(
|
|
"arm", device::ManagedDeviceState::Running,
|
|
{device::DeviceHealthState::Healthy, {}});
|
|
coordinator.markStartupComplete();
|
|
|
|
const auto stopped = coordinator.stopAll(
|
|
"stop-failed", SafetyClock::now() + std::chrono::seconds(1));
|
|
ASSERT_FALSE(stopped.success);
|
|
ASSERT_EQ(stopped.system_state, SystemAdmissionState::Latched);
|
|
const auto latched = coordinator.snapshot();
|
|
ASSERT_EQ(latched.participants.size(), 1U);
|
|
EXPECT_TRUE(latched.participants.front().barrier_active);
|
|
EXPECT_TRUE(latched.participants.front().barrier_retained);
|
|
EXPECT_TRUE(latched.participants.front().last_verify.recorded);
|
|
EXPECT_FALSE(latched.participants.front().last_verify.success);
|
|
EXPECT_FALSE(latched.participants.front().last_release.recorded);
|
|
|
|
participant->verify_result = {true, SafetyReason::None, {}};
|
|
RecoveryRequest request;
|
|
request.recovery_id = "recovery-1";
|
|
request.all_devices = true;
|
|
request.expected_safety_epoch = stopped.current_safety_epoch;
|
|
request.verify_only = false;
|
|
request.reason = "operator confirmed work cell is clear";
|
|
request.deadline = SafetyClock::now() + std::chrono::seconds(1);
|
|
const auto recovered = coordinator.recover(request);
|
|
EXPECT_EQ(recovered.result, RecoveryResultCode::Recovered);
|
|
EXPECT_EQ(recovered.system_state, SystemAdmissionState::Open);
|
|
EXPECT_EQ(endpoint->recoveries.load(), 1);
|
|
EXPECT_EQ(participant->recoveries.load(), 1);
|
|
const auto reopened = coordinator.snapshot();
|
|
ASSERT_EQ(reopened.participants.size(), 1U);
|
|
EXPECT_FALSE(reopened.participants.front().barrier_active);
|
|
EXPECT_FALSE(reopened.participants.front().barrier_retained);
|
|
EXPECT_TRUE(reopened.participants.front().last_release.recorded);
|
|
EXPECT_TRUE(reopened.participants.front().last_release.success);
|
|
}
|
|
|
|
TEST(SafetyManagerTest, RecoveryCannotIgnoreEmergencyStop)
|
|
{
|
|
SafetyManager coordinator;
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
auto participant = std::make_shared<FakeParticipant>();
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, participant}));
|
|
coordinator.markStartupComplete();
|
|
coordinator.quarantineDevice("arm", SafetyReason::OutcomeUnknown);
|
|
const auto before = coordinator.snapshot();
|
|
|
|
endpoint->emergency_stop = TriState::True;
|
|
RecoveryRequest request;
|
|
request.recovery_id = "recovery-estop";
|
|
request.all_devices = true;
|
|
request.expected_safety_epoch = before.safety_epoch;
|
|
request.verify_only = false;
|
|
request.reason = "test";
|
|
request.deadline = SafetyClock::now() + std::chrono::seconds(1);
|
|
const auto recovered = coordinator.recover(request);
|
|
EXPECT_EQ(recovered.result, RecoveryResultCode::BlockerRemains);
|
|
EXPECT_EQ(recovered.system_state, SystemAdmissionState::Latched);
|
|
ASSERT_FALSE(recovered.targets.empty());
|
|
EXPECT_EQ(
|
|
recovered.targets.front().reason,
|
|
SafetyReason::EmergencyStopActive);
|
|
EXPECT_EQ(endpoint->recoveries.load(), 0);
|
|
}
|
|
|
|
TEST(SafetyManagerTest,
|
|
OperationalRestoreClearsRecoverableHardwareAndSoftwareState)
|
|
{
|
|
SafetyManager coordinator;
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
endpoint->condition = SafetyCondition::Unsafe;
|
|
endpoint->ready = TriState::False;
|
|
endpoint->protective_stop = TriState::True;
|
|
endpoint->quiescent = TriState::True;
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, {}}));
|
|
coordinator.markStartupComplete();
|
|
coordinator.quarantineDevice("arm", SafetyReason::OutcomeUnknown);
|
|
const auto before = coordinator.snapshot();
|
|
|
|
RecoveryRequest request;
|
|
request.recovery_id = "restore-operational";
|
|
request.all_devices = true;
|
|
request.expected_safety_epoch = before.safety_epoch;
|
|
request.verify_only = false;
|
|
request.restore_operational_state = true;
|
|
request.reason = "operator confirmed the work cell is clear";
|
|
request.deadline = SafetyClock::now() + std::chrono::seconds(1);
|
|
request.authorize_clear = [] { return true; };
|
|
|
|
const auto restored = coordinator.recover(request);
|
|
|
|
EXPECT_EQ(restored.result, RecoveryResultCode::Recovered);
|
|
EXPECT_EQ(restored.system_state, SystemAdmissionState::Open);
|
|
ASSERT_EQ(restored.targets.size(), 1U);
|
|
EXPECT_TRUE(restored.targets.front().success)
|
|
<< restored.targets.front().detail;
|
|
EXPECT_EQ(endpoint->operational_restores.load(), 1);
|
|
EXPECT_EQ(endpoint->recoveries.load(), 1);
|
|
EXPECT_EQ(
|
|
coordinator.snapshot().devices.front().admission_state,
|
|
DeviceAdmissionState::Open);
|
|
}
|
|
|
|
TEST(SafetyManagerTest, OperationalRestoreFailsClosedOnActiveEmergencyStop)
|
|
{
|
|
SafetyManager coordinator;
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
endpoint->condition = SafetyCondition::Unsafe;
|
|
endpoint->ready = TriState::False;
|
|
endpoint->emergency_stop = TriState::True;
|
|
endpoint->quiescent = TriState::True;
|
|
endpoint->operational_restore_check = {
|
|
false,
|
|
SafetyReason::EmergencyStopActive,
|
|
"hardware emergency-stop input is still active"};
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, {}}));
|
|
coordinator.markStartupComplete();
|
|
coordinator.quarantineDevice("arm", SafetyReason::OutcomeUnknown);
|
|
const auto before = coordinator.snapshot();
|
|
|
|
RecoveryRequest request;
|
|
request.recovery_id = "restore-active-estop";
|
|
request.all_devices = true;
|
|
request.expected_safety_epoch = before.safety_epoch;
|
|
request.verify_only = false;
|
|
request.restore_operational_state = true;
|
|
request.reason = "operator requested recovery";
|
|
request.deadline = SafetyClock::now() + std::chrono::seconds(1);
|
|
request.authorize_clear = [] { return true; };
|
|
|
|
const auto restored = coordinator.recover(request);
|
|
|
|
EXPECT_EQ(restored.result, RecoveryResultCode::BlockerRemains);
|
|
EXPECT_EQ(restored.system_state, SystemAdmissionState::Latched);
|
|
ASSERT_EQ(restored.targets.size(), 1U);
|
|
EXPECT_FALSE(restored.targets.front().success);
|
|
EXPECT_EQ(
|
|
restored.targets.front().reason,
|
|
SafetyReason::EmergencyStopActive);
|
|
EXPECT_EQ(endpoint->operational_restores.load(), 0);
|
|
EXPECT_EQ(endpoint->recoveries.load(), 0);
|
|
}
|
|
|
|
TEST(SafetyManagerTest, OperationalRestoreDoesNotRecoverAnActiveDevice)
|
|
{
|
|
SafetyManager coordinator;
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
endpoint->condition = SafetyCondition::Unsafe;
|
|
endpoint->ready = TriState::False;
|
|
endpoint->quiescent = TriState::False;
|
|
endpoint->fault = TriState::True;
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, {}}));
|
|
coordinator.markStartupComplete();
|
|
coordinator.quarantineDevice("arm", SafetyReason::OutcomeUnknown);
|
|
|
|
RecoveryRequest request;
|
|
request.recovery_id = "restore-active-device";
|
|
request.all_devices = true;
|
|
request.expected_safety_epoch = coordinator.snapshot().safety_epoch;
|
|
request.verify_only = false;
|
|
request.restore_operational_state = true;
|
|
request.reason = "operator requested recovery";
|
|
request.deadline = SafetyClock::now() + std::chrono::seconds(1);
|
|
request.authorize_clear = [] { return true; };
|
|
|
|
const auto restored = coordinator.recover(request);
|
|
|
|
EXPECT_EQ(restored.result, RecoveryResultCode::BlockerRemains);
|
|
ASSERT_EQ(restored.targets.size(), 1U);
|
|
EXPECT_EQ(
|
|
restored.targets.front().reason,
|
|
SafetyReason::DeviceStillMoving);
|
|
EXPECT_EQ(endpoint->operational_restores.load(), 0);
|
|
EXPECT_EQ(endpoint->recoveries.load(), 0);
|
|
}
|
|
|
|
TEST(SafetyManagerTest, OperationalRestoreAuditFailurePreventsDeviceRecovery)
|
|
{
|
|
SafetyManager coordinator;
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
endpoint->condition = SafetyCondition::Unsafe;
|
|
endpoint->ready = TriState::False;
|
|
endpoint->protective_stop = TriState::True;
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, {}}));
|
|
coordinator.markStartupComplete();
|
|
coordinator.quarantineDevice("arm", SafetyReason::OutcomeUnknown);
|
|
|
|
RecoveryRequest request;
|
|
request.recovery_id = "restore-audit-failure";
|
|
request.all_devices = true;
|
|
request.expected_safety_epoch = coordinator.snapshot().safety_epoch;
|
|
request.verify_only = false;
|
|
request.restore_operational_state = true;
|
|
request.reason = "operator requested recovery";
|
|
request.deadline = SafetyClock::now() + std::chrono::seconds(1);
|
|
request.authorize_clear = [] { return false; };
|
|
|
|
const auto restored = coordinator.recover(request);
|
|
|
|
EXPECT_EQ(restored.result, RecoveryResultCode::BlockerRemains);
|
|
ASSERT_EQ(restored.targets.size(), 1U);
|
|
EXPECT_EQ(
|
|
restored.targets.front().reason,
|
|
SafetyReason::RecoveryAuditFailed);
|
|
EXPECT_EQ(endpoint->operational_restores.load(), 0);
|
|
EXPECT_EQ(endpoint->recoveries.load(), 0);
|
|
}
|
|
|
|
TEST(SafetyManagerTest, OperationalRestoreRequiresExplicitAuthorization)
|
|
{
|
|
SafetyManager coordinator;
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, {}}));
|
|
coordinator.markStartupComplete();
|
|
|
|
RecoveryRequest request;
|
|
request.recovery_id = "restore-without-authorization";
|
|
request.all_devices = true;
|
|
request.expected_safety_epoch = coordinator.snapshot().safety_epoch;
|
|
request.verify_only = false;
|
|
request.restore_operational_state = true;
|
|
request.reason = "operator requested recovery";
|
|
|
|
const auto restored = coordinator.recover(request);
|
|
|
|
EXPECT_EQ(restored.result, RecoveryResultCode::Failed);
|
|
ASSERT_EQ(restored.targets.size(), 1U);
|
|
EXPECT_EQ(
|
|
restored.targets.front().reason,
|
|
SafetyReason::InvalidArgument);
|
|
EXPECT_EQ(endpoint->operational_restores.load(), 0);
|
|
}
|
|
|
|
TEST(SafetyManagerTest,
|
|
OperationalRestoreAuditFailureWithNoDevicesRemainsLatched)
|
|
{
|
|
SafetyManager coordinator;
|
|
coordinator.markStartupComplete();
|
|
|
|
RecoveryRequest request;
|
|
request.recovery_id = "empty-restore-audit-failure";
|
|
request.all_devices = true;
|
|
request.expected_safety_epoch = coordinator.snapshot().safety_epoch;
|
|
request.verify_only = false;
|
|
request.restore_operational_state = true;
|
|
request.reason = "operator requested recovery";
|
|
request.deadline = SafetyClock::now() + std::chrono::seconds(1);
|
|
request.authorize_clear = [] { return false; };
|
|
|
|
const auto restored = coordinator.recover(request);
|
|
|
|
EXPECT_EQ(restored.result, RecoveryResultCode::BlockerRemains);
|
|
EXPECT_EQ(restored.system_state, SystemAdmissionState::Latched);
|
|
ASSERT_EQ(restored.targets.size(), 1U);
|
|
EXPECT_EQ(restored.targets.front().target_id, "system");
|
|
EXPECT_EQ(
|
|
restored.targets.front().reason,
|
|
SafetyReason::RecoveryAuditFailed);
|
|
}
|
|
|
|
TEST(SafetyManagerTest,
|
|
OperationalRestoreDoesNotPartiallyReleaseSoftwareAdmission)
|
|
{
|
|
SafetyManager coordinator;
|
|
auto first = std::make_shared<FakeEndpoint>(
|
|
controlDescriptor("arm-first"));
|
|
first->condition = SafetyCondition::Unsafe;
|
|
first->ready = TriState::False;
|
|
first->protective_stop = TriState::True;
|
|
auto second = std::make_shared<FakeEndpoint>(
|
|
controlDescriptor("arm-second"));
|
|
second->condition = SafetyCondition::Unsafe;
|
|
second->ready = TriState::False;
|
|
second->fault = TriState::True;
|
|
second->operational_restore_check = {
|
|
false, SafetyReason::DeviceFault, "fault reset failed"};
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor("arm-first"), first, {}}));
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor("arm-second"), second, {}}));
|
|
coordinator.markStartupComplete();
|
|
coordinator.quarantineDevice(
|
|
"arm-first", SafetyReason::OutcomeUnknown);
|
|
coordinator.quarantineDevice(
|
|
"arm-second", SafetyReason::OutcomeUnknown);
|
|
|
|
RecoveryRequest request;
|
|
request.recovery_id = "restore-partial-failure";
|
|
request.all_devices = true;
|
|
request.expected_safety_epoch = coordinator.snapshot().safety_epoch;
|
|
request.verify_only = false;
|
|
request.restore_operational_state = true;
|
|
request.reason = "operator requested recovery";
|
|
request.deadline = SafetyClock::now() + std::chrono::seconds(1);
|
|
request.authorize_clear = [] { return true; };
|
|
|
|
const auto restored = coordinator.recover(request);
|
|
|
|
EXPECT_EQ(restored.result, RecoveryResultCode::BlockerRemains);
|
|
EXPECT_EQ(restored.system_state, SystemAdmissionState::Latched);
|
|
EXPECT_EQ(first->operational_restores.load(), 1);
|
|
EXPECT_EQ(second->operational_restores.load(), 1);
|
|
EXPECT_EQ(first->recoveries.load(), 0);
|
|
EXPECT_EQ(second->recoveries.load(), 0);
|
|
const auto snapshot = coordinator.snapshot();
|
|
ASSERT_EQ(snapshot.devices.size(), 2U);
|
|
for (const auto& device : snapshot.devices) {
|
|
EXPECT_EQ(
|
|
device.admission_state,
|
|
DeviceAdmissionState::Quarantined)
|
|
<< device.descriptor.device_id;
|
|
}
|
|
}
|
|
|
|
TEST(SafetyManagerTest, RecoveryAuditFailureCannotReleaseLatch)
|
|
{
|
|
SafetyManager coordinator;
|
|
auto endpoint = std::make_shared<FakeEndpoint>(controlDescriptor());
|
|
auto participant = std::make_shared<FakeParticipant>();
|
|
participant->verify_result = {
|
|
false, SafetyReason::StopUnconfirmed, "motion not confirmed"};
|
|
ASSERT_TRUE(coordinator.registerDevice(
|
|
{controlDescriptor(), endpoint, participant}));
|
|
coordinator.markStartupComplete();
|
|
|
|
const auto stopped = coordinator.stopAll(
|
|
"stop-audit", SafetyClock::now() + std::chrono::seconds(1));
|
|
ASSERT_FALSE(stopped.success);
|
|
participant->verify_result = {true, SafetyReason::None, {}};
|
|
|
|
RecoveryRequest request;
|
|
request.recovery_id = "recovery-audit";
|
|
request.all_devices = true;
|
|
request.expected_safety_epoch = stopped.current_safety_epoch;
|
|
request.verify_only = false;
|
|
request.reason = "operator inspected the work cell";
|
|
request.deadline = SafetyClock::now() + std::chrono::seconds(1);
|
|
request.authorize_clear = [] { return false; };
|
|
const auto recovered = coordinator.recover(request);
|
|
|
|
EXPECT_EQ(recovered.result, RecoveryResultCode::BlockerRemains);
|
|
EXPECT_EQ(recovered.system_state, SystemAdmissionState::Latched);
|
|
ASSERT_FALSE(recovered.targets.empty());
|
|
EXPECT_EQ(
|
|
recovered.targets.front().reason,
|
|
SafetyReason::RecoveryAuditFailed);
|
|
EXPECT_EQ(endpoint->recoveries.load(), 0);
|
|
EXPECT_EQ(participant->recoveries.load(), 0);
|
|
EXPECT_EQ(participant->releases.load(), 0);
|
|
}
|
|
|
|
} // namespace
|
|
} // namespace cmvr::safety
|