2ea3ab796a
E84StateMachine had the full signal-level handshake but no timer
enforcement. In a real AMHS that's a deadlock: if equipment is slow to
assert L_REQ / U_REQ, or AMHS is slow to assert BUSY / COMPT, neither
side notices — the wires just sit stuck. SEMI E84 §6 mandates three
timers that bound each leg of the dance.
TA1 — armed in ValidAsserted, cancelled in Load/UnloadReady.
AMHS bounds how long equipment takes to acknowledge VALID.
TA2 — armed in Load/UnloadReady, cancelled in Transferring.
Equipment bounds how long AMHS takes to start the transfer.
TA3 — armed in Transferring, cancelled on Complete.
Equipment bounds the BUSY-phase duration.
The FSM stays I/O-free (it's the design invariant): arm/cancel are
delivered via callbacks, the application owns the asio::steady_timer,
and the application calls `fsm.on_timeout(id)` when its real clock
fires. Stale on_timeout calls (post-cancel race) are no-ops.
On expiry, the FSM transitions to a new `HandoffFault` state, records
the `E84Fault` reason, fires the optional fault_handler, and latches
the fault until `reset()`. Signal jitter on the wires cannot silently
clear a recorded handshake timeout — once you've crossed the timer,
you stop.
Defaults are all-zero, which disables arming. This is what every
existing test relies on, and what back-to-back simulation (no
wall-clock) needs. Production tools call `set_timeouts({2s, 2s, 60s})`
or whatever their port spec dictates.
12 new test cases / 59 assertions: arming per state, cancelling per
exit, expiry-to-fault for all three timers, ES cancels everything,
stale-expiry no-op, fault latching across signal jitter, and a
full-cycle arm/cancel trace.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
192 lines
6.1 KiB
C++
192 lines
6.1 KiB
C++
#include "secsgem/gem/e84_state.hpp"
|
|
|
|
namespace secsgem::gem {
|
|
|
|
const char* e84_signal_name(E84Signal s) {
|
|
switch (s) {
|
|
case E84Signal::CS_0: return "CS_0";
|
|
case E84Signal::CS_1: return "CS_1";
|
|
case E84Signal::VALID: return "VALID";
|
|
case E84Signal::TR_REQ: return "TR_REQ";
|
|
case E84Signal::BUSY: return "BUSY";
|
|
case E84Signal::COMPT: return "COMPT";
|
|
case E84Signal::L_REQ: return "L_REQ";
|
|
case E84Signal::U_REQ: return "U_REQ";
|
|
case E84Signal::READY: return "READY";
|
|
case E84Signal::ES: return "ES";
|
|
}
|
|
return "?";
|
|
}
|
|
|
|
const char* e84_state_name(E84State s) {
|
|
switch (s) {
|
|
case E84State::Idle: return "Idle";
|
|
case E84State::CarrierPresent: return "CarrierPresent";
|
|
case E84State::ValidAsserted: return "ValidAsserted";
|
|
case E84State::LoadReady: return "LoadReady";
|
|
case E84State::UnloadReady: return "UnloadReady";
|
|
case E84State::Transferring: return "Transferring";
|
|
case E84State::Complete: return "Complete";
|
|
case E84State::EmergencyStop: return "EmergencyStop";
|
|
case E84State::HandoffFault: return "HandoffFault";
|
|
case E84State::NoState: return "NoState";
|
|
}
|
|
return "?";
|
|
}
|
|
|
|
const char* e84_timer_name(E84TimerId t) {
|
|
switch (t) {
|
|
case E84TimerId::TA1: return "TA1";
|
|
case E84TimerId::TA2: return "TA2";
|
|
case E84TimerId::TA3: return "TA3";
|
|
}
|
|
return "?";
|
|
}
|
|
|
|
const char* e84_fault_name(E84Fault f) {
|
|
switch (f) {
|
|
case E84Fault::None: return "None";
|
|
case E84Fault::TA1Expired: return "TA1Expired";
|
|
case E84Fault::TA2Expired: return "TA2Expired";
|
|
case E84Fault::TA3Expired: return "TA3Expired";
|
|
}
|
|
return "?";
|
|
}
|
|
|
|
void E84StateMachine::on_signal_change(E84Signal s, bool value) {
|
|
signals_.set(s, value);
|
|
reevaluate(s);
|
|
}
|
|
|
|
void E84StateMachine::on_timeout(E84TimerId id) {
|
|
const auto idx = static_cast<std::size_t>(id) - 1;
|
|
if (!armed_[idx]) return; // stale expiry, FSM already moved on
|
|
armed_[idx] = false; // consume the arming
|
|
|
|
// Record the fault and transition to HandoffFault. Other armed
|
|
// timers are cancelled by enter_state_'s update_timers_for_state_.
|
|
switch (id) {
|
|
case E84TimerId::TA1: fault_ = E84Fault::TA1Expired; break;
|
|
case E84TimerId::TA2: fault_ = E84Fault::TA2Expired; break;
|
|
case E84TimerId::TA3: fault_ = E84Fault::TA3Expired; break;
|
|
}
|
|
if (on_fault_) on_fault_(fault_);
|
|
|
|
// ES as trigger is the closest synthetic signal for "the handshake
|
|
// collapsed out-of-band." The fault_handler delivers the precise
|
|
// reason; observers that just track state changes still get notified.
|
|
enter_state_(E84State::HandoffFault, E84Signal::ES);
|
|
}
|
|
|
|
bool E84StateMachine::timer_armed(E84TimerId id) const {
|
|
return armed_[static_cast<std::size_t>(id) - 1];
|
|
}
|
|
|
|
void E84StateMachine::reset() {
|
|
signals_.clear();
|
|
cancel_all_timers_();
|
|
fault_ = E84Fault::None;
|
|
if (state_ != E84State::Idle) {
|
|
const auto prev = state_;
|
|
state_ = E84State::Idle;
|
|
if (on_change_) on_change_(prev, state_, E84Signal::ES); // trigger is arbitrary
|
|
}
|
|
}
|
|
|
|
void E84StateMachine::reevaluate(E84Signal trigger) {
|
|
// A latched HandoffFault sticks until reset() — signal jitter on the
|
|
// wires shouldn't silently clear a recorded handshake timeout.
|
|
if (state_ == E84State::HandoffFault) return;
|
|
|
|
E84State next = state_;
|
|
|
|
// Emergency stop dominates.
|
|
if (signals_.get(E84Signal::ES)) {
|
|
next = E84State::EmergencyStop;
|
|
} else if (signals_.get(E84Signal::COMPT)) {
|
|
next = E84State::Complete;
|
|
} else if (signals_.get(E84Signal::BUSY)) {
|
|
next = E84State::Transferring;
|
|
} else if (signals_.get(E84Signal::VALID)) {
|
|
if (signals_.get(E84Signal::L_REQ)) next = E84State::LoadReady;
|
|
else if (signals_.get(E84Signal::U_REQ)) next = E84State::UnloadReady;
|
|
else next = E84State::ValidAsserted;
|
|
} else if (signals_.get(E84Signal::CS_0) || signals_.get(E84Signal::CS_1)) {
|
|
next = E84State::CarrierPresent;
|
|
} else {
|
|
next = E84State::Idle;
|
|
}
|
|
|
|
if (next != state_) enter_state_(next, trigger);
|
|
}
|
|
|
|
void E84StateMachine::enter_state_(E84State next, E84Signal trigger) {
|
|
const auto prev = state_;
|
|
state_ = next;
|
|
update_timers_for_state_(next);
|
|
if (on_change_) on_change_(prev, state_, trigger);
|
|
}
|
|
|
|
void E84StateMachine::update_timers_for_state_(E84State target) {
|
|
// Arm exactly the timer that should be running in `target`; cancel
|
|
// every other still-armed timer. This makes the arm/cancel pair on
|
|
// the application side idempotent regardless of which state we came
|
|
// from — the FSM is the source of truth.
|
|
std::optional<E84TimerId> to_arm;
|
|
switch (target) {
|
|
case E84State::ValidAsserted:
|
|
to_arm = E84TimerId::TA1; break;
|
|
case E84State::LoadReady:
|
|
case E84State::UnloadReady:
|
|
to_arm = E84TimerId::TA2; break;
|
|
case E84State::Transferring:
|
|
to_arm = E84TimerId::TA3; break;
|
|
case E84State::Idle:
|
|
case E84State::CarrierPresent:
|
|
case E84State::Complete:
|
|
case E84State::EmergencyStop:
|
|
case E84State::HandoffFault:
|
|
case E84State::NoState:
|
|
break;
|
|
}
|
|
|
|
for (auto id : {E84TimerId::TA1, E84TimerId::TA2, E84TimerId::TA3}) {
|
|
if (to_arm && *to_arm == id) {
|
|
if (!armed_[static_cast<std::size_t>(id) - 1]) arm_timer_(id);
|
|
} else {
|
|
cancel_timer_(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
void E84StateMachine::arm_timer_(E84TimerId id) {
|
|
const auto d = timeout_for_(id);
|
|
if (d.count() <= 0) return; // 0 = disabled; no arming
|
|
armed_[static_cast<std::size_t>(id) - 1] = true;
|
|
if (on_arm_) on_arm_(id, d);
|
|
}
|
|
|
|
void E84StateMachine::cancel_timer_(E84TimerId id) {
|
|
const auto idx = static_cast<std::size_t>(id) - 1;
|
|
if (!armed_[idx]) return;
|
|
armed_[idx] = false;
|
|
if (on_cancel_) on_cancel_(id);
|
|
}
|
|
|
|
void E84StateMachine::cancel_all_timers_() {
|
|
for (auto id : {E84TimerId::TA1, E84TimerId::TA2, E84TimerId::TA3}) {
|
|
cancel_timer_(id);
|
|
}
|
|
}
|
|
|
|
std::chrono::milliseconds E84StateMachine::timeout_for_(E84TimerId id) const {
|
|
switch (id) {
|
|
case E84TimerId::TA1: return timeouts_.ta1;
|
|
case E84TimerId::TA2: return timeouts_.ta2;
|
|
case E84TimerId::TA3: return timeouts_.ta3;
|
|
}
|
|
return std::chrono::milliseconds{0};
|
|
}
|
|
|
|
} // namespace secsgem::gem
|