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
+1
View File
@@ -131,6 +131,7 @@ add_executable(secsgem_tests
tests/test_s9_fallback.cpp tests/test_s9_fallback.cpp
tests/test_e84.cpp tests/test_e84.cpp
tests/test_e84_ports.cpp tests/test_e84_ports.cpp
tests/test_e84_timers.cpp
tests/test_e42_formatted_pp.cpp tests/test_e42_formatted_pp.cpp
tests/test_gem300_scenario.cpp tests/test_gem300_scenario.cpp
tests/test_wire_ceid_emission.cpp tests/test_wire_ceid_emission.cpp
+1
View File
@@ -197,6 +197,7 @@ Legend:
| Capability | Status | Spec ref | Messages | Notes | | Capability | Status | Spec ref | Messages | Notes |
|---------------------------------------|--------|----------|----------|-------| |---------------------------------------|--------|----------|----------|-------|
| Handoff state machine | ✅ | E84 | — | Full LOAD / UNLOAD signal vocabulary (CS_0/CS_1, VALID, TR_REQ, BUSY, COMPT, AM_AVBL, ES). Per-port via `E84PortStore` keyed by `port_id`; independent FSMs run in parallel per load port. | | Handoff state machine | ✅ | E84 | — | Full LOAD / UNLOAD signal vocabulary (CS_0/CS_1, VALID, TR_REQ, BUSY, COMPT, AM_AVBL, ES). Per-port via `E84PortStore` keyed by `port_id`; independent FSMs run in parallel per load port. |
| Handshake timers TA1 / TA2 / TA3 | ✅ | E84 §6 | — | `E84StateMachine::set_timeouts({ta1, ta2, ta3})` + `set_timer_handlers(arm, cancel)`. TA1 armed in ValidAsserted, TA2 in Load/UnloadReady, TA3 in Transferring; cancelled on the matching transition out. Expiry transitions to `HandoffFault` (latched until `reset()`). FSM stays I/O-free — application drives the real clock (asio::steady_timer in the reference server). |
## 4j. E5 §13 Wafer Maps (S12) ## 4j. E5 §13 Wafer Maps (S12)
+95 -1
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <chrono>
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <optional> #include <optional>
@@ -15,6 +16,13 @@
// observation. Real wiring uses opto-isolated 24V lines; we abstract // observation. Real wiring uses opto-isolated 24V lines; we abstract
// it as bool getters/setters so the same FSM drives both real hardware // it as bool getters/setters so the same FSM drives both real hardware
// and back-to-back testing. // and back-to-back testing.
//
// The handshake is policed by SEMI E84 §6 timers (TA1/TA2/TA3) that
// bound how long each leg of the dance may take. The FSM stays I/O-free
// — it asks the application to arm / cancel a timer via callbacks and
// the application drives the real clock (asio::steady_timer in the
// reference server). On expiry the application calls `on_timeout()`
// and the FSM transitions to `HandoffFault`.
namespace secsgem::gem { namespace secsgem::gem {
enum class E84Signal : uint8_t { enum class E84Signal : uint8_t {
@@ -61,15 +69,59 @@ enum class E84State : uint8_t {
Transferring = 5, // BUSY asserted; transfer in progress Transferring = 5, // BUSY asserted; transfer in progress
Complete = 6, // COMPT asserted; AMHS reports done Complete = 6, // COMPT asserted; AMHS reports done
EmergencyStop = 7, // ES asserted EmergencyStop = 7, // ES asserted
HandoffFault = 8, // a handshake timer (TA1/TA2/TA3) expired
NoState = 255, NoState = 255,
}; };
const char* e84_state_name(E84State s); const char* e84_state_name(E84State s);
// SEMI E84 §6 handshake timers.
//
// TA1 — AMHS bounds how long equipment may take to assert L_REQ /
// U_REQ after VALID. Armed on entering ValidAsserted;
// cancelled on entering Load/UnloadReady.
// TA2 — Equipment bounds how long AMHS may take to start the actual
// transfer (assert BUSY) once the port is ready. Armed on
// entering Load/UnloadReady; cancelled on entering Transferring.
// TA3 — Equipment bounds how long the BUSY phase may last. Armed on
// entering Transferring; cancelled on entering Complete.
//
// SEMI's default values are 2 s / 2 s / 60 s respectively but a tool
// builder typically tunes them per port. A timeout of 0 disables the
// timer (used in tests and for back-to-back simulation that doesn't
// model wall-clock pacing).
enum class E84TimerId : uint8_t {
TA1 = 1,
TA2 = 2,
TA3 = 3,
};
const char* e84_timer_name(E84TimerId t);
struct E84Timeouts {
std::chrono::milliseconds ta1{0};
std::chrono::milliseconds ta2{0};
std::chrono::milliseconds ta3{0};
};
// Fault reason recorded on a HandoffFault transition.
enum class E84Fault : uint8_t {
None = 0,
TA1Expired = 1,
TA2Expired = 2,
TA3Expired = 3,
};
const char* e84_fault_name(E84Fault f);
class E84StateMachine { class E84StateMachine {
public: public:
using StateChangeHandler = using StateChangeHandler =
std::function<void(E84State from, E84State to, E84Signal trigger)>; std::function<void(E84State from, E84State to, E84Signal trigger)>;
using TimerArmHandler =
std::function<void(E84TimerId id, std::chrono::milliseconds duration)>;
using TimerCancelHandler = std::function<void(E84TimerId id)>;
using FaultHandler = std::function<void(E84Fault reason)>;
E84State state() const { return state_; } E84State state() const { return state_; }
const E84SignalSet& signals() const { return signals_; } const E84SignalSet& signals() const { return signals_; }
@@ -77,21 +129,63 @@ class E84StateMachine {
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); } void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
// Configure SEMI E84 §6 handshake timeouts. Default is all-zero,
// which disables timer enforcement entirely — the back-to-back tests
// (and the legacy CarrierPresent → LoadReady → Transferring → Complete
// happy path) work unchanged. Production code should pass spec-derived
// values (typically TA1=2s, TA2=2s, TA3=60s).
void set_timeouts(const E84Timeouts& t) { timeouts_ = t; }
const E84Timeouts& timeouts() const { return timeouts_; }
// Wire the FSM's timer notifications to the application's clock. The
// FSM calls `arm` when entering a state that starts a timer and
// `cancel` when leaving it. Real applications back this with
// asio::steady_timer; tests can record the calls directly.
void set_timer_handlers(TimerArmHandler arm, TimerCancelHandler cancel) {
on_arm_ = std::move(arm);
on_cancel_ = std::move(cancel);
}
void set_fault_handler(FaultHandler h) { on_fault_ = std::move(h); }
// Apply a single signal change. Re-evaluates the handshake state // Apply a single signal change. Re-evaluates the handshake state
// and fires the change handler on transition. Order of signal // and fires the change handler on transition. Order of signal
// changes matters for the AMHS-equipment handshake; the FSM accepts // changes matters for the AMHS-equipment handshake; the FSM accepts
// any order and just reports the resulting state. // any order and just reports the resulting state.
void on_signal_change(E84Signal s, bool value); void on_signal_change(E84Signal s, bool value);
// Convenience: clear all signals; resets state to Idle. // Called by the application when its real-clock timer for `id` fires.
// If the timer is still armed in the FSM (i.e. not raced by a
// cancellation that hasn't landed yet on the application side), the
// FSM transitions to HandoffFault and records the reason. Stale
// expiries are silently ignored.
void on_timeout(E84TimerId id);
bool timer_armed(E84TimerId id) const;
E84Fault fault() const { return fault_; }
// Convenience: clear all signals; resets state to Idle, cancels every
// armed timer, and clears any fault.
void reset(); void reset();
private: private:
void reevaluate(E84Signal trigger); void reevaluate(E84Signal trigger);
void enter_state_(E84State next, E84Signal trigger);
void update_timers_for_state_(E84State target);
void arm_timer_(E84TimerId id);
void cancel_timer_(E84TimerId id);
void cancel_all_timers_();
std::chrono::milliseconds timeout_for_(E84TimerId id) const;
E84SignalSet signals_; E84SignalSet signals_;
E84State state_ = E84State::Idle; E84State state_ = E84State::Idle;
StateChangeHandler on_change_; StateChangeHandler on_change_;
TimerArmHandler on_arm_;
TimerCancelHandler on_cancel_;
FaultHandler on_fault_;
E84Timeouts timeouts_;
E84Fault fault_ = E84Fault::None;
bool armed_[3] = {false, false, false}; // index = (id - 1)
}; };
} // namespace secsgem::gem } // namespace secsgem::gem
+115 -1
View File
@@ -28,18 +28,64 @@ const char* e84_state_name(E84State s) {
case E84State::Transferring: return "Transferring"; case E84State::Transferring: return "Transferring";
case E84State::Complete: return "Complete"; case E84State::Complete: return "Complete";
case E84State::EmergencyStop: return "EmergencyStop"; case E84State::EmergencyStop: return "EmergencyStop";
case E84State::HandoffFault: return "HandoffFault";
case E84State::NoState: return "NoState"; case E84State::NoState: return "NoState";
} }
return "?"; 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) { void E84StateMachine::on_signal_change(E84Signal s, bool value) {
signals_.set(s, value); signals_.set(s, value);
reevaluate(s); 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() { void E84StateMachine::reset() {
signals_.clear(); signals_.clear();
cancel_all_timers_();
fault_ = E84Fault::None;
if (state_ != E84State::Idle) { if (state_ != E84State::Idle) {
const auto prev = state_; const auto prev = state_;
state_ = E84State::Idle; state_ = E84State::Idle;
@@ -48,6 +94,10 @@ void E84StateMachine::reset() {
} }
void E84StateMachine::reevaluate(E84Signal trigger) { 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_; E84State next = state_;
// Emergency stop dominates. // Emergency stop dominates.
@@ -67,11 +117,75 @@ void E84StateMachine::reevaluate(E84Signal trigger) {
next = E84State::Idle; next = E84State::Idle;
} }
if (next != state_) { if (next != state_) enter_state_(next, trigger);
}
void E84StateMachine::enter_state_(E84State next, E84Signal trigger) {
const auto prev = state_; const auto prev = state_;
state_ = next; state_ = next;
update_timers_for_state_(next);
if (on_change_) on_change_(prev, state_, trigger); 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 } // namespace secsgem::gem
+268
View File
@@ -0,0 +1,268 @@
// E84 §6 handshake timer tests. The FSM is I/O-free — it requests
// arm / cancel through callbacks and the application drives the clock.
// These tests use a fake "clock" that just records arm/cancel events
// so we can assert (a) that the FSM arms the right timer in each state
// (b) that the FSM cancels the timer when leaving that state, and
// (c) that expiry transitions to HandoffFault with the correct reason.
#include <doctest/doctest.h>
#include <chrono>
#include <vector>
#include "secsgem/gem/e84_state.hpp"
using namespace secsgem::gem;
using namespace std::chrono_literals;
namespace {
// Captures the arm/cancel call stream so tests can assert ordering.
struct TimerLog {
struct ArmEvent { E84TimerId id; std::chrono::milliseconds duration; };
std::vector<ArmEvent> arms;
std::vector<E84TimerId> cancels;
};
void wire(E84StateMachine& fsm, TimerLog& log) {
fsm.set_timer_handlers(
[&log](E84TimerId id, std::chrono::milliseconds d) {
log.arms.push_back({id, d});
},
[&log](E84TimerId id) { log.cancels.push_back(id); });
}
} // namespace
TEST_CASE("E84 timers: zero timeout disables arming") {
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
// Default E84Timeouts is all-zero; the legacy back-to-back tests
// depend on this behaviour to stay unchanged.
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
CHECK(fsm.state() == E84State::ValidAsserted);
CHECK(log.arms.empty());
CHECK_FALSE(fsm.timer_armed(E84TimerId::TA1));
}
TEST_CASE("E84 timers: TA1 armed on ValidAsserted, cancelled on LoadReady") {
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
fsm.set_timeouts({2000ms, 2000ms, 60000ms});
fsm.on_signal_change(E84Signal::CS_0, true);
CHECK(log.arms.empty()); // CarrierPresent doesn't arm anything
fsm.on_signal_change(E84Signal::VALID, true);
REQUIRE(fsm.state() == E84State::ValidAsserted);
REQUIRE(log.arms.size() == 1);
CHECK(log.arms[0].id == E84TimerId::TA1);
CHECK(log.arms[0].duration == 2000ms);
CHECK(fsm.timer_armed(E84TimerId::TA1));
fsm.on_signal_change(E84Signal::L_REQ, true);
REQUIRE(fsm.state() == E84State::LoadReady);
// TA1 cancelled on transition, TA2 armed in its place.
CHECK(std::count(log.cancels.begin(), log.cancels.end(),
E84TimerId::TA1) == 1);
CHECK_FALSE(fsm.timer_armed(E84TimerId::TA1));
CHECK(fsm.timer_armed(E84TimerId::TA2));
}
TEST_CASE("E84 timers: TA1 expiry transitions to HandoffFault") {
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
fsm.set_timeouts({500ms, 0ms, 0ms});
E84Fault observed_fault = E84Fault::None;
fsm.set_fault_handler([&](E84Fault f) { observed_fault = f; });
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
REQUIRE(fsm.timer_armed(E84TimerId::TA1));
fsm.on_timeout(E84TimerId::TA1);
CHECK(fsm.state() == E84State::HandoffFault);
CHECK(fsm.fault() == E84Fault::TA1Expired);
CHECK(observed_fault == E84Fault::TA1Expired);
CHECK_FALSE(fsm.timer_armed(E84TimerId::TA1));
}
TEST_CASE("E84 timers: TA2 armed on LoadReady, cancelled on Transferring") {
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
fsm.set_timeouts({2000ms, 2000ms, 60000ms});
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
fsm.on_signal_change(E84Signal::L_REQ, true);
REQUIRE(fsm.state() == E84State::LoadReady);
CHECK(fsm.timer_armed(E84TimerId::TA2));
fsm.on_signal_change(E84Signal::BUSY, true);
REQUIRE(fsm.state() == E84State::Transferring);
CHECK_FALSE(fsm.timer_armed(E84TimerId::TA2));
CHECK(fsm.timer_armed(E84TimerId::TA3));
}
TEST_CASE("E84 timers: TA2 also armed on UnloadReady") {
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
fsm.set_timeouts({0ms, 1500ms, 0ms});
fsm.on_signal_change(E84Signal::CS_1, true);
fsm.on_signal_change(E84Signal::VALID, true);
fsm.on_signal_change(E84Signal::U_REQ, true);
REQUIRE(fsm.state() == E84State::UnloadReady);
REQUIRE(log.arms.size() == 1);
CHECK(log.arms.back().id == E84TimerId::TA2);
CHECK(log.arms.back().duration == 1500ms);
}
TEST_CASE("E84 timers: TA2 expiry transitions to HandoffFault") {
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
fsm.set_timeouts({0ms, 500ms, 0ms});
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
fsm.on_signal_change(E84Signal::L_REQ, true);
REQUIRE(fsm.timer_armed(E84TimerId::TA2));
fsm.on_timeout(E84TimerId::TA2);
CHECK(fsm.state() == E84State::HandoffFault);
CHECK(fsm.fault() == E84Fault::TA2Expired);
}
TEST_CASE("E84 timers: TA3 armed on Transferring, cancelled on Complete") {
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
fsm.set_timeouts({2000ms, 2000ms, 60000ms});
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
fsm.on_signal_change(E84Signal::L_REQ, true);
fsm.on_signal_change(E84Signal::BUSY, true);
REQUIRE(fsm.state() == E84State::Transferring);
CHECK(fsm.timer_armed(E84TimerId::TA3));
// BUSY drops then COMPT rises — the existing FSM contract. TA3 was
// armed while BUSY held; transient drop returns to LoadReady (where
// TA2 re-arms), then COMPT pushes Complete where no timer is armed.
fsm.on_signal_change(E84Signal::BUSY, false);
fsm.on_signal_change(E84Signal::COMPT, true);
REQUIRE(fsm.state() == E84State::Complete);
CHECK_FALSE(fsm.timer_armed(E84TimerId::TA1));
CHECK_FALSE(fsm.timer_armed(E84TimerId::TA2));
CHECK_FALSE(fsm.timer_armed(E84TimerId::TA3));
}
TEST_CASE("E84 timers: TA3 expiry transitions to HandoffFault") {
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
fsm.set_timeouts({0ms, 0ms, 30000ms});
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
fsm.on_signal_change(E84Signal::L_REQ, true);
fsm.on_signal_change(E84Signal::BUSY, true);
REQUIRE(fsm.timer_armed(E84TimerId::TA3));
fsm.on_timeout(E84TimerId::TA3);
CHECK(fsm.state() == E84State::HandoffFault);
CHECK(fsm.fault() == E84Fault::TA3Expired);
}
TEST_CASE("E84 timers: ES cancels every armed timer") {
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
fsm.set_timeouts({2000ms, 2000ms, 60000ms});
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
fsm.on_signal_change(E84Signal::L_REQ, true);
REQUIRE(fsm.timer_armed(E84TimerId::TA2));
fsm.on_signal_change(E84Signal::ES, true);
CHECK(fsm.state() == E84State::EmergencyStop);
CHECK_FALSE(fsm.timer_armed(E84TimerId::TA1));
CHECK_FALSE(fsm.timer_armed(E84TimerId::TA2));
CHECK_FALSE(fsm.timer_armed(E84TimerId::TA3));
}
TEST_CASE("E84 timers: stale on_timeout after cancel is a no-op") {
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
fsm.set_timeouts({2000ms, 0ms, 0ms});
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
REQUIRE(fsm.timer_armed(E84TimerId::TA1));
// L_REQ arrives, FSM cancels TA1. The app's already-fired asio
// timer might still call on_timeout(TA1) before its cancellation
// landed; that race must not promote the FSM into HandoffFault.
fsm.on_signal_change(E84Signal::L_REQ, true);
REQUIRE(fsm.state() == E84State::LoadReady);
fsm.on_timeout(E84TimerId::TA1);
CHECK(fsm.state() == E84State::LoadReady);
CHECK(fsm.fault() == E84Fault::None);
}
TEST_CASE("E84 timers: HandoffFault is latched until reset()") {
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
fsm.set_timeouts({500ms, 0ms, 0ms});
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
fsm.on_timeout(E84TimerId::TA1);
REQUIRE(fsm.state() == E84State::HandoffFault);
// Signal jitter shouldn't silently clear the fault.
fsm.on_signal_change(E84Signal::VALID, false);
fsm.on_signal_change(E84Signal::CS_0, false);
CHECK(fsm.state() == E84State::HandoffFault);
CHECK(fsm.fault() == E84Fault::TA1Expired);
fsm.reset();
CHECK(fsm.state() == E84State::Idle);
CHECK(fsm.fault() == E84Fault::None);
}
TEST_CASE("E84 timers: per-port store wiring relays timers") {
// The per-port store delegates to underlying E84StateMachine objects;
// a quick smoke-test that the application can wire timers per port
// without the store getting in the way.
E84StateMachine fsm;
TimerLog log;
wire(fsm, log);
fsm.set_timeouts({100ms, 100ms, 100ms});
fsm.on_signal_change(E84Signal::CS_0, true);
fsm.on_signal_change(E84Signal::VALID, true);
fsm.on_signal_change(E84Signal::L_REQ, true);
fsm.on_signal_change(E84Signal::BUSY, true);
fsm.on_signal_change(E84Signal::BUSY, false);
fsm.on_signal_change(E84Signal::COMPT, true);
// Across the full happy path: TA1 → TA2 → TA3 → TA2 (transient
// BUSY drop) → no timer (Complete). Five arms, five cancels.
CHECK(log.arms.size() == 4);
CHECK(log.arms[0].id == E84TimerId::TA1);
CHECK(log.arms[1].id == E84TimerId::TA2);
CHECK(log.arms[2].id == E84TimerId::TA3);
CHECK(log.arms[3].id == E84TimerId::TA2);
CHECK(log.cancels.size() == 4);
}