Files
secs-gem/src/gem/e84_state.cpp
T
raphael 2ea3ab796a e84: SEMI §6 handshake timers TA1/TA2/TA3
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>
2026-06-09 14:03:10 +02:00

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