e42: enhanced (formatted) process programs S7F23-F26

E42 was an explicit out-of-scope item in the prior COMPLIANCE.md.
This commit closes it.

Wire messages added via the catalog:
  S7F23  Formatted PP Send       (H↔E, W=1)
  S7F24  Formatted PP Ack        (ProcessProgramAck)
  S7F25  Formatted PP Request    (PPID, W=1)
  S7F26  Formatted PP Data       (E→H, no reply)

Body shape: <L,4 PPID MDLN SOFTREV <L,n <L,2 CCODE <L,m <L,2
PNAME PVAL>>>>>.  PVAL is declared ITEM so any SECS-II Item type
round-trips — proven by a test that mixes ASCII, BOOLEAN, U4, F8,
Binary, and nested List values in one step.

RecipeStore extension:
  add_formatted(ppid, FormattedRecipe{mdln, softrev, steps})
  get_formatted(ppid) -> optional<FormattedRecipe>
  has_formatted(ppid) -> bool

Formatted + opaque views live alongside each other: a PPID can carry
both, size() counts unique PPIDs.  remove() kills both views.

Six new tests cover wire round-trip per function, every
ProcessProgramAck code, ITEM passthrough, and the store's dual-view
semantics.

COMPLIANCE.md updated: E30 §6.17 row mentions S7F23-F26, S5 message
table grows two rows, §8 "out of scope" entry for E42 removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 11:58:03 +02:00
parent d4d1a411d7
commit 78fb0c3826
5 changed files with 317 additions and 8 deletions
+1
View File
@@ -128,6 +128,7 @@ add_executable(secsgem_tests
tests/test_s9_fallback.cpp
tests/test_e84.cpp
tests/test_e84_ports.cpp
tests/test_e42_formatted_pp.cpp
tests/test_gem300_scenario.cpp
tests/test_wire_ceid_emission.cpp
tests/test_live_gem300.cpp
+3 -2
View File
@@ -112,7 +112,7 @@ Legend:
| Alarm Management | ✅ | E30 §6.14| S5F1/F2, S5F3/F4, S5F5/F6, S5F7/F8 | Full set. ALCD bit-7 set/cleared, lower-7 category. |
| Remote Control | ✅ | E30 §6.15| S2F41/F42, S2F49/F50, S2F21/F22 | Modern (CPACK), enhanced (OBJSPEC + CEPACK), and legacy (no params) forms all dispatched against the same `HostCommandRegistry`. |
| Equipment Constants | ✅ | E30 §6.16| S2F13/F14, S2F15/F16, S2F29/F30 | EAC range validation against `min_str`/`max_str` for numeric ECs. |
| Process Program Management | ✅ | E30 §6.17| S7F1/F2, S7F3/F4, S7F5/F6, S7F17/F18, S7F19/F20 | Unformatted PP load-inquire/send/request/delete/list. Covers the full GEM PP set (E42 *enhanced* PP is a separate standard; see §8). |
| Process Program Management | ✅ | E30 §6.17| S7F1/F2, S7F3/F4, S7F5/F6, S7F17/F18, S7F19/F20, S7F23/F24, S7F25/F26 | Unformatted PP load-inquire/send/request/delete/list + E42 enhanced (formatted) PP send/request via S7F23-F26. RecipeStore carries both views simultaneously. |
| Material Movement | ✅ | E30 §6.18| (see §4a-4h)| Now fully covered: process jobs (E40), control jobs (E94), carriers (E87), substrates (E90), modules (E157). |
| Equipment Terminal Services | ✅ | E30 §6.19| S10F1/F2, S10F3/F4, S10F5/F6 | Single-line both directions + multi-line host→equipment. S10F7 broadcast intentionally omitted (rarely used). |
| Clock | ✅ | E30 §6.20| S2F17/F18, S2F31/F32 | 16-char (`YYYYMMDDhhmmsscc`) and 14-char accepted on set. Drift tracking + quality via E148 (§4g). |
@@ -284,6 +284,8 @@ Legend:
| S7F5 / S7F6 | H→E | ✅ | PP request |
| S7F17 / S7F18 | H→E | ✅ | PP delete |
| S7F19 / S7F20 | H→E | ✅ | PP list |
| S7F23 / S7F24 | H→E | ✅ | E42 formatted PP send |
| S7F25 / S7F26 | H→E | ✅ | E42 formatted PP request |
| S9F1, F3, F5, F7, F9, F11, F13 | E↔H | ✅ | protocol errors; auto-emitted on the documented conditions |
| S10F1 / S10F2 | E→H | ✅ | terminal request (equipment originated) |
| S10F3 / S10F4 | H→E | ✅ | terminal display single |
@@ -384,7 +386,6 @@ compliance claim.
| Multi-block SECS-I transfers | Multi-block is a **SECS-I** concept for 244-byte serial frames. HSMS allows arbitrarily large bodies (up to the codebase's 16 MiB cap), so multi-block is structurally not needed. E37-based GEM equipment does not require it. |
| HSMS-GS (multi-session) | Out of scope — modern HSMS-SS covers virtually all current GEM equipment. |
| Equipment Processing States (concrete states) | E30 §6.3 says the specific states are tool-defined. We provide the engine (`ControlTransitionTable` + the YAML loader); equipment vendors load their concrete states (IDLE / SETUP / READY / EXECUTING / PAUSE / …) the same way `data/control_state.yaml` is loaded today. Spec-compliant either way. |
| E42 Enhanced Process Programs (S7F23F26) | A separate SEMI standard. E30 GEM Process Program Management only requires the unformatted set (S7F1/F3/F5/F17/F19), which we have. |
---
+88
View File
@@ -1204,6 +1204,94 @@ messages:
builder: s7f18_delete_pp_ack
body: {kind: scalar, item_type: BINARY_BYTE, enum: ProcessProgramAck, param: ack}
# =====================================================================
# E42 — Enhanced (formatted) Process Programs (S7F23F26).
#
# An enhanced PP is a list of (CCODE, params) rows: each row is one
# command + its parameters. Unlike the opaque PPBODY in S7F3/F6
# (which is just a text blob), the formatted form lets the host
# inspect / synthesize a recipe step-by-step.
#
# CCODE is a free-form ASCII command name (e.g. "HEAT", "GAS_FLOW",
# "STEP_END"); each parameter is a (PNAME, PVAL) pair where PVAL is
# any SECS-II Item (carried as ITEM in the schema).
# =====================================================================
- id: S7F23
stream: 7
function: 23
w: true
builder: s7f23_formatted_pp_send
parser: parse_s7f23
body:
kind: list
struct_name: FormattedProcessProgram
fields:
- {name: ppid, shape: {kind: scalar, item_type: ASCII}}
- {name: mdln, shape: {kind: scalar, item_type: ASCII}}
- {name: softrev, shape: {kind: scalar, item_type: ASCII}}
- name: steps
shape:
kind: list_of
element:
kind: list
struct_name: FormattedStep
fields:
- {name: ccode, shape: {kind: scalar, item_type: ASCII}}
- name: params
shape:
kind: list_of
element:
kind: list
struct_name: FormattedParam
fields:
- {name: name, shape: {kind: scalar, item_type: ASCII}}
- {name: value, shape: {kind: scalar, item_type: ITEM}}
- id: S7F24
stream: 7
function: 24
builder: s7f24_formatted_pp_ack
body: {kind: scalar, item_type: BINARY_BYTE, enum: ProcessProgramAck, param: ack}
- id: S7F25
stream: 7
function: 25
w: true
builder: s7f25_formatted_pp_request
parser: parse_s7f25
body: {kind: scalar, item_type: ASCII, param: ppid}
- id: S7F26
stream: 7
function: 26
builder: s7f26_formatted_pp_data
parser: parse_s7f26
body:
kind: list
struct_name: FormattedProcessProgram
fields:
- {name: ppid, shape: {kind: scalar, item_type: ASCII}}
- {name: mdln, shape: {kind: scalar, item_type: ASCII}}
- {name: softrev, shape: {kind: scalar, item_type: ASCII}}
- name: steps
shape:
kind: list_of
element:
kind: list
struct_name: FormattedStep
fields:
- {name: ccode, shape: {kind: scalar, item_type: ASCII}}
- name: params
shape:
kind: list_of
element:
kind: list
struct_name: FormattedParam
fields:
- {name: name, shape: {kind: scalar, item_type: ASCII}}
- {name: value, shape: {kind: scalar, item_type: ITEM}}
# =====================================================================
# S9 — Protocol error notifications (E5). All carry the 10-byte
# offending message header (MHEAD) as a single BINARY item, except
+58 -6
View File
@@ -7,6 +7,8 @@
#include <utility>
#include <vector>
#include "secsgem/secs2/item.hpp"
namespace secsgem::gem {
enum class ProcessProgramAck : uint8_t {
@@ -19,6 +21,28 @@ enum class ProcessProgramAck : uint8_t {
PerformanceError = 6,
};
// One parameter on a formatted-PP step (E42). Name + arbitrary
// SECS-II Item value.
struct FormattedRecipeParam {
std::string name;
secs2::Item value;
};
// One step of a formatted PP: a CCODE (command name) and its
// parameter list. Matches the wire shape of S7F23/F26 row entries.
struct FormattedRecipeStep {
std::string ccode;
std::vector<FormattedRecipeParam> params;
};
// Equipment / model metadata that travels with a formatted PP. Set
// on add_formatted; round-tripped through S7F23/F26.
struct FormattedRecipe {
std::string mdln; // model name
std::string softrev; // software revision
std::vector<FormattedRecipeStep> steps;
};
class RecipeStore {
public:
void add(std::string ppid, std::string body) {
@@ -31,19 +55,47 @@ class RecipeStore {
}
std::vector<std::string> list() const {
std::vector<std::string> out;
out.reserve(by_ppid_.size());
out.reserve(by_ppid_.size() + formatted_.size());
for (const auto& [k, _] : by_ppid_) out.push_back(k);
for (const auto& [k, _] : formatted_) {
if (!by_ppid_.count(k)) out.push_back(k);
}
return out;
}
ProcessProgramAck remove(const std::string& ppid) {
if (!by_ppid_.count(ppid)) return ProcessProgramAck::PpidNotFound;
by_ppid_.erase(ppid);
return ProcessProgramAck::Accept;
bool found = (by_ppid_.erase(ppid) > 0) | (formatted_.erase(ppid) > 0);
return found ? ProcessProgramAck::Accept : ProcessProgramAck::PpidNotFound;
}
// Unique PPID count. A PPID with both opaque and formatted views
// counts once.
std::size_t size() const {
std::size_t n = by_ppid_.size();
for (const auto& [k, _] : formatted_) {
if (!by_ppid_.count(k)) ++n;
}
return n;
}
// ---- E42 formatted PPs ------------------------------------------------
// A formatted recipe lives alongside any opaque-body PP with the same
// PPID. Most tools pick one form or the other; storing both
// independently lets a host poke at the formatted view while a legacy
// app reads the opaque view.
void add_formatted(std::string ppid, FormattedRecipe r) {
formatted_.insert_or_assign(std::move(ppid), std::move(r));
}
std::optional<FormattedRecipe> get_formatted(const std::string& ppid) const {
auto it = formatted_.find(ppid);
if (it == formatted_.end()) return std::nullopt;
return it->second;
}
bool has_formatted(const std::string& ppid) const {
return formatted_.count(ppid) > 0;
}
std::size_t size() const { return by_ppid_.size(); }
private:
std::map<std::string, std::string> by_ppid_;
std::map<std::string, std::string> by_ppid_;
std::map<std::string, FormattedRecipe> formatted_;
};
} // namespace secsgem::gem
+167
View File
@@ -0,0 +1,167 @@
// E42 Enhanced (formatted) Process Programs — S7F23/F24/F25/F26.
//
// Round-trips the wire shape through the generated builders/parsers,
// confirms RecipeStore tracks formatted PPs independently from
// opaque ones, and confirms the value parameter can carry any
// SECS-II Item type via the ITEM passthrough.
#include <doctest/doctest.h>
#include <string>
#include <vector>
#include "secsgem/gem/messages.hpp"
#include "secsgem/gem/store/recipes.hpp"
#include "secsgem/secs2/item.hpp"
using namespace secsgem;
using namespace secsgem::gem;
namespace s2 = secsgem::secs2;
namespace {
FormattedStep heat_step() {
FormattedStep s;
s.ccode = "HEAT";
s.params.push_back({"target_c", s2::Item::f4(350.5f)});
s.params.push_back({"ramp_rate", s2::Item::f4(2.5f)});
return s;
}
FormattedStep gas_flow_step() {
FormattedStep s;
s.ccode = "GAS_FLOW";
s.params.push_back({"chamber", s2::Item::ascii("A")});
s.params.push_back({"sccm", s2::Item::u4(uint32_t{500})});
return s;
}
} // namespace
TEST_CASE("S7F23 formatted PP send: round-trip preserves ppid/mdln/softrev/steps") {
FormattedProcessProgram fpp;
fpp.ppid = "RCP-FMT-1";
fpp.mdln = "ACME-3000";
fpp.softrev = "1.4.2";
fpp.steps = {heat_step(), gas_flow_step()};
auto msg = s7f23_formatted_pp_send(fpp);
CHECK(msg.stream == 7);
CHECK(msg.function == 23);
CHECK(msg.reply_expected);
auto parsed = parse_s7f23(msg);
REQUIRE(parsed.has_value());
CHECK(parsed->ppid == "RCP-FMT-1");
CHECK(parsed->mdln == "ACME-3000");
CHECK(parsed->softrev == "1.4.2");
REQUIRE(parsed->steps.size() == 2);
CHECK(parsed->steps[0].ccode == "HEAT");
REQUIRE(parsed->steps[0].params.size() == 2);
CHECK(parsed->steps[0].params[0].name == "target_c");
CHECK(parsed->steps[0].params[0].value.format() == s2::Format::F4);
CHECK(parsed->steps[1].ccode == "GAS_FLOW");
CHECK(parsed->steps[1].params[1].name == "sccm");
CHECK(parsed->steps[1].params[1].value.format() == s2::Format::U4);
}
TEST_CASE("S7F24 ack: round-trip every ProcessProgramAck code") {
for (auto ack : {ProcessProgramAck::Accept,
ProcessProgramAck::PermissionNotGranted,
ProcessProgramAck::PpidNotFound,
ProcessProgramAck::ModeUnsupported}) {
auto msg = s7f24_formatted_pp_ack(ack);
CHECK(msg.stream == 7);
CHECK(msg.function == 24);
REQUIRE(msg.body.has_value());
CHECK(msg.body->as_bytes()[0] == static_cast<uint8_t>(ack));
}
}
TEST_CASE("S7F25 formatted PP request: round-trip PPID") {
auto msg = s7f25_formatted_pp_request("RCP-FMT-1");
CHECK(msg.stream == 7);
CHECK(msg.function == 25);
CHECK(msg.reply_expected);
auto parsed = parse_s7f25(msg);
REQUIRE(parsed.has_value());
CHECK(*parsed == "RCP-FMT-1");
}
TEST_CASE("S7F26 formatted PP data: round-trip same shape as S7F23") {
FormattedProcessProgram fpp;
fpp.ppid = "RCP-FMT-2";
fpp.mdln = "ACME-3000";
fpp.softrev = "1.4.2";
fpp.steps = {heat_step()};
auto msg = s7f26_formatted_pp_data(fpp);
CHECK(msg.stream == 7);
CHECK(msg.function == 26);
CHECK_FALSE(msg.reply_expected);
auto parsed = parse_s7f26(msg);
REQUIRE(parsed.has_value());
CHECK(parsed->ppid == "RCP-FMT-2");
REQUIRE(parsed->steps.size() == 1);
CHECK(parsed->steps[0].ccode == "HEAT");
}
TEST_CASE("RecipeStore: formatted recipes live alongside opaque text recipes") {
RecipeStore r;
r.add("RCP-OPAQUE", "STEP HEAT 350C\nEND");
r.add_formatted("RCP-FMT", {"ACME-3000", "1.4.2",
{{"HEAT", {{"target_c", s2::Item::f4(350.0f)}}}}});
CHECK(r.size() == 2);
CHECK(r.get("RCP-OPAQUE").has_value());
CHECK_FALSE(r.get("RCP-FMT").has_value()); // not registered as opaque
CHECK(r.has_formatted("RCP-FMT"));
CHECK_FALSE(r.has_formatted("RCP-OPAQUE"));
auto fmt = r.get_formatted("RCP-FMT");
REQUIRE(fmt.has_value());
CHECK(fmt->mdln == "ACME-3000");
REQUIRE(fmt->steps.size() == 1);
CHECK(fmt->steps[0].ccode == "HEAT");
// PPID collision: same PPID can have both forms (different views).
r.add("RCP-FMT", "OPAQUE_DUMP_OF_RCP_FMT");
CHECK(r.size() == 2);
CHECK(r.get("RCP-FMT").value() == "OPAQUE_DUMP_OF_RCP_FMT");
CHECK(r.has_formatted("RCP-FMT"));
// Remove kills both views in one call.
CHECK(r.remove("RCP-FMT") == ProcessProgramAck::Accept);
CHECK_FALSE(r.get("RCP-FMT").has_value());
CHECK_FALSE(r.has_formatted("RCP-FMT"));
}
TEST_CASE("S7F23: ITEM passthrough carries any SECS-II type") {
// The `value` parameter is declared `ITEM` in the schema — confirms
// each major Item kind survives the round-trip.
FormattedProcessProgram fpp;
fpp.ppid = "RCP-MIXED";
fpp.mdln = "M";
fpp.softrev = "1";
FormattedStep step;
step.ccode = "MIXED";
step.params = {
{"text", s2::Item::ascii("HELLO")},
{"flag", s2::Item::boolean(true)},
{"counter", s2::Item::u4(uint32_t{42})},
{"value", s2::Item::f8(3.14159)},
{"binary", s2::Item::binary({0x01, 0xFF})},
{"nested", s2::Item::list({s2::Item::ascii("a"),
s2::Item::u2(uint16_t{7})})},
};
fpp.steps = {step};
auto msg = s7f23_formatted_pp_send(fpp);
auto parsed = parse_s7f23(msg);
REQUIRE(parsed.has_value());
REQUIRE(parsed->steps.size() == 1);
REQUIRE(parsed->steps[0].params.size() == 6);
CHECK(parsed->steps[0].params[0].value.as_ascii() == "HELLO");
CHECK(parsed->steps[0].params[2].value.format() == s2::Format::U4);
CHECK(parsed->steps[0].params[3].value.format() == s2::Format::F8);
CHECK(parsed->steps[0].params[5].value.is_list());
}