Close COMPLIANCE.md gap: Documentation (S1F19-F22)
tests / build-and-test (push) Failing after 32s

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     <L,3 <A SOFTREV>
                 <A EQPTYP>
                 <L,a <L,2 <U1 CCODE> <A CDESC>>>>
                                       Get GEM Compliance Data
  S1F21 W   <L,n <U4 VID>>             DVID Namelist Request (n=0 = all)
  S1F22     <L,n <L,3 <U4 VID>
                       <A VNAME>
                       <A UNITS>>>     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<CapabilityEntry>.
  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 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 00:30:43 +02:00
parent 547fd2116b
commit 813e011409
9 changed files with 217 additions and 5 deletions
+4 -5
View File
@@ -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** (S2F45F48, S6F45/F46) if the
+36
View File
@@ -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({}),
+13
View File
@@ -178,6 +178,19 @@ int main(int argc, char** argv) {
logfn("S1F17 -> S1F18 ONLACK=" + std::to_string(static_cast<int>(ack)));
return gem::s1f18_online_ack(ack);
});
router.on(1, 19, [desc, logfn](const s2::Message&) {
std::vector<gem::CapabilityEntry> 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<gem::StatusName> 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<s2::Message> {
auto ids = gem::parse_u4_list_body(msg);
+24
View File
@@ -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"}
+61
View File
@@ -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
# =====================================================================
+4
View File
@@ -4,6 +4,8 @@
#include <optional>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
#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<std::pair<uint8_t, std::string>> capabilities; // (CCODE, CDESC)
std::optional<uint32_t> emit_on_control_change;
};
+20
View File
@@ -140,11 +140,20 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
desc.device_id = static_cast<uint16_t>(d["id"].as<int>());
desc.model_name = d["model_name"].as<std::string>();
desc.software_rev = d["software_rev"].as<std::string>();
desc.equipment_type =
d["equipment_type"] ? d["equipment_type"].as<std::string>() : "EQUIPMENT";
}
if (auto e = root["emit_on_control_change"]) {
if (!e.IsNull()) desc.emit_on_control_change = static_cast<uint32_t>(e.as<int>());
}
if (auto caps = root["capabilities"]) {
for (const auto& c : caps) {
desc.capabilities.emplace_back(static_cast<uint8_t>(c["code"].as<int>()),
c["name"].as<std::string>());
}
}
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<uint32_t>(dv["id"].as<int>()),
dv["name"].as<std::string>(),
dv["units"] ? dv["units"].as<std::string>() : "",
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");
+14
View File
@@ -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") {
+41
View File
@@ -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<CapabilityEntry> 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<StatusName> 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") {