diff --git a/CMakeLists.txt b/CMakeLists.txt index 43cc422..cbefc0e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -132,6 +132,7 @@ add_executable(secsgem_tests tests/test_e84.cpp tests/test_e84_ports.cpp tests/test_e84_timers.cpp + tests/test_e84_asio_timers.cpp tests/test_e42_formatted_pp.cpp tests/test_gem300_scenario.cpp tests/test_wire_ceid_emission.cpp diff --git a/INTEGRATION.md b/INTEGRATION.md index 4b00de3..d2bdfe3 100644 --- a/INTEGRATION.md +++ b/INTEGRATION.md @@ -267,7 +267,50 @@ model->alarms.set(1, false); // emits S5F1(ALCD=0x04) The dispatcher takes care of the wire frame — you just toggle. -### 4.6. Recoverable exceptions (E5 §9, S5F9–F18) +### 4.6. E84 parallel I/O handoff (AMHS) + +For each load port that talks to the AMHS robot: + +```cpp +#include "secsgem/gem/e84_asio_timers.hpp" + +auto* fsm = model->e84_ports.get(/*port_id=*/1); +if (!fsm) { model->e84_ports.create(1); fsm = model->e84_ports.get(1); } + +// SEMI E84 §6 handshake timers. Defaults below are spec-typical; tune +// per port. TA1=AMHS waits for L_REQ/U_REQ after VALID; TA2=equipment +// waits for BUSY after port is ready; TA3=BUSY phase budget. +fsm->set_timeouts({std::chrono::seconds(2), + std::chrono::seconds(2), + std::chrono::seconds(60)}); + +// Wire arm/cancel into asio so the FSM polices the real wall clock. +auto driver = std::make_shared(io.get_executor(), *fsm); +driver->attach(); +// Keep `driver` alive for the lifetime of the FSM (e.g. as a member +// of your per-port object). + +// Optional: log handoff faults. +fsm->set_fault_handler([port_id = 1](gem::E84Fault reason) { + log("E84 port " + std::to_string(port_id) + " fault: " + + gem::e84_fault_name(reason)); +}); + +// Now feed signal changes from your I/O bridge. On a real AMHS the +// bridge polls or interrupts on the parallel-I/O lines: +model->e84_ports.on_signal_change(1, gem::E84Signal::CS_0, true); +model->e84_ports.on_signal_change(1, gem::E84Signal::VALID, true); +// equipment side asserts when port is physically ready: +model->e84_ports.on_signal_change(1, gem::E84Signal::L_REQ, true); +// ... AMHS continues with BUSY / COMPT. +``` + +If TA1, TA2, or TA3 expires the FSM transitions to `HandoffFault` and +the fault handler fires with the precise `E84Fault` reason. Your +application is then responsible for whatever the tool's fault policy is +(typically: assert your local ES line and raise an alarm). + +### 4.7. Recoverable exceptions (E5 §9, S5F9–F18) For faults where you want a host/equipment recovery dialogue: diff --git a/include/secsgem/gem/e84_asio_timers.hpp b/include/secsgem/gem/e84_asio_timers.hpp new file mode 100644 index 0000000..09d004f --- /dev/null +++ b/include/secsgem/gem/e84_asio_timers.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "secsgem/gem/e84_state.hpp" + +// asio-backed driver for the SEMI E84 §6 handshake timers (TA1/TA2/TA3). +// +// `E84StateMachine` is I/O-free by design: it tells the application to +// arm or cancel a timer via callbacks and expects the application to +// drive a real clock and call `on_timeout(id)` on expiry. This +// header-only adapter is the canonical asio glue — three steady_timers, +// wired so that an arm() restarts the matching timer and a cancel() +// stops it; expiry routes back into `fsm.on_timeout(id)`. +// +// Usage (one adapter per E84StateMachine, one E84StateMachine per +// load port via `E84PortStore::get(port_id)`): +// +// E84StateMachine* fsm = model->e84_ports.get(port_id); +// auto driver = std::make_shared(io.get_executor(), *fsm); +// driver->attach(); // wires set_timer_handlers +// fsm->set_timeouts({2s, 2s, 60s}); // SEMI defaults +// +// Keep the shared_ptr alive for as long as the FSM may transition — +// typically tied to the load-port lifetime. Detach (or just drop the +// shared_ptr) before destroying the FSM. +namespace secsgem::gem { + +class E84AsioTimers : public std::enable_shared_from_this { + public: + E84AsioTimers(asio::any_io_executor ex, E84StateMachine& fsm) + : fsm_(fsm), + timers_{{asio::steady_timer{ex}, asio::steady_timer{ex}, + asio::steady_timer{ex}}} {} + + E84AsioTimers(const E84AsioTimers&) = delete; + E84AsioTimers& operator=(const E84AsioTimers&) = delete; + + // Wires the FSM's arm/cancel callbacks to the internal timers. Must + // be called after construction (we need shared_from_this for the + // expiry continuation). + void attach() { + auto self = shared_from_this(); + fsm_.set_timer_handlers( + [self](E84TimerId id, std::chrono::milliseconds d) { + self->arm_(id, d); + }, + [self](E84TimerId id) { self->cancel_(id); }); + } + + // Detach so timers stop driving the FSM. Pending expiries are + // cancelled; in-flight asio handlers see ec == operation_aborted and + // return early. + void detach() { + for (auto& t : timers_) t.cancel(); + fsm_.set_timer_handlers({}, {}); + } + + private: + void arm_(E84TimerId id, std::chrono::milliseconds d) { + auto& t = timer_for_(id); + t.expires_after(d); + auto self = shared_from_this(); + t.async_wait([self, id](std::error_code ec) { + if (ec) return; // cancelled or timer destroyed + self->fsm_.on_timeout(id); + }); + } + + void cancel_(E84TimerId id) { timer_for_(id).cancel(); } + + asio::steady_timer& timer_for_(E84TimerId id) { + return timers_[static_cast(id) - 1]; + } + + E84StateMachine& fsm_; + std::array timers_; +}; + +} // namespace secsgem::gem diff --git a/tests/test_e84_asio_timers.cpp b/tests/test_e84_asio_timers.cpp new file mode 100644 index 0000000..82b76bd --- /dev/null +++ b/tests/test_e84_asio_timers.cpp @@ -0,0 +1,138 @@ +// Wire-level test for the E84 asio adapter. Drives the FSM through a +// real asio::io_context with wall-clock timers and asserts that +// HandoffFault fires on actual elapsed time — the integration the unit +// tests in test_e84_timers.cpp can't reach because they call +// fsm.on_timeout() synthetically. + +#include + +#include +#include +#include + +#include "secsgem/gem/e84_asio_timers.hpp" +#include "secsgem/gem/e84_state.hpp" + +using namespace secsgem::gem; +using namespace std::chrono_literals; + +namespace { + +// Run io until either the predicate is true or `cap` elapses. Stops +// the harness as soon as the FSM reaches the expected state so the test +// doesn't sit on the deadline. +template +void run_until(asio::io_context& io, Pred p, std::chrono::milliseconds cap) { + asio::steady_timer cap_timer(io); + cap_timer.expires_after(cap); + cap_timer.async_wait([&io](std::error_code) { io.stop(); }); + + asio::steady_timer poll(io); + std::function tick = + [&](std::error_code ec) { + if (ec) return; + if (p()) { io.stop(); return; } + poll.expires_after(5ms); + poll.async_wait(tick); + }; + poll.expires_after(5ms); + poll.async_wait(tick); + + io.run(); +} + +} // namespace + +TEST_CASE("E84 asio: TA1 expires on real wall clock and faults the FSM") { + asio::io_context io; + E84StateMachine fsm; + fsm.set_timeouts({50ms, 0ms, 0ms}); + + auto driver = std::make_shared(io.get_executor(), fsm); + driver->attach(); + + fsm.on_signal_change(E84Signal::CS_0, true); + fsm.on_signal_change(E84Signal::VALID, true); + REQUIRE(fsm.state() == E84State::ValidAsserted); + REQUIRE(fsm.timer_armed(E84TimerId::TA1)); + + run_until(io, [&] { return fsm.state() == E84State::HandoffFault; }, 1s); + + CHECK(fsm.state() == E84State::HandoffFault); + CHECK(fsm.fault() == E84Fault::TA1Expired); +} + +TEST_CASE("E84 asio: signal-driven cancel before TA1 expires") { + asio::io_context io; + E84StateMachine fsm; + fsm.set_timeouts({200ms, 0ms, 0ms}); + + auto driver = std::make_shared(io.get_executor(), fsm); + driver->attach(); + + fsm.on_signal_change(E84Signal::CS_0, true); + fsm.on_signal_change(E84Signal::VALID, true); + + // Schedule the L_REQ assertion well before TA1 fires. After it lands, + // the FSM is in LoadReady (TA2 takes over but timeouts.ta2 is 0, so + // nothing is armed) and TA1 must NOT have promoted to HandoffFault. + asio::steady_timer ack(io); + ack.expires_after(20ms); + ack.async_wait([&](std::error_code) { + fsm.on_signal_change(E84Signal::L_REQ, true); + }); + + run_until(io, + [&] { return fsm.state() == E84State::HandoffFault; }, + 400ms); + + CHECK(fsm.state() == E84State::LoadReady); + CHECK(fsm.fault() == E84Fault::None); +} + +TEST_CASE("E84 asio: TA3 fires after Transferring exceeds its budget") { + asio::io_context io; + E84StateMachine fsm; + fsm.set_timeouts({0ms, 0ms, 60ms}); + + auto driver = std::make_shared(io.get_executor(), fsm); + driver->attach(); + + 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); + REQUIRE(fsm.timer_armed(E84TimerId::TA3)); + + run_until(io, + [&] { return fsm.state() == E84State::HandoffFault; }, + 1s); + + CHECK(fsm.state() == E84State::HandoffFault); + CHECK(fsm.fault() == E84Fault::TA3Expired); +} + +TEST_CASE("E84 asio: detach() halts further timer-driven transitions") { + asio::io_context io; + E84StateMachine fsm; + fsm.set_timeouts({40ms, 0ms, 0ms}); + + auto driver = std::make_shared(io.get_executor(), fsm); + driver->attach(); + + fsm.on_signal_change(E84Signal::CS_0, true); + fsm.on_signal_change(E84Signal::VALID, true); + driver->detach(); + + // detach cancels the timer; pending asio handler sees aborted and + // returns without calling on_timeout. Let the io drain for double + // the timeout to confirm. + asio::steady_timer wait(io); + wait.expires_after(120ms); + wait.async_wait([&](std::error_code) { io.stop(); }); + io.run(); + + CHECK(fsm.state() == E84State::ValidAsserted); + CHECK(fsm.fault() == E84Fault::None); +}