E40 Process Jobs + E94 Control Jobs + E30 communication state
GEM300 layer: SEMI E40-0705 Process Job and E94-0705 Control Job state machines, plus the E30 §6.1 communication-state machine that sits between HSMS SELECT and full GEM communication. Data-driven via data/process_job_state.yaml and data/control_job_state.yaml, mirroring the existing control_state.yaml pattern. Wire coverage: S14F9/F10 CreateObject (CJ) host -> equipment S14F11/F12 DeleteObject (CJ) host -> equipment S16F5/F6 PRJobCommand host -> equipment S16F9 PRJobAlert equipment -> host S16F11/F12 PRJobCreate (simplified body) host -> equipment S16F13/F14 PRJobDequeue host -> equipment S16F27/F28 CJobCommand host -> equipment Process Job FSM exposes 8 states matching PRJOBSTATE bytes (E40 §10.3.2); HOQ is reorder-aware (move-to-head against an insertion-order vector); Stop/Abort on a Queued PJ routes through ABORTING so the host observes PRJOBSTATE=7 on the wire (§6.3); alert_enabled is settable per-PJ for PRALERT control; FSM dispatches through ProcessJobStore::on_change_ dynamically so a late set_state_change_handler() reaches existing PJs. Hardening: loader rejects NoState (sentinel) as initial/from/to and rejects `on: created` rows; static_asserts pin enum values to wire bytes; ProcessJobStore is non-movable to keep the per-PJ this-capture safe. Server simulator cascades the full CJ -> PJ lifecycle on CJSTART so the wire trace exercises every legal state. CEIDs 400/401 fire on CJ state changes via the existing event-report pipeline. Tests: 60+ new assertions across test_process_jobs, test_control_jobs, test_communication_state, test_hsms_connection, plus loader and messages round-trip coverage. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace secsgem::gem {
|
||||
|
||||
// E30 §6.5 — GEM Communication State Model.
|
||||
//
|
||||
// This is a *separate* state machine from the HSMS connection state
|
||||
// (NOT-SELECTED / SELECTED) and from the E30 control state model. It
|
||||
// governs whether the equipment is currently in a "communicating"
|
||||
// relationship with the host as established by the S1F13 / S1F14
|
||||
// handshake. Per E30 §6.5:
|
||||
//
|
||||
// DISABLED
|
||||
// +-- (operator enables comms) ----------+
|
||||
// | v
|
||||
// ENABLED.NOT-COMMUNICATING.WAIT-CRA (sent S1F13; awaiting S1F14)
|
||||
// | ^ |
|
||||
// | | (T_DELAY elapses) | (S1F14 COMMACK=Accept)
|
||||
// | | v
|
||||
// ENABLED.NOT-COMMUNICATING.WAIT-DELAY ENABLED.COMMUNICATING
|
||||
// ^ ^ |
|
||||
// | +------ (T_CRA timeout, or /
|
||||
// | COMMACK!=Accept) ---------+
|
||||
// +-- (any event below; comms re-attempted on T_DELAY)
|
||||
//
|
||||
// (anywhere in ENABLED) -- (operator disables) --> DISABLED
|
||||
//
|
||||
// Timers (E30 §6.5):
|
||||
// T_CRA — Communication Response Awaited; default 30 s.
|
||||
// Bounds how long we'll wait for S1F14 after sending S1F13.
|
||||
// T_DELAY — Delay between retry attempts; default 10 s.
|
||||
//
|
||||
// This class is pure logic. It owns NO sockets and NO timers — instead
|
||||
// it asks its embedder to arm a timer via the `OnTimer` callback (and
|
||||
// cancel via OnCancelTimer), and the embedder reports the timer firing
|
||||
// by calling on_cra_timeout() / on_delay_elapsed(). That keeps the
|
||||
// state machine testable without Asio.
|
||||
|
||||
enum class CommState : uint8_t {
|
||||
Disabled, // operator-disabled; no comm attempts
|
||||
WaitCRA, // S1F13 sent, waiting for S1F14
|
||||
WaitDelay, // S1F14 failed or timed out; waiting before retry
|
||||
Communicating, // S1F13/F14 handshake succeeded
|
||||
};
|
||||
|
||||
const char* comm_state_name(CommState s);
|
||||
|
||||
// Optional listener for state changes (mainly for logging).
|
||||
using CommStateChangeHandler =
|
||||
std::function<void(CommState from, CommState to, const std::string& reason)>;
|
||||
|
||||
struct CommTimers {
|
||||
std::chrono::milliseconds t_cra{std::chrono::seconds(30)};
|
||||
std::chrono::milliseconds t_delay{std::chrono::seconds(10)};
|
||||
};
|
||||
|
||||
// Embedder callbacks. arm_t_cra() / arm_t_delay() must schedule a
|
||||
// one-shot callback that calls on_cra_timeout() / on_delay_elapsed()
|
||||
// respectively when the duration elapses. cancel_timers() must cancel
|
||||
// any pending arming. We deliberately do not embed an asio::steady_timer
|
||||
// here so the state machine is unit-testable.
|
||||
struct CommEnvironment {
|
||||
std::function<void(std::chrono::milliseconds)> arm_t_cra;
|
||||
std::function<void(std::chrono::milliseconds)> arm_t_delay;
|
||||
std::function<void()> cancel_timers;
|
||||
// Asked to send the equipment-initiated S1F13. May be empty; only
|
||||
// equipment-initiated establishment uses this.
|
||||
std::function<void()> send_s1f13;
|
||||
};
|
||||
|
||||
class CommunicationStateMachine {
|
||||
public:
|
||||
// The communication-establishment direction. Per E30 the host may
|
||||
// also initiate by sending S1F13 first; in that case we go directly
|
||||
// from DISABLED to COMMUNICATING on receipt of a successful S1F13.
|
||||
enum class Initiator {
|
||||
Equipment, // we send S1F13 ourselves on enable
|
||||
Host, // we wait for host's S1F13
|
||||
};
|
||||
|
||||
explicit CommunicationStateMachine(CommTimers timers = {},
|
||||
Initiator initiator = Initiator::Equipment);
|
||||
|
||||
// Injection point for the embedder. Required before enable() can fire
|
||||
// the equipment-initiated S1F13.
|
||||
void set_environment(CommEnvironment env) { env_ = std::move(env); }
|
||||
void set_state_change_handler(CommStateChangeHandler h) { on_change_ = std::move(h); }
|
||||
|
||||
CommState state() const { return state_; }
|
||||
bool communicating() const { return state_ == CommState::Communicating; }
|
||||
|
||||
// Operator actions.
|
||||
void enable(); // DISABLED -> WaitCRA (equipment-initiated) or stays
|
||||
// in DISABLED-equivalent-NotCommunicating (host-initiated)
|
||||
void disable(); // any -> DISABLED
|
||||
|
||||
// ---- Events from the message layer / timer layer --------------------
|
||||
|
||||
// Inbound S1F14 with the given COMMACK byte. Accept (0) transitions
|
||||
// us to COMMUNICATING; anything else drops us to WAIT-DELAY for a
|
||||
// retry. Only meaningful in WAIT-CRA; ignored elsewhere.
|
||||
void on_s1f14_received(uint8_t commack);
|
||||
|
||||
// Inbound S1F13 from the host. The equipment must reply with S1F14;
|
||||
// we transition to COMMUNICATING immediately (the reply is the
|
||||
// embedder's responsibility — typically via a Router handler).
|
||||
void on_s1f13_received();
|
||||
|
||||
// The transport layer dropped (HSMS Connection closed). Per E30 any
|
||||
// active communications transition back into NOT-COMMUNICATING.
|
||||
void on_connection_lost();
|
||||
|
||||
// Timer firings — called by the embedder's scheduled callbacks.
|
||||
void on_cra_timeout();
|
||||
void on_delay_elapsed();
|
||||
|
||||
// Manual retry, mostly for tests. Equivalent to "T_DELAY elapsed
|
||||
// right now"; only meaningful in WAIT-DELAY.
|
||||
void retry_now() { on_delay_elapsed(); }
|
||||
|
||||
Initiator initiator() const { return initiator_; }
|
||||
const CommTimers& timers() const { return timers_; }
|
||||
|
||||
private:
|
||||
void transition(CommState next, const std::string& reason);
|
||||
|
||||
CommTimers timers_;
|
||||
Initiator initiator_;
|
||||
CommState state_ = CommState::Disabled;
|
||||
CommEnvironment env_;
|
||||
CommStateChangeHandler on_change_;
|
||||
};
|
||||
|
||||
} // namespace secsgem::gem
|
||||
@@ -0,0 +1,111 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/gem/store/host_commands.hpp" // HostCmdAck
|
||||
|
||||
namespace secsgem::gem {
|
||||
|
||||
// E94 §6 Control Job state model.
|
||||
//
|
||||
// E94 governs the *batch* of work — a CJ owns an ordered list of PRJOBIDs
|
||||
// (process jobs) and a processing policy. States below match E94-0705 §6
|
||||
// (the values are this project's own opaque encoding — E94 does not pin a
|
||||
// PRJOBSTATE-style wire enum for CJ; we surface state via S6F11 CEIDs in
|
||||
// the demo).
|
||||
enum class ControlJobState : uint8_t {
|
||||
Queued = 0,
|
||||
Selected = 1,
|
||||
WaitingForStart = 2,
|
||||
Executing = 3,
|
||||
Paused = 4,
|
||||
Completed = 5, // terminal: all PJs done, awaiting deletion
|
||||
Stopping = 6,
|
||||
Aborting = 7,
|
||||
NoState = 255,
|
||||
};
|
||||
|
||||
const char* control_job_state_name(ControlJobState s);
|
||||
std::optional<ControlJobState> parse_control_job_state(const std::string& s);
|
||||
|
||||
enum class ControlJobEvent {
|
||||
Created, // -> Queued
|
||||
Select, // Queued -> Selected
|
||||
SetupComplete, // Selected -> WaitingForStart
|
||||
Start, // host: CJSTART -> Executing
|
||||
Pause, // host: CJPAUSE -> Paused
|
||||
Resume, // host: CJRESUME -> Executing
|
||||
Stop, // host: CJSTOP -> Stopping
|
||||
Abort, // host: CJABORT -> Aborting
|
||||
AllJobsComplete, // internal: Executing/Stopping -> Completed
|
||||
AbortComplete, // internal: Aborting -> Completed
|
||||
};
|
||||
|
||||
const char* control_job_event_name(ControlJobEvent e);
|
||||
std::optional<ControlJobEvent> parse_control_job_event(const std::string& s);
|
||||
|
||||
// Wire-name -> event mapping for the CTLJOBCMD string on S16F27.
|
||||
std::optional<ControlJobEvent> ctl_cmd_to_event(const std::string& cmd);
|
||||
|
||||
struct ControlJobTransition {
|
||||
ControlJobState from;
|
||||
ControlJobEvent on;
|
||||
std::optional<ControlJobState> to;
|
||||
std::optional<uint8_t> ack_code; // HostCmdAck when applicable
|
||||
};
|
||||
|
||||
class ControlJobTransitionTable {
|
||||
public:
|
||||
void add(ControlJobTransition row);
|
||||
const ControlJobTransition* find(ControlJobState from,
|
||||
ControlJobEvent on) const;
|
||||
std::size_t size() const { return rows_.size(); }
|
||||
const std::vector<ControlJobTransition>& rows() const { return rows_; }
|
||||
|
||||
static ControlJobTransitionTable default_table();
|
||||
|
||||
private:
|
||||
std::vector<ControlJobTransition> rows_;
|
||||
};
|
||||
|
||||
class ControlJobStateMachine {
|
||||
public:
|
||||
using StateChangeHandler =
|
||||
std::function<void(ControlJobState from, ControlJobState to,
|
||||
ControlJobEvent trigger)>;
|
||||
|
||||
ControlJobStateMachine();
|
||||
explicit ControlJobStateMachine(ControlJobTransitionTable table,
|
||||
ControlJobState initial = ControlJobState::Queued);
|
||||
|
||||
ControlJobState state() const { return state_; }
|
||||
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
|
||||
|
||||
HostCmdAck on_host_command(ControlJobEvent event);
|
||||
bool on_internal(ControlJobEvent event);
|
||||
|
||||
private:
|
||||
const ControlJobTransition* fire(ControlJobEvent on);
|
||||
void transition(ControlJobState next, ControlJobEvent trigger);
|
||||
|
||||
ControlJobTransitionTable table_;
|
||||
ControlJobState state_;
|
||||
StateChangeHandler on_change_;
|
||||
};
|
||||
|
||||
// S14F10 / F12 OBJACK — generic ObjectService ack. Used by the
|
||||
// CreateControlJob / DeleteControlJob handlers.
|
||||
enum class ObjectAck : uint8_t {
|
||||
Success = 0,
|
||||
Error = 1,
|
||||
Denied_UnknownObject = 2,
|
||||
Denied_AlreadyExists = 3,
|
||||
Denied_InvalidAttribute = 4,
|
||||
Denied_BadState = 5,
|
||||
};
|
||||
|
||||
} // namespace secsgem::gem
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
#include "secsgem/gem/store/alarms.hpp"
|
||||
#include "secsgem/gem/store/clock.hpp"
|
||||
#include "secsgem/gem/store/control_jobs.hpp"
|
||||
#include "secsgem/gem/store/equipment_constants.hpp"
|
||||
#include "secsgem/gem/store/event_reports.hpp"
|
||||
#include "secsgem/gem/store/host_commands.hpp"
|
||||
#include "secsgem/gem/store/limits.hpp"
|
||||
#include "secsgem/gem/store/process_jobs.hpp"
|
||||
#include "secsgem/gem/store/recipes.hpp"
|
||||
#include "secsgem/gem/store/spool.hpp"
|
||||
#include "secsgem/gem/store/status_variables.hpp"
|
||||
@@ -30,6 +32,8 @@ struct EquipmentDataModel {
|
||||
SpoolStore spool;
|
||||
LimitMonitorStore limits;
|
||||
TraceStore traces;
|
||||
ProcessJobStore process_jobs;
|
||||
ControlJobStore control_jobs;
|
||||
|
||||
// Convenience: VID -> value lookup spanning SVIDs and DVIDs.
|
||||
std::optional<s2::Item> vid_value(uint32_t vid) const {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/gem/store/host_commands.hpp" // HostCmdAck
|
||||
|
||||
namespace secsgem::gem {
|
||||
|
||||
// E40 §6 Process Job state model.
|
||||
//
|
||||
// Per E40-0705 §6.3 the PJ lifecycle is a directed graph over the eight
|
||||
// states below. The values match the PRJOBSTATE byte that S16F9 carries
|
||||
// on the wire (E40-0705 §10.3.2): 0 QUEUED, 1 SETTING-UP,
|
||||
// 2 WAITING-FOR-START, 3 PROCESSING, 4 PROCESS-COMPLETE, 5 PAUSED,
|
||||
// 6 STOPPING, 7 ABORTING. NoState (255) is our sentinel for "doesn't
|
||||
// exist yet / freshly deleted" so the same enum can be used in the API.
|
||||
enum class ProcessJobState : uint8_t {
|
||||
Queued = 0,
|
||||
SettingUp = 1,
|
||||
WaitingForStart = 2,
|
||||
Processing = 3,
|
||||
ProcessComplete = 4,
|
||||
Paused = 5,
|
||||
Stopping = 6,
|
||||
Aborting = 7,
|
||||
NoState = 255,
|
||||
};
|
||||
|
||||
const char* process_job_state_name(ProcessJobState s);
|
||||
std::optional<ProcessJobState> parse_process_job_state(const std::string& s);
|
||||
|
||||
// Inputs that drive the PJ FSM. Names mirror the E40 spec verbs: the
|
||||
// host-initiated PJSTART / PJPAUSE / ... arrive as S16F5 PRCMD strings;
|
||||
// the internal SetupComplete / ProcessComplete fire from the equipment's
|
||||
// application logic (recipe runner, abort controller, etc.).
|
||||
//
|
||||
// `Created` is a synthetic observer signal — the store fires it through
|
||||
// the change handler when a PJ first lands in Queued, so subscribers can
|
||||
// distinguish "created in Queued" from "transitioned into Queued" (the
|
||||
// latter never happens, but the signal is still useful for bookkeeping).
|
||||
// It deliberately does NOT appear in the transition table and the YAML
|
||||
// loader rejects `on: created` rows.
|
||||
enum class ProcessJobEvent {
|
||||
Created, // synthetic NoState -> Queued (observer only)
|
||||
Select, // QUEUED -> SETTING-UP (CJ promoted this PJ)
|
||||
SetupComplete, // SETTING-UP -> WAITING-FOR-START
|
||||
Start, // WAITING-FOR-START -> PROCESSING (host: PJSTART)
|
||||
Pause, // PROCESSING -> PAUSED (host: PJPAUSE)
|
||||
Resume, // PAUSED -> PROCESSING (host: PJRESUME)
|
||||
Stop, // ... -> STOPPING (host: PJSTOP)
|
||||
Abort, // ... -> ABORTING (host: PJABORT)
|
||||
HeadOfQueue, // QUEUED -> QUEUED (host: PJHOQ; reorder only)
|
||||
ProcessComplete, // PROCESSING/STOPPING -> PROCESS-COMPLETE
|
||||
AbortComplete, // ABORTING -> PROCESS-COMPLETE
|
||||
};
|
||||
|
||||
const char* process_job_event_name(ProcessJobEvent e);
|
||||
std::optional<ProcessJobEvent> parse_process_job_event(const std::string& s);
|
||||
|
||||
// Wire-name -> event mapping for the PRCMD string on S16F5. Returns
|
||||
// nullopt for unknown verbs (server should reply HCACK=InvalidCommand).
|
||||
std::optional<ProcessJobEvent> pr_cmd_to_event(const std::string& prcmd);
|
||||
|
||||
// One row of the PJ transition table. Same shape conventions as
|
||||
// ControlTransition (see control_state.hpp): `to` absent means the row
|
||||
// only validates the event (e.g. HOQ is a reorder, not a state change),
|
||||
// and `ack_code` is the HCACK the equipment should return to the host
|
||||
// when the event came from S16F5.
|
||||
struct ProcessJobTransition {
|
||||
ProcessJobState from;
|
||||
ProcessJobEvent on;
|
||||
std::optional<ProcessJobState> to;
|
||||
std::optional<uint8_t> ack_code; // HostCmdAck when applicable
|
||||
};
|
||||
|
||||
class ProcessJobTransitionTable {
|
||||
public:
|
||||
void add(ProcessJobTransition row);
|
||||
const ProcessJobTransition* find(ProcessJobState from,
|
||||
ProcessJobEvent on) const;
|
||||
std::size_t size() const { return rows_.size(); }
|
||||
const std::vector<ProcessJobTransition>& rows() const { return rows_; }
|
||||
|
||||
// Built-in default table matching data/process_job_state.yaml exactly.
|
||||
// Tests use this so they don't depend on the YAML being present.
|
||||
static ProcessJobTransitionTable default_table();
|
||||
|
||||
private:
|
||||
std::vector<ProcessJobTransition> rows_;
|
||||
};
|
||||
|
||||
// PJ FSM engine. One instance per Process Job — the ProcessJobStore
|
||||
// keeps them keyed by PRJOBID. Behaviour rules live in the table.
|
||||
class ProcessJobStateMachine {
|
||||
public:
|
||||
using StateChangeHandler =
|
||||
std::function<void(ProcessJobState from, ProcessJobState to,
|
||||
ProcessJobEvent trigger)>;
|
||||
|
||||
ProcessJobStateMachine();
|
||||
explicit ProcessJobStateMachine(ProcessJobTransitionTable table,
|
||||
ProcessJobState initial = ProcessJobState::Queued);
|
||||
|
||||
ProcessJobState state() const { return state_; }
|
||||
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
|
||||
|
||||
// Apply the host-initiated event from S16F5. Returns the HCACK the
|
||||
// equipment should reply with: Accept if a row exists with no
|
||||
// ack_code override, otherwise the row's ack. CannotDoNow if the
|
||||
// (state, event) pair has no matching row.
|
||||
HostCmdAck on_host_command(ProcessJobEvent event);
|
||||
|
||||
// Apply an internal event (SetupComplete, ProcessComplete,
|
||||
// AbortComplete). Returns true if a transition fired.
|
||||
bool on_internal(ProcessJobEvent event);
|
||||
|
||||
private:
|
||||
const ProcessJobTransition* fire(ProcessJobEvent on);
|
||||
void transition(ProcessJobState next, ProcessJobEvent trigger);
|
||||
|
||||
ProcessJobTransitionTable table_;
|
||||
ProcessJobState state_;
|
||||
StateChangeHandler on_change_;
|
||||
};
|
||||
|
||||
} // namespace secsgem::gem
|
||||
@@ -0,0 +1,120 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/gem/control_job_state.hpp"
|
||||
|
||||
namespace secsgem::gem {
|
||||
|
||||
// One Control Job — owns an ordered list of PRJOBIDs (process jobs).
|
||||
struct ControlJob {
|
||||
std::string ctljobid;
|
||||
std::vector<std::string> prjobids;
|
||||
std::unique_ptr<ControlJobStateMachine> fsm;
|
||||
};
|
||||
|
||||
class ControlJobStore {
|
||||
public:
|
||||
using TransitionTableFactory =
|
||||
std::function<ControlJobTransitionTable()>;
|
||||
using StateChangeHandler =
|
||||
std::function<void(const std::string& ctljobid,
|
||||
ControlJobState from, ControlJobState to,
|
||||
ControlJobEvent trigger)>;
|
||||
|
||||
ControlJobStore()
|
||||
: factory_([] { return ControlJobTransitionTable::default_table(); }) {}
|
||||
|
||||
void set_table_factory(TransitionTableFactory f) { factory_ = std::move(f); }
|
||||
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
|
||||
|
||||
enum class CreateResult {
|
||||
Created,
|
||||
Denied_AlreadyExists,
|
||||
Denied_UnknownPRJob,
|
||||
Denied_Empty,
|
||||
};
|
||||
|
||||
CreateResult create(std::string ctljobid,
|
||||
std::vector<std::string> prjobids,
|
||||
const std::function<bool(const std::string&)>& pj_exists =
|
||||
[](const std::string&) { return true; }) {
|
||||
if (jobs_.count(ctljobid)) return CreateResult::Denied_AlreadyExists;
|
||||
if (prjobids.empty()) return CreateResult::Denied_Empty;
|
||||
for (const auto& id : prjobids) {
|
||||
if (!pj_exists(id)) return CreateResult::Denied_UnknownPRJob;
|
||||
}
|
||||
auto fsm = std::make_unique<ControlJobStateMachine>(factory_(),
|
||||
ControlJobState::Queued);
|
||||
const std::string id_for_handler = ctljobid;
|
||||
if (on_change_) {
|
||||
auto cb = on_change_;
|
||||
fsm->set_state_change_handler(
|
||||
[cb, id_for_handler](ControlJobState from, ControlJobState to,
|
||||
ControlJobEvent trig) {
|
||||
cb(id_for_handler, from, to, trig);
|
||||
});
|
||||
}
|
||||
jobs_.emplace(ctljobid, ControlJob{ctljobid, std::move(prjobids),
|
||||
std::move(fsm)});
|
||||
if (on_change_) {
|
||||
on_change_(id_for_handler, ControlJobState::NoState,
|
||||
ControlJobState::Queued, ControlJobEvent::Created);
|
||||
}
|
||||
return CreateResult::Created;
|
||||
}
|
||||
|
||||
bool has(const std::string& ctljobid) const {
|
||||
return jobs_.count(ctljobid) > 0;
|
||||
}
|
||||
const ControlJob* get(const std::string& ctljobid) const {
|
||||
auto it = jobs_.find(ctljobid);
|
||||
return it == jobs_.end() ? nullptr : &it->second;
|
||||
}
|
||||
ControlJob* get(const std::string& ctljobid) {
|
||||
auto it = jobs_.find(ctljobid);
|
||||
return it == jobs_.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
ControlJobState state(const std::string& ctljobid) const {
|
||||
auto it = jobs_.find(ctljobid);
|
||||
return it == jobs_.end() ? ControlJobState::NoState
|
||||
: it->second.fsm->state();
|
||||
}
|
||||
|
||||
HostCmdAck on_host_command(const std::string& ctljobid, ControlJobEvent event) {
|
||||
auto* cj = get(ctljobid);
|
||||
if (!cj) return HostCmdAck::InvalidObject;
|
||||
return cj->fsm->on_host_command(event);
|
||||
}
|
||||
|
||||
bool fire_internal(const std::string& ctljobid, ControlJobEvent event) {
|
||||
auto* cj = get(ctljobid);
|
||||
if (!cj) return false;
|
||||
return cj->fsm->on_internal(event);
|
||||
}
|
||||
|
||||
bool remove(const std::string& ctljobid) {
|
||||
return jobs_.erase(ctljobid) > 0;
|
||||
}
|
||||
|
||||
std::size_t size() const { return jobs_.size(); }
|
||||
std::vector<std::string> ids() const {
|
||||
std::vector<std::string> out;
|
||||
out.reserve(jobs_.size());
|
||||
for (const auto& kv : jobs_) out.push_back(kv.first);
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
std::map<std::string, ControlJob> jobs_;
|
||||
TransitionTableFactory factory_;
|
||||
StateChangeHandler on_change_;
|
||||
};
|
||||
|
||||
} // namespace secsgem::gem
|
||||
@@ -25,11 +25,13 @@ struct EquipmentConstant {
|
||||
std::string max_str;
|
||||
};
|
||||
|
||||
// S2F16 EAC per SEMI E5: 0=OK, 1=one or more constants does not exist,
|
||||
// 2=busy, 3=one or more values out of range. Values 4-127 are reserved.
|
||||
enum class EquipmentAck : uint8_t {
|
||||
Accept = 0,
|
||||
Denied_UnknownEcid = 1,
|
||||
Denied_Busy = 3,
|
||||
Denied_OutOfRange = 4,
|
||||
Denied_Busy = 2,
|
||||
Denied_OutOfRange = 3,
|
||||
};
|
||||
|
||||
class EquipmentConstantStore {
|
||||
@@ -82,17 +84,22 @@ class EquipmentConstantStore {
|
||||
}
|
||||
|
||||
static bool extract_number(const s2::Item& item, double& out) {
|
||||
auto first = [&](const auto& v) -> bool {
|
||||
if (v.empty()) return false;
|
||||
out = static_cast<double>(v.front());
|
||||
return true;
|
||||
};
|
||||
switch (item.format()) {
|
||||
case s2::Format::U1: out = std::get<std::vector<uint8_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::U2: out = std::get<std::vector<uint16_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::U4: out = std::get<std::vector<uint32_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::U8: out = static_cast<double>(std::get<std::vector<uint64_t>>(item.storage()).front()); return true;
|
||||
case s2::Format::I1: out = std::get<std::vector<int8_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::I2: out = std::get<std::vector<int16_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::I4: out = std::get<std::vector<int32_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::I8: out = static_cast<double>(std::get<std::vector<int64_t>>(item.storage()).front()); return true;
|
||||
case s2::Format::F4: out = std::get<std::vector<float>>(item.storage()).front(); return true;
|
||||
case s2::Format::F8: out = std::get<std::vector<double>>(item.storage()).front(); return true;
|
||||
case s2::Format::U1: return first(std::get<std::vector<uint8_t>>(item.storage()));
|
||||
case s2::Format::U2: return first(std::get<std::vector<uint16_t>>(item.storage()));
|
||||
case s2::Format::U4: return first(std::get<std::vector<uint32_t>>(item.storage()));
|
||||
case s2::Format::U8: return first(std::get<std::vector<uint64_t>>(item.storage()));
|
||||
case s2::Format::I1: return first(std::get<std::vector<int8_t>>(item.storage()));
|
||||
case s2::Format::I2: return first(std::get<std::vector<int16_t>>(item.storage()));
|
||||
case s2::Format::I4: return first(std::get<std::vector<int32_t>>(item.storage()));
|
||||
case s2::Format::I8: return first(std::get<std::vector<int64_t>>(item.storage()));
|
||||
case s2::Format::F4: return first(std::get<std::vector<float>>(item.storage()));
|
||||
case s2::Format::F8: return first(std::get<std::vector<double>>(item.storage()));
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,15 @@ struct ReportData {
|
||||
std::vector<s2::Item> values;
|
||||
};
|
||||
|
||||
// S2F34 DRACK per SEMI E5/E30: 0=accept, 1=insufficient space,
|
||||
// 2=invalid format, 3=at least one RPTID already defined,
|
||||
// 4=at least one VID does not exist.
|
||||
enum class DefineReportAck : uint8_t {
|
||||
Accept = 0,
|
||||
InsufficientSpace = 1,
|
||||
InvalidFormat = 2,
|
||||
RptidAlreadyDefined = 3,
|
||||
InvalidVid = 5,
|
||||
InvalidVid = 4,
|
||||
};
|
||||
|
||||
enum class LinkEventAck : uint8_t {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/gem/process_job_state.hpp"
|
||||
|
||||
namespace secsgem::gem {
|
||||
|
||||
// One Process Job record. The FSM is heap-allocated through unique_ptr so
|
||||
// the per-PJ state-change handler can capture a stable pointer to
|
||||
// `this`-style state without invalidation on map rehash.
|
||||
struct ProcessJob {
|
||||
std::string prjobid;
|
||||
std::string ppid; // recipe identifier
|
||||
std::vector<std::string> mtrloutspec; // material identifiers
|
||||
bool alert_enabled = true; // S16F9 alerts on/off
|
||||
std::unique_ptr<ProcessJobStateMachine> fsm;
|
||||
};
|
||||
|
||||
class ProcessJobStore {
|
||||
public:
|
||||
using TransitionTableFactory =
|
||||
std::function<ProcessJobTransitionTable()>;
|
||||
using StateChangeHandler =
|
||||
std::function<void(const std::string& prjobid,
|
||||
ProcessJobState from, ProcessJobState to,
|
||||
ProcessJobEvent trigger)>;
|
||||
|
||||
ProcessJobStore()
|
||||
: factory_([] { return ProcessJobTransitionTable::default_table(); }) {}
|
||||
|
||||
// The per-PJ FSM closes over `this`, so the store must keep a stable
|
||||
// address. unique_ptr makes copying impossible anyway; moves would
|
||||
// silently dangle the per-PJ lambdas — disallow them explicitly.
|
||||
ProcessJobStore(const ProcessJobStore&) = delete;
|
||||
ProcessJobStore& operator=(const ProcessJobStore&) = delete;
|
||||
ProcessJobStore(ProcessJobStore&&) = delete;
|
||||
ProcessJobStore& operator=(ProcessJobStore&&) = delete;
|
||||
|
||||
void set_table_factory(TransitionTableFactory f) { factory_ = std::move(f); }
|
||||
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
|
||||
|
||||
enum class CreateResult {
|
||||
Created,
|
||||
Denied_AlreadyExists,
|
||||
Denied_InvalidPpid,
|
||||
};
|
||||
|
||||
// Validate `ppid` against the optional callback; if accepted, create
|
||||
// the PJ in Queued and fire the Created -> Queued change handler.
|
||||
CreateResult create(std::string prjobid, std::string ppid,
|
||||
std::vector<std::string> materials,
|
||||
const std::function<bool(const std::string&)>& ppid_exists =
|
||||
[](const std::string&) { return true; }) {
|
||||
if (jobs_.count(prjobid)) return CreateResult::Denied_AlreadyExists;
|
||||
if (!ppid_exists(ppid)) return CreateResult::Denied_InvalidPpid;
|
||||
auto fsm = std::make_unique<ProcessJobStateMachine>(factory_(),
|
||||
ProcessJobState::Queued);
|
||||
// Dispatch through *this so a later set_state_change_handler() takes
|
||||
// effect for existing PJs (the captured-at-create snapshot pattern
|
||||
// would otherwise pin the old handler on jobs already in the map).
|
||||
fsm->set_state_change_handler(
|
||||
[this, id = prjobid](ProcessJobState from, ProcessJobState to,
|
||||
ProcessJobEvent trig) {
|
||||
if (on_change_) on_change_(id, from, to, trig);
|
||||
});
|
||||
order_.push_back(prjobid);
|
||||
jobs_.emplace(prjobid, ProcessJob{prjobid, std::move(ppid),
|
||||
std::move(materials), true,
|
||||
std::move(fsm)});
|
||||
// Synthetic NoState -> Queued so subscribers observe creation. The
|
||||
// server filters this so it doesn't emit a bogus S16F9 for a PJ
|
||||
// that's still being acked.
|
||||
if (on_change_) {
|
||||
on_change_(jobs_.find(prjobid)->first, ProcessJobState::NoState,
|
||||
ProcessJobState::Queued, ProcessJobEvent::Created);
|
||||
}
|
||||
return CreateResult::Created;
|
||||
}
|
||||
|
||||
bool has(const std::string& prjobid) const {
|
||||
return jobs_.count(prjobid) > 0;
|
||||
}
|
||||
|
||||
const ProcessJob* get(const std::string& prjobid) const {
|
||||
auto it = jobs_.find(prjobid);
|
||||
return it == jobs_.end() ? nullptr : &it->second;
|
||||
}
|
||||
ProcessJob* get(const std::string& prjobid) {
|
||||
auto it = jobs_.find(prjobid);
|
||||
return it == jobs_.end() ? nullptr : &it->second;
|
||||
}
|
||||
|
||||
ProcessJobState state(const std::string& prjobid) const {
|
||||
auto it = jobs_.find(prjobid);
|
||||
return it == jobs_.end() ? ProcessJobState::NoState
|
||||
: it->second.fsm->state();
|
||||
}
|
||||
|
||||
// Host-initiated S16F5 PRJobCommand. Returns InvalidObject for unknown
|
||||
// PJs; CannotDoNow when the current PJ state has no row for the event.
|
||||
// For HOQ the FSM gates legality (Queued only) and the store performs
|
||||
// the actual reorder so the next CJ Select picks this PJ first.
|
||||
HostCmdAck on_host_command(const std::string& prjobid, ProcessJobEvent event) {
|
||||
auto* pj = get(prjobid);
|
||||
if (!pj) return HostCmdAck::InvalidObject;
|
||||
HostCmdAck ack = pj->fsm->on_host_command(event);
|
||||
if (ack == HostCmdAck::Accept && event == ProcessJobEvent::HeadOfQueue) {
|
||||
move_to_head(prjobid);
|
||||
}
|
||||
return ack;
|
||||
}
|
||||
|
||||
// Position of `prjobid` in the queue order (insertion order; HOQ rewrites
|
||||
// it). Returns -1 if unknown. Exposed for tests and CJ promotion logic.
|
||||
int position(const std::string& prjobid) const {
|
||||
auto it = std::find(order_.begin(), order_.end(), prjobid);
|
||||
return it == order_.end() ? -1 : static_cast<int>(it - order_.begin());
|
||||
}
|
||||
|
||||
// Internal events from the application (recipe runner etc.).
|
||||
bool fire_internal(const std::string& prjobid, ProcessJobEvent event) {
|
||||
auto* pj = get(prjobid);
|
||||
if (!pj) return false;
|
||||
return pj->fsm->on_internal(event);
|
||||
}
|
||||
|
||||
// Dequeue S16F13: only legal while QUEUED.
|
||||
HostCmdAck dequeue(const std::string& prjobid) {
|
||||
auto it = jobs_.find(prjobid);
|
||||
if (it == jobs_.end()) return HostCmdAck::InvalidObject;
|
||||
if (it->second.fsm->state() != ProcessJobState::Queued)
|
||||
return HostCmdAck::CannotDoNow;
|
||||
erase_from_order(prjobid);
|
||||
jobs_.erase(it);
|
||||
return HostCmdAck::Accept;
|
||||
}
|
||||
|
||||
// Remove a terminal job from the store (caller responsibility to ensure
|
||||
// ProcessComplete before deleting). Used after CJ Completed cleanup.
|
||||
bool remove(const std::string& prjobid) {
|
||||
erase_from_order(prjobid);
|
||||
return jobs_.erase(prjobid) > 0;
|
||||
}
|
||||
|
||||
// Per-PJ S16F9 alert gate. E40 §10.3 leaves PRALERT control to the host
|
||||
// (S16F1/F2 in the full multi-create form, which we don't model yet);
|
||||
// exposed here so application code can toggle alerts directly.
|
||||
bool set_alert(const std::string& prjobid, bool enabled) {
|
||||
auto* pj = get(prjobid);
|
||||
if (!pj) return false;
|
||||
pj->alert_enabled = enabled;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::size_t size() const { return jobs_.size(); }
|
||||
|
||||
// Insertion-order list of PRJOBIDs, mutated by HOQ. Used by CJ promotion
|
||||
// logic and by tests that need to assert ordering.
|
||||
const std::vector<std::string>& ids() const { return order_; }
|
||||
|
||||
private:
|
||||
void erase_from_order(const std::string& prjobid) {
|
||||
auto it = std::find(order_.begin(), order_.end(), prjobid);
|
||||
if (it != order_.end()) order_.erase(it);
|
||||
}
|
||||
void move_to_head(const std::string& prjobid) {
|
||||
auto it = std::find(order_.begin(), order_.end(), prjobid);
|
||||
if (it == order_.end() || it == order_.begin()) return;
|
||||
std::rotate(order_.begin(), it, it + 1);
|
||||
}
|
||||
|
||||
std::map<std::string, ProcessJob> jobs_;
|
||||
std::vector<std::string> order_; // queue position (E40 HOQ-aware)
|
||||
TransitionTableFactory factory_;
|
||||
StateChangeHandler on_change_;
|
||||
};
|
||||
|
||||
} // namespace secsgem::gem
|
||||
Reference in New Issue
Block a user