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
+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