2ea3ab796a
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>
192 lines
7.2 KiB
C++
192 lines
7.2 KiB
C++
#pragma once
|
|
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <functional>
|
|
#include <optional>
|
|
#include <string>
|
|
|
|
// E84 §6 Parallel I/O — the digital handshake between AMHS (Automated
|
|
// Material Handling System) and equipment for carrier load/unload.
|
|
//
|
|
// E84 is signal-level, not SECS: ten parallel boolean wires between the
|
|
// AMHS robot and the equipment, sequenced in a strict handshake. This
|
|
// FSM models the signal bitmap and the handshake state, accepting
|
|
// signal-change events as input and exposing state transitions for
|
|
// 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 {
|
|
CS_0 = 0, // AMHS -> equip: carrier stage select 0
|
|
CS_1 = 1, // AMHS -> equip: carrier stage select 1
|
|
VALID = 2, // AMHS -> equip: handshake start
|
|
TR_REQ = 3, // AMHS -> equip: transfer request
|
|
BUSY = 4, // AMHS -> equip: transfer in progress
|
|
COMPT = 5, // AMHS -> equip: transfer complete
|
|
L_REQ = 6, // equip -> AMHS: load request (port ready to receive)
|
|
U_REQ = 7, // equip -> AMHS: unload request (port ready to release)
|
|
READY = 8, // equip -> AMHS: ready
|
|
ES = 9, // either direction: emergency stop
|
|
};
|
|
|
|
const char* e84_signal_name(E84Signal s);
|
|
|
|
// 10-bit signal bitmap with bool get/set.
|
|
class E84SignalSet {
|
|
public:
|
|
bool get(E84Signal s) const {
|
|
return (bits_ & (uint16_t{1} << static_cast<uint8_t>(s))) != 0;
|
|
}
|
|
void set(E84Signal s, bool v) {
|
|
const uint16_t mask = uint16_t{1} << static_cast<uint8_t>(s);
|
|
if (v) bits_ |= mask;
|
|
else bits_ &= static_cast<uint16_t>(~mask);
|
|
}
|
|
uint16_t raw() const { return bits_; }
|
|
void clear() { bits_ = 0; }
|
|
|
|
private:
|
|
uint16_t bits_ = 0;
|
|
};
|
|
|
|
// E84 handoff handshake state (E84 §6.3). Names are short for log
|
|
// readability; semantics in comments.
|
|
enum class E84State : uint8_t {
|
|
Idle = 0, // no signals asserted (or carrier absent)
|
|
CarrierPresent = 1, // CS_0 or CS_1 asserted; no VALID yet
|
|
ValidAsserted = 2, // CS && VALID; equipment hasn't acknowledged
|
|
LoadReady = 3, // VALID && L_REQ; ready to receive carrier
|
|
UnloadReady = 4, // VALID && U_REQ; ready to release carrier
|
|
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<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_; }
|
|
const E84SignalSet& signals() const { return signals_; }
|
|
bool signal(E84Signal s) const { return signals_.get(s); }
|
|
|
|
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);
|
|
|
|
// 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
|