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:
2026-06-07 21:00:32 +02:00
parent 1f67aad985
commit 90c177b7ce
33 changed files with 3122 additions and 62 deletions
+18
View File
@@ -7,8 +7,10 @@
#include <utility>
#include <vector>
#include "secsgem/gem/control_job_state.hpp"
#include "secsgem/gem/control_state.hpp"
#include "secsgem/gem/data_model.hpp"
#include "secsgem/gem/process_job_state.hpp"
// YAML-driven loaders for the E30 control-state transition table and the
// equipment data dictionary. Behaviour rules live in the YAML; this is the
@@ -42,4 +44,20 @@ struct EquipmentDescriptor {
EquipmentDescriptor load_equipment(const std::string& yaml_path,
gem::EquipmentDataModel& model);
struct ProcessJobStateConfig {
gem::ProcessJobTransitionTable table;
gem::ProcessJobState initial = gem::ProcessJobState::Queued;
};
// Loads data/process_job_state.yaml.
ProcessJobStateConfig load_process_job_state(const std::string& yaml_path);
struct ControlJobStateConfig {
gem::ControlJobTransitionTable table;
gem::ControlJobState initial = gem::ControlJobState::Queued;
};
// Loads data/control_job_state.yaml.
ControlJobStateConfig load_control_job_state(const std::string& yaml_path);
} // namespace secsgem::config
+140
View File
@@ -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
+111
View File
@@ -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
+4
View File
@@ -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 {
+130
View File
@@ -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
+120
View File
@@ -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;
}
}
+4 -1
View File
@@ -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 {
+185
View File
@@ -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
+15 -2
View File
@@ -16,6 +16,8 @@ enum class Format : uint8_t {
Binary = 010, // 8
Boolean = 011, // 9
ASCII = 020, // 16
JIS8 = 021, // 17 — 8-bit JIS (single-byte Japanese text)
C2 = 022, // 18 — 2-byte Unicode code points (big-endian)
I8 = 030, // 24
I1 = 031, // 25
I2 = 032, // 26
@@ -34,6 +36,8 @@ inline const char* format_name(Format f) {
case Format::Binary: return "B";
case Format::Boolean: return "BOOLEAN";
case Format::ASCII: return "A";
case Format::JIS8: return "J";
case Format::C2: return "C";
case Format::I8: return "I8";
case Format::I1: return "I1";
case Format::I2: return "I2";
@@ -55,10 +59,12 @@ inline std::size_t element_size(Format f) {
switch (f) {
case Format::List: return 0;
case Format::ASCII:
case Format::JIS8:
case Format::Binary:
case Format::Boolean:
case Format::U1:
case Format::I1: return 1;
case Format::C2:
case Format::U2:
case Format::I2: return 2;
case Format::U4:
@@ -81,13 +87,13 @@ class Item {
using List = std::vector<Item>;
using Storage = std::variant<
List, // List
std::string, // ASCII
std::string, // ASCII, JIS-8
std::vector<uint8_t>, // Binary, Boolean, U1
std::vector<int8_t>, // I1
std::vector<int16_t>, // I2
std::vector<int32_t>, // I4
std::vector<int64_t>, // I8
std::vector<uint16_t>, // U2
std::vector<uint16_t>, // U2, C2 (Unicode code points)
std::vector<uint32_t>, // U4
std::vector<uint64_t>, // U8
std::vector<float>, // F4
@@ -107,6 +113,8 @@ class Item {
// --- Factory functions -------------------------------------------------
static Item list(List items) { return Item(Format::List, std::move(items)); }
static Item ascii(std::string s) { return Item(Format::ASCII, std::move(s)); }
static Item jis8(std::string s) { return Item(Format::JIS8, std::move(s)); }
static Item c2(std::vector<uint16_t> code_points) { return Item(Format::C2, std::move(code_points)); }
static Item binary(std::vector<uint8_t> b) { return Item(Format::Binary, std::move(b)); }
static Item boolean(std::vector<uint8_t> b) { return Item(Format::Boolean, std::move(b)); }
static Item boolean(bool b) { return Item(Format::Boolean, std::vector<uint8_t>{static_cast<uint8_t>(b ? 1 : 0)}); }
@@ -141,6 +149,11 @@ class Item {
const List& as_list() const { return std::get<List>(data_); }
List& as_list() { return std::get<List>(data_); }
const std::string& as_ascii() const { return std::get<std::string>(data_); }
// JIS-8 shares the std::string storage slot (it's a single-byte
// encoding like ASCII); callers disambiguate via `format()`.
const std::string& as_jis8() const { return std::get<std::string>(data_); }
// C2 (Unicode) shares the std::vector<uint16_t> storage with U2.
const std::vector<uint16_t>& as_c2() const { return std::get<std::vector<uint16_t>>(data_); }
const std::vector<uint8_t>& as_bytes() const { return std::get<std::vector<uint8_t>>(data_); }
const Storage& storage() const { return data_; }