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>
This commit is contained in:
2026-06-09 14:03:10 +02:00
parent a4419e15cd
commit 2ea3ab796a
5 changed files with 483 additions and 5 deletions
+118 -4
View File
@@ -28,18 +28,64 @@ const char* e84_state_name(E84State s) {
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;
@@ -48,6 +94,10 @@ void E84StateMachine::reset() {
}
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.
@@ -67,11 +117,75 @@ void E84StateMachine::reevaluate(E84Signal trigger) {
next = E84State::Idle;
}
if (next != state_) {
const auto prev = state_;
state_ = next;
if (on_change_) on_change_(prev, state_, trigger);
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