interop: secsgem-py cross-validation harness + lenient identifier parsing

Adds a Docker-based interop harness that drives the C++ server with
secsgem-py 0.3.0 as the active host and probes a secsgem-py-passive
equipment from a minimal C++ active client.  Surfaces and fixes four
interoperability bugs uncovered by cross-testing:

  * SEMI E5 identifier formatcodes are a U1|U2|U4|U8 wildcard;
    secsgem-py picks the narrowest fitting width while our parsers
    only accepted U4.  `as_uN_scalar` / `as_iN_scalar` now accept
    any unsigned/signed width and range-check the downcast.
  * PPBODY (S7F3/F6) is "ASCII | Binary | List" per the spec;
    secsgem-py defaults to ASCII.  Added BINARY_OR_ASCII codegen
    item type with `as_text_or_binary` accessor.
  * S1F23/F24 Collection Event Namelist was unimplemented; added
    schema + `vids_for(ceid)` accessor on EventReportSubscriptions
    plus the dispatch handler.
  * S10F1 was registered as a host->equipment handler, but per
    SEMI E5 §12 S10F1 is equipment->host; S10F3 is the actual
    host->equipment Terminal Display Single.  Added an S10F3
    handler alongside (we keep S10F1 too for backward compat).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 23:17:18 +02:00
parent 9fbab92106
commit 2d60571a9c
13 changed files with 793 additions and 10 deletions
+107 -8
View File
@@ -2,6 +2,7 @@
#include <cstdint>
#include <functional>
#include <limits>
#include <optional>
#include <string>
#include <variant>
@@ -34,6 +35,22 @@ inline std::optional<std::string> as_binary_string(const s2::Item& item) {
return std::string(v.begin(), v.end());
}
// SEMI E5 §13.16 PPBODY (and §13 documents-with-data-format-wildcards
// generally) is encoded as "List | Binary | ASCII". We expose a single
// helper that accepts either ASCII or Binary and returns a std::string
// (Lists end up rejected — callers with list-shaped PPBODY must use the
// ITEM passthrough type).
inline std::optional<std::string> as_text_or_binary(const s2::Item& item) {
if (item.format() == s2::Format::ASCII || item.format() == s2::Format::JIS8) {
return item.as_ascii();
}
if (item.format() == s2::Format::Binary) {
const auto& v = item.as_bytes();
return std::string(v.begin(), v.end());
}
return std::nullopt;
}
inline std::optional<uint8_t> as_binary_first(const s2::Item& item) {
if (item.format() != s2::Format::Binary) return std::nullopt;
const auto& v = item.as_bytes();
@@ -58,14 +75,96 @@ inline std::optional<typename Vec::value_type> first_or_none(const s2::Item& ite
return v.front();
}
inline std::optional<uint8_t> as_u1_scalar(const s2::Item& i) { return first_or_none<std::vector<uint8_t>>(i, s2::Format::U1); }
inline std::optional<uint16_t> as_u2_scalar(const s2::Item& i) { return first_or_none<std::vector<uint16_t>>(i, s2::Format::U2); }
inline std::optional<uint32_t> as_u4_scalar(const s2::Item& i) { return first_or_none<std::vector<uint32_t>>(i, s2::Format::U4); }
inline std::optional<uint64_t> as_u8_scalar(const s2::Item& i) { return first_or_none<std::vector<uint64_t>>(i, s2::Format::U8); }
inline std::optional<int8_t> as_i1_scalar(const s2::Item& i) { return first_or_none<std::vector<int8_t>>(i, s2::Format::I1); }
inline std::optional<int16_t> as_i2_scalar(const s2::Item& i) { return first_or_none<std::vector<int16_t>>(i, s2::Format::I2); }
inline std::optional<int32_t> as_i4_scalar(const s2::Item& i) { return first_or_none<std::vector<int32_t>>(i, s2::Format::I4); }
inline std::optional<int64_t> as_i8_scalar(const s2::Item& i) { return first_or_none<std::vector<int64_t>>(i, s2::Format::I8); }
// SEMI E5 declares most identifier fields (DATAID, RPTID, CEID, VID,
// ALID, EXID, …) with FORMATCODE = "U1 | U2 | U4 | U8" — meaning a peer
// is free to encode them as any unsigned width that fits. secsgem-py,
// for example, picks the smallest type that holds the value (so an
// ALID of 1 goes out as U1). We therefore accept any unsigned width
// in the as_uN_scalar helpers, range-checking the downcast. Same logic
// for the signed I-types. Strictly typed fields (Binary, Boolean,
// ASCII, F4/F8) stay strict.
template <typename Out>
inline std::optional<Out> any_unsigned_first(const s2::Item& item) {
auto take = [](auto width) -> std::optional<Out> {
using W = decltype(width);
if constexpr (sizeof(W) == 0) return std::nullopt; // unreachable
else {
if (static_cast<uint64_t>(width) > static_cast<uint64_t>(std::numeric_limits<Out>::max()))
return std::nullopt;
return static_cast<Out>(width);
}
};
switch (item.format()) {
case s2::Format::U1: {
const auto& v = std::get<std::vector<uint8_t>>(item.storage());
if (v.empty()) return std::nullopt;
return take(v.front());
}
case s2::Format::U2: {
const auto& v = std::get<std::vector<uint16_t>>(item.storage());
if (v.empty()) return std::nullopt;
return take(v.front());
}
case s2::Format::U4: {
const auto& v = std::get<std::vector<uint32_t>>(item.storage());
if (v.empty()) return std::nullopt;
return take(v.front());
}
case s2::Format::U8: {
const auto& v = std::get<std::vector<uint64_t>>(item.storage());
if (v.empty()) return std::nullopt;
return take(v.front());
}
default: return std::nullopt;
}
}
template <typename Out>
inline std::optional<Out> any_signed_first(const s2::Item& item) {
auto take = [](auto width) -> std::optional<Out> {
using W = decltype(width);
if constexpr (sizeof(W) == 0) return std::nullopt;
else {
const int64_t w = static_cast<int64_t>(width);
if (w < static_cast<int64_t>(std::numeric_limits<Out>::min()) ||
w > static_cast<int64_t>(std::numeric_limits<Out>::max()))
return std::nullopt;
return static_cast<Out>(w);
}
};
switch (item.format()) {
case s2::Format::I1: {
const auto& v = std::get<std::vector<int8_t>>(item.storage());
if (v.empty()) return std::nullopt;
return take(v.front());
}
case s2::Format::I2: {
const auto& v = std::get<std::vector<int16_t>>(item.storage());
if (v.empty()) return std::nullopt;
return take(v.front());
}
case s2::Format::I4: {
const auto& v = std::get<std::vector<int32_t>>(item.storage());
if (v.empty()) return std::nullopt;
return take(v.front());
}
case s2::Format::I8: {
const auto& v = std::get<std::vector<int64_t>>(item.storage());
if (v.empty()) return std::nullopt;
return take(v.front());
}
default: return std::nullopt;
}
}
inline std::optional<uint8_t> as_u1_scalar(const s2::Item& i) { return any_unsigned_first<uint8_t>(i); }
inline std::optional<uint16_t> as_u2_scalar(const s2::Item& i) { return any_unsigned_first<uint16_t>(i); }
inline std::optional<uint32_t> as_u4_scalar(const s2::Item& i) { return any_unsigned_first<uint32_t>(i); }
inline std::optional<uint64_t> as_u8_scalar(const s2::Item& i) { return any_unsigned_first<uint64_t>(i); }
inline std::optional<int8_t> as_i1_scalar(const s2::Item& i) { return any_signed_first<int8_t>(i); }
inline std::optional<int16_t> as_i2_scalar(const s2::Item& i) { return any_signed_first<int16_t>(i); }
inline std::optional<int32_t> as_i4_scalar(const s2::Item& i) { return any_signed_first<int32_t>(i); }
inline std::optional<int64_t> as_i8_scalar(const s2::Item& i) { return any_signed_first<int64_t>(i); }
inline std::optional<float> as_f4_scalar(const s2::Item& i) { return first_or_none<std::vector<float>>(i, s2::Format::F4); }
inline std::optional<double> as_f8_scalar(const s2::Item& i) { return first_or_none<std::vector<double>>(i, s2::Format::F8); }