From 813e01140919a1b8f3a7d542d2c7358c42473c98 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Sun, 7 Jun 2026 00:30:43 +0200 Subject: [PATCH] Close COMPLIANCE.md gap: Documentation (S1F19-F22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the GEM "Documentation" Fundamental capability: the equipment now self-reports which GEM capabilities it supports, and the host can discover the DVID namelist with the same shape used for SVIDs. Catalog (data/messages.yaml -> generated messages.hpp) S1F19 W header-only Get GEM Compliance Request S1F20 >>> Get GEM Compliance Data S1F21 W > DVID Namelist Request (n=0 = all) S1F22 >> DVID Namelist Data Codegen emits CapabilityEntry and GemCompliance structs. S1F22 reuses S1F12's StatusName struct (same wire shape; dedup avoids redefinition). Equipment data dictionary (data/equipment.yaml) device: Adds `equipment_type: "EQUIPMENT"` for the S1F20 EQPTYP field. capabilities: New section. List of - {code, name} (CCODE, CDESC) pairs honestly reflecting what the codebase implements: 1, 2, 3, 5, 6, 7, 8, 9, 11, 12, 14 (partial), 15. dvids: New section, same schema as svids:. Demo populates two: - WaferCounter (U4, units wafer) - ChamberPressure (F4, units Torr) Loader (src/config/loader.cpp + include/secsgem/config/loader.hpp) EquipmentDescriptor gains equipment_type and capabilities (vector of (uint8_t, string) pairs). load_equipment now reads `capabilities:` into the descriptor and `dvids:` into model.dvids. Server (apps/secs_server.cpp) router.on(1, 19) returns S1F20 with desc.software_rev, desc.equipment_type, and desc.capabilities converted to vector. router.on(1, 21) returns S1F22 built from model.dvids.all(). Client (apps/secs_client.cpp) Two new demo steps after Request Online and before SVID discovery: S1F19 -> S1F20: logs SOFTREV, EQPTYP, and every (CCODE, CDESC) the equipment claims. S1F21 -> S1F22: logs each DVID with units. Tests tests/test_messages.cpp Round-trip S1F19/F20 with a 3-entry capability list; round-trip S1F22 with two DVIDs. tests/test_loader.cpp Asserts equipment_type, the capabilities list contains CCODE 14 (Spooling), and the two DVIDs land in model.dvids. COMPLIANCE.md "Documentation" Fundamental moves from ⬜ to ✅. S1F19/F20 + S1F21/F22 rows in the coverage matrix flip to ✅. The "what would it take" list drops the documentation-messages bullet. Verified - Tests: 77 cases / 444 assertions pass. - Demo: client logs the full capability list received from the equipment, including CCODE 14 "Spooling (partial; S2F43/F44 + S6F23/F24)" — the equipment honestly reports its partial implementation rather than overclaiming. Co-Authored-By: Claude Opus 4.7 --- COMPLIANCE.md | 9 ++--- apps/secs_client.cpp | 36 ++++++++++++++++++ apps/secs_server.cpp | 13 +++++++ data/equipment.yaml | 24 ++++++++++++ data/messages.yaml | 61 +++++++++++++++++++++++++++++++ include/secsgem/config/loader.hpp | 4 ++ src/config/loader.cpp | 20 ++++++++++ tests/test_loader.cpp | 14 +++++++ tests/test_messages.cpp | 41 +++++++++++++++++++++ 9 files changed, 217 insertions(+), 5 deletions(-) diff --git a/COMPLIANCE.md b/COMPLIANCE.md index ae9c49f..13c387f 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -70,7 +70,7 @@ Legend: | Event Notification | ✅ | E30 §6.6 | S6F11/F12 | Equipment-initiated, host-acknowledged. | | On-Line Identification | ✅ | E30 §6.7 | S1F1/F2 | MDLN + SOFTREV. | | Error Messages | 🟡 | E30 §6.9 | S9F* | HSMS Reject.req covers the transport-level case. S9F1/F3/F5/F7/F9/F11/F13 are now in the message catalog and round-trip-tested. `Connection` automatically emits **S9F7** when a peer's primary or reply body fails SECS-II decode (connection stays up; the caller of `send_request` sees `Error::IllegalData`), and **S9F9** when its outgoing request times out at T3 (alongside the existing `Error::Timeout`). **Not wired**: S9F3 (unknown stream) and S9F5 (unknown function) — these belong in the `Router`'s fallback path; today the fallback returns SxF0 only. | -| Documentation | ⬜ | E30 §6.10| S1F19/F20 (GEM compliance), S1F21/F22 (data variable namelist) | Not implemented. | +| Documentation | ✅ | E30 §6.10| S1F19/F20, S1F21/F22 | Equipment self-reports its GEM-compliance level via S1F20 (SOFTREV, EQPTYP, list of (CCODE, CDESC) per E30 Appendix A) and its DVID namelist via S1F22. Both populated from `data/equipment.yaml`. | | Control (Operator-Initiated) | ✅ | E30 §6.2 | — | `ControlStateMachine::operator_online/offline/local/remote`. | --- @@ -107,8 +107,8 @@ Legend: | S1F13 / S1F14 | H↔E | ✅ | `messages.hpp` | ✅ in demo | | S1F15 / S1F16 | H→E | ✅ | `messages.hpp` | ✅ in demo | | S1F17 / S1F18 | H→E | ✅ | `messages.hpp` | ✅ in demo | -| S1F19 / S1F20 | H→E | ⬜ | — | — | -| S1F21 / S1F22 | H→E | ⬜ | — | — | +| S1F19 / S1F20 | H→E | ✅ | catalog | ✅ round-trip + demo | +| S1F21 / S1F22 | H→E | ✅ | catalog | ✅ round-trip + demo | | S2F13 / S2F14 | H→E | ✅ | `messages.hpp` | ✅ in demo | | S2F15 / S2F16 | H→E | ✅ | `messages.hpp` | ✅ round-trip | | S2F17 / S2F18 | H→E | ✅ | `messages.hpp` | ✅ round-trip | @@ -196,8 +196,7 @@ The honest list, in priority order: 2. **Finish S9 wiring**: route Router-level "unknown stream/function" through S9F3/F5, and emit S9F11 (Data Too Long) with the 4-byte length prefix in place of MHEAD when the incoming frame is oversized. -3. **Implement Documentation messages** S1F19/F20 (GEM-compliance) and - S1F21/F22 (data variable namelist) — needed for E30 conformance. +3. *(done in this revision)*: S1F19/F20 + S1F21/F22 — Documentation. 4. **Implement EC range validation** in `set_equipment_constant_value` so out-of-range sets return EAC=4 instead of being silently accepted. 5. **Implement Limits Monitoring** (S2F45–F48, S6F45/F46) if the diff --git a/apps/secs_client.cpp b/apps/secs_client.cpp index fdf94ec..89cf211 100644 --- a/apps/secs_client.cpp +++ b/apps/secs_client.cpp @@ -147,6 +147,42 @@ int main(int argc, char** argv) { }); }); + // 2a. Ask the equipment to self-report its GEM compliance. + seq->steps.push_back([conn, logfn, fail](auto next) { + conn->send_request(gem::s1f19_get_gem_compliance_request(), + [logfn, fail, next](std::error_code ec, const s2::Message& reply) { + if (ec) { fail("S1F19", ec); return; } + auto parsed = gem::parse_s1f20(reply); + if (parsed) { + logfn("S1F20 SOFTREV=" + parsed->softrev + + " TYPE=" + parsed->equipment_type + + " (" + std::to_string(parsed->capabilities.size()) + + " capabilities)"); + for (const auto& c : parsed->capabilities) { + logfn(" CCODE " + std::to_string(c.ccode) + " " + c.cdesc); + } + } + next(); + }); + }); + + // 2b. Discover DVIDs. + seq->steps.push_back([conn, logfn, fail](auto next) { + conn->send_request(gem::s1f21_data_variable_namelist_request({}), + [logfn, fail, next](std::error_code ec, const s2::Message& reply) { + if (ec) { fail("S1F21", ec); return; } + auto parsed = gem::parse_s1f22(reply); + if (parsed) { + logfn("S1F22 (" + std::to_string(parsed->size()) + " DVIDs)"); + for (const auto& dn : *parsed) { + logfn(" DVID " + std::to_string(dn.id) + " " + dn.name + + (dn.units.empty() ? "" : " [" + dn.units + "]")); + } + } + next(); + }); + }); + // 3. Discover SVIDs. seq->steps.push_back([conn, logfn, fail, svids](auto next) { conn->send_request(gem::s1f11_status_namelist_request({}), diff --git a/apps/secs_server.cpp b/apps/secs_server.cpp index 0284a33..736c523 100644 --- a/apps/secs_server.cpp +++ b/apps/secs_server.cpp @@ -178,6 +178,19 @@ int main(int argc, char** argv) { logfn("S1F17 -> S1F18 ONLACK=" + std::to_string(static_cast(ack))); return gem::s1f18_online_ack(ack); }); + router.on(1, 19, [desc, logfn](const s2::Message&) { + std::vector caps; + for (const auto& c : desc.capabilities) caps.push_back({c.first, c.second}); + logfn("S1F19 -> S1F20 (" + std::to_string(caps.size()) + " capabilities)"); + return gem::s1f20_get_gem_compliance_data(desc.software_rev, desc.equipment_type, caps); + }); + router.on(1, 21, [model, logfn](const s2::Message&) { + std::vector rows; + for (const auto& dv : model->dvids.all()) + rows.push_back({dv.id, dv.name, dv.units}); + logfn("S1F21 -> S1F22 (" + std::to_string(rows.size()) + " DVIDs)"); + return gem::s1f22_data_variable_namelist_data(rows); + }); router.on(2, 13, [model, logfn](const s2::Message& msg) -> std::optional { auto ids = gem::parse_u4_list_body(msg); diff --git a/data/equipment.yaml b/data/equipment.yaml index 44f0d20..9f3ad02 100644 --- a/data/equipment.yaml +++ b/data/equipment.yaml @@ -7,6 +7,25 @@ device: id: 0 model_name: "SECSGEM-SIM" software_rev: "0.1.0" + equipment_type: "EQUIPMENT" # S1F20 EQPTYP string + +# Reported on S1F19 -> S1F20 (Get GEM Compliance). CCODE values follow +# E30 Appendix A; CDESC is implementer-defined. Keep this list honest: +# it should reflect what the equipment actually implements (see +# COMPLIANCE.md for the audit). +capabilities: + - {code: 1, name: "Establish Communications"} + - {code: 2, name: "Dynamic Event Report Configuration"} + - {code: 3, name: "Variable Data Collection"} + - {code: 5, name: "Status Data Collection"} + - {code: 6, name: "Alarm Management"} + - {code: 7, name: "Remote Control"} + - {code: 8, name: "Equipment Constants"} + - {code: 9, name: "Process Program Management"} + - {code: 11, name: "Equipment Terminal Services"} + - {code: 12, name: "Clock"} + - {code: 14, name: "Spooling (partial; S2F43/F44 + S6F23/F24)"} + - {code: 15, name: "Control (operator initiated)"} # Reported on S1F3 (values) and S1F11 (namelist). `value` is the initial. # `type` controls the SECS-II Item format. @@ -15,6 +34,11 @@ svids: - {id: 2, name: Clock, units: "", type: ASCII, value: ""} - {id: 3, name: EventsEnabled, units: "", type: BOOLEAN, value: true} +# Reported on S1F21/F22 (DVID namelist). Same shape as SVIDs. +dvids: + - {id: 100, name: WaferCounter, units: "wafer", type: U4, value: 0} + - {id: 101, name: ChamberPressure, units: "Torr", type: F4, value: 0.000001} + # Reported on S2F13 (values) / S2F29 (namelist) / S2F15 (set). ecids: - {id: 10, name: TimeFormat, units: "code", type: U4, value: 1, min: "0", max: "1"} diff --git a/data/messages.yaml b/data/messages.yaml index b577001..c9f5c24 100644 --- a/data/messages.yaml +++ b/data/messages.yaml @@ -130,6 +130,67 @@ messages: builder: s1f18_online_ack body: {kind: scalar, item_type: BINARY_BYTE, enum: OnlineAck, param: ack} + # S1F19 / S1F20 — Get GEM Compliance. Host asks the equipment which GEM + # capabilities it supports; equipment replies with its SOFTREV, an + # implementer-defined equipment-type string, and a (CCODE, CDESC) list + # per E30 Appendix A. Closes the "Documentation" Fundamental capability + # by letting the equipment self-report its own COMPLIANCE.md status. + - id: S1F19 + stream: 1 + function: 19 + w: true + builder: s1f19_get_gem_compliance_request + + - id: S1F20 + stream: 1 + function: 20 + builder: s1f20_get_gem_compliance_data + parser: parse_s1f20 + body: + kind: list + struct_name: GemCompliance + fields: + - {name: softrev, shape: {kind: scalar, item_type: ASCII}} + - {name: equipment_type, shape: {kind: scalar, item_type: ASCII}} + - name: capabilities + shape: + kind: list_of + element: + kind: list + struct_name: CapabilityEntry + fields: + - {name: ccode, shape: {kind: scalar, item_type: U1}} + - {name: cdesc, shape: {kind: scalar, item_type: ASCII}} + + # S1F21 / S1F22 — Data Variable Namelist (same wire shape as S1F11/F12 + # but for DVIDs). Reuses the StatusName struct emitted by S1F12. + - id: S1F21 + stream: 1 + function: 21 + w: true + builder: s1f21_data_variable_namelist_request + parser: parse_s1f21 + body: + kind: list_of + element: {kind: scalar, item_type: U4} + name: vids + + - id: S1F22 + stream: 1 + function: 22 + builder: s1f22_data_variable_namelist_data + parser: parse_s1f22 + body: + kind: list_of + name: items + element: + kind: list + struct_name: StatusName + fields: + - {name: id, shape: {kind: scalar, item_type: U4}} + - {name: name, shape: {kind: scalar, item_type: ASCII}} + - {name: units, shape: {kind: scalar, item_type: ASCII}} + # ===================================================================== # S2 — Equipment control + reports # ===================================================================== diff --git a/include/secsgem/config/loader.hpp b/include/secsgem/config/loader.hpp index af03bec..8f6759c 100644 --- a/include/secsgem/config/loader.hpp +++ b/include/secsgem/config/loader.hpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include "secsgem/gem/control_state.hpp" #include "secsgem/gem/data_model.hpp" @@ -30,6 +32,8 @@ struct EquipmentDescriptor { uint16_t device_id = 0; std::string model_name; std::string software_rev; + std::string equipment_type; // S1F20 EQPTYP + std::vector> capabilities; // (CCODE, CDESC) std::optional emit_on_control_change; }; diff --git a/src/config/loader.cpp b/src/config/loader.cpp index 4d61e8e..d652aa0 100644 --- a/src/config/loader.cpp +++ b/src/config/loader.cpp @@ -140,11 +140,20 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo desc.device_id = static_cast(d["id"].as()); desc.model_name = d["model_name"].as(); desc.software_rev = d["software_rev"].as(); + desc.equipment_type = + d["equipment_type"] ? d["equipment_type"].as() : "EQUIPMENT"; } if (auto e = root["emit_on_control_change"]) { if (!e.IsNull()) desc.emit_on_control_change = static_cast(e.as()); } + if (auto caps = root["capabilities"]) { + for (const auto& c : caps) { + desc.capabilities.emplace_back(static_cast(c["code"].as()), + c["name"].as()); + } + } + if (auto svids = root["svids"]) { for (const auto& sv : svids) { model.svids.add({ @@ -156,6 +165,17 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo } } + if (auto dvids = root["dvids"]) { + for (const auto& dv : dvids) { + model.dvids.add({ + static_cast(dv["id"].as()), + dv["name"].as(), + dv["units"] ? dv["units"].as() : "", + make_item(dv["type"], dv["value"], path + " dvid"), + }); + } + } + if (auto ecids = root["ecids"]) { for (const auto& ec : ecids) { const auto value = make_item(ec["type"], ec["value"], path + " ecid"); diff --git a/tests/test_loader.cpp b/tests/test_loader.cpp index f0e09d7..3e5b286 100644 --- a/tests/test_loader.cpp +++ b/tests/test_loader.cpp @@ -78,6 +78,20 @@ TEST_CASE("equipment.yaml populates SVIDs, ECIDs, CEIDs, alarms, recipes, comman CHECK(*on.force_spool == true); REQUIRE(off.force_spool.has_value()); CHECK(*off.force_spool == false); + + // Capabilities + equipment_type loaded for S1F20. + CHECK(desc.equipment_type == "EQUIPMENT"); + REQUIRE(desc.capabilities.size() >= 5); + bool saw_spooling = false; + for (const auto& [code, name] : desc.capabilities) { + if (code == 14) saw_spooling = true; + } + CHECK(saw_spooling); + + // DVIDs loaded for S1F22. + CHECK(m.dvids.all().size() == 2); + CHECK(m.dvids.get(100)->name == "WaferCounter"); + CHECK(m.dvids.get(101)->units == "Torr"); } TEST_CASE("loader surfaces YAML errors with file path") { diff --git a/tests/test_messages.cpp b/tests/test_messages.cpp index 1e8ca3d..90e3c7a 100644 --- a/tests/test_messages.cpp +++ b/tests/test_messages.cpp @@ -151,6 +151,47 @@ TEST_CASE("S9F13 conversation timeout carries MEXP + EDID") { CHECK(parsed->edid == "EQUIP-01"); } +// ---- S1F19 / S1F20 Get GEM Compliance ---------------------------------- + +TEST_CASE("S1F19 is header-only and S1F20 round-trips capability list") { + auto req = s1f19_get_gem_compliance_request(); + CHECK(req.stream == 1); + CHECK(req.function == 19); + CHECK(req.reply_expected); + CHECK_FALSE(req.body.has_value()); + + std::vector caps = { + {1, "Establish Communications"}, + {5, "Status Data Collection"}, + {14, "Spooling"}, + }; + auto m = s1f20_get_gem_compliance_data("0.1.0", "EQUIPMENT", caps); + auto parsed = parse_s1f20(m); + REQUIRE(parsed.has_value()); + CHECK(parsed->softrev == "0.1.0"); + CHECK(parsed->equipment_type == "EQUIPMENT"); + REQUIRE(parsed->capabilities.size() == 3); + CHECK(parsed->capabilities[0].ccode == 1); + CHECK(parsed->capabilities[0].cdesc == "Establish Communications"); + CHECK(parsed->capabilities[2].ccode == 14); + CHECK(parsed->capabilities[2].cdesc == "Spooling"); +} + +// ---- S1F21 / S1F22 DVID Namelist --------------------------------------- + +TEST_CASE("S1F22 round-trip preserves DVIDs (shape matches S1F12)") { + std::vector dvs = {{100, "WaferCounter", "wafer"}, + {101, "ChamberPressure", "Torr"}}; + auto m = s1f22_data_variable_namelist_data(dvs); + auto parsed = parse_s1f22(m); + REQUIRE(parsed.has_value()); + REQUIRE(parsed->size() == 2); + CHECK((*parsed)[0].id == 100); + CHECK((*parsed)[0].name == "WaferCounter"); + CHECK((*parsed)[0].units == "wafer"); + CHECK((*parsed)[1].id == 101); +} + // ---- Extended GEM message round-trips ------------------------------------ TEST_CASE("S2F33 define-report round-trip") {