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:
@@ -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); }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user