diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d316e8..43cc422 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -131,6 +131,7 @@ add_executable(secsgem_tests tests/test_s9_fallback.cpp tests/test_e84.cpp tests/test_e84_ports.cpp + tests/test_e84_timers.cpp tests/test_e42_formatted_pp.cpp tests/test_gem300_scenario.cpp tests/test_wire_ceid_emission.cpp diff --git a/COMPLIANCE.md b/COMPLIANCE.md index ef53546..3ac664d 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -197,6 +197,7 @@ Legend: | 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. | +| 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) diff --git a/include/secsgem/gem/e84_state.hpp b/include/secsgem/gem/e84_state.hpp index ac67c3a..9676f14 100644 --- a/include/secsgem/gem/e84_state.hpp +++ b/include/secsgem/gem/e84_state.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -15,6 +16,13 @@ // observation. Real wiring uses opto-isolated 24V lines; we abstract // it as bool getters/setters so the same FSM drives both real hardware // 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 { enum class E84Signal : uint8_t { @@ -61,15 +69,59 @@ enum class E84State : uint8_t { Transferring = 5, // BUSY asserted; transfer in progress Complete = 6, // COMPT asserted; AMHS reports done EmergencyStop = 7, // ES asserted + HandoffFault = 8, // a handshake timer (TA1/TA2/TA3) expired NoState = 255, }; 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 { public: using StateChangeHandler = std::function; + using TimerArmHandler = + std::function; + using TimerCancelHandler = std::function; + using FaultHandler = std::function; E84State state() const { return state_; } const E84SignalSet& signals() const { return signals_; } @@ -77,21 +129,63 @@ class E84StateMachine { 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 // and fires the change handler on transition. Order of signal // changes matters for the AMHS-equipment handshake; the FSM accepts // any order and just reports the resulting state. 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(); private: 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_; E84State state_ = E84State::Idle; 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 diff --git a/src/gem/e84_state.cpp b/src/gem/e84_state.cpp index 2cd6902..f68bce3 100644 --- a/src/gem/e84_state.cpp +++ b/src/gem/e84_state.cpp @@ -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(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(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 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(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(id) - 1] = true; + if (on_arm_) on_arm_(id, d); +} + +void E84StateMachine::cancel_timer_(E84TimerId id) { + const auto idx = static_cast(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 diff --git a/tests/test_e84_timers.cpp b/tests/test_e84_timers.cpp new file mode 100644 index 0000000..d7d7fdb --- /dev/null +++ b/tests/test_e84_timers.cpp @@ -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 + +#include +#include + +#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 arms; + std::vector 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); +}