diff --git a/CMakeLists.txt b/CMakeLists.txt index 2752625..011a8c5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/COMPLIANCE.md b/COMPLIANCE.md index cb0f563..4a67be0 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -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 (S7F23–F26) | A separate SEMI standard. E30 GEM Process Program Management only requires the unformatted set (S7F1/F3/F5/F17/F19), which we have. | --- diff --git a/data/messages.yaml b/data/messages.yaml index d5bf5f3..f706c39 100644 --- a/data/messages.yaml +++ b/data/messages.yaml @@ -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 (S7F23–F26). + # + # 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 diff --git a/include/secsgem/gem/store/recipes.hpp b/include/secsgem/gem/store/recipes.hpp index 79f1db5..61e8a5a 100644 --- a/include/secsgem/gem/store/recipes.hpp +++ b/include/secsgem/gem/store/recipes.hpp @@ -7,6 +7,8 @@ #include #include +#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 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 steps; +}; + class RecipeStore { public: void add(std::string ppid, std::string body) { @@ -31,19 +55,47 @@ class RecipeStore { } std::vector list() const { std::vector 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 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 by_ppid_; + std::map by_ppid_; + std::map formatted_; }; } // namespace secsgem::gem diff --git a/tests/test_e42_formatted_pp.cpp b/tests/test_e42_formatted_pp.cpp new file mode 100644 index 0000000..c8c890f --- /dev/null +++ b/tests/test_e42_formatted_pp.cpp @@ -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 + +#include +#include + +#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(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()); +}