e84: asio adapter for handshake timers + wall-clock test
The E84StateMachine timers landed last commit but stayed theoretical — arming was delivered via abstract callbacks the application had to glue to a real clock. This commit ships the canonical glue: - include/secsgem/gem/e84_asio_timers.hpp: header-only E84AsioTimers wraps three asio::steady_timers, wires set_timer_handlers on attach(), routes async_wait expiry back into fsm.on_timeout(). detach() cancels everything cleanly. - tests/test_e84_asio_timers.cpp: four scenarios exercised through a real asio::io_context with wall-clock timers — TA1 expiry, signal-driven cancel before TA1 fires, TA3 expiry from the Transferring state, and detach() halting further transitions. These cover the integration the synthetic unit tests in test_e84_timers.cpp can't reach. - INTEGRATION.md §4.6: the vendor-side recipe — create the port, set timeouts, make_shared<E84AsioTimers>(...)::attach(), feed signals from your I/O bridge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+44
-1
@@ -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<gem::E84AsioTimers>(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:
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
|
||||
#include <asio.hpp>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
#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<E84AsioTimers>(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<E84AsioTimers> {
|
||||
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<std::size_t>(id) - 1];
|
||||
}
|
||||
|
||||
E84StateMachine& fsm_;
|
||||
std::array<asio::steady_timer, 3> timers_;
|
||||
};
|
||||
|
||||
} // namespace secsgem::gem
|
||||
@@ -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 <doctest/doctest.h>
|
||||
|
||||
#include <asio.hpp>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
|
||||
#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 <typename Pred>
|
||||
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<void(std::error_code)> 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<E84AsioTimers>(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<E84AsioTimers>(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<E84AsioTimers>(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<E84AsioTimers>(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);
|
||||
}
|
||||
Reference in New Issue
Block a user