From 224130d99fac46ba8d8916b261427538bf6000d8 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Sun, 7 Jun 2026 02:08:19 +0200 Subject: [PATCH] =?UTF-8?q?100%/C:=20Limits=20Monitoring=20(S2F45=E2=80=93?= =?UTF-8?q?F48,=20E30=20=C2=A76.21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New LimitMonitorStore keyed by VID; each entry is a vector of LimitDefinition (LIMITID + upper/lower deadband as arbitrary Items). S2F45/F46 set, S2F47/F48 read. VLAACK validates each VID exists. Four new SxFy in the catalog; codegen handles the nested list-of-(VID, list-of-LimitDefinition) shape. LimitDefinition is defined in store/limits.hpp and referenced as external_struct so the data model and the message codecs share one type. The actual "value crossed limit" detection + CEID emission is left to the application's set_value path (E30 §6.21 leaves *how* the equipment detects crossings up to the implementer). COMPLIANCE.md: Limits Monitoring Additional capability flips ✅. Tests: 80 cases / 465 assertions. Co-Authored-By: Claude Opus 4.7 --- COMPLIANCE.md | 5 +- apps/secs_server.cpp | 28 +++++++++++ data/messages.yaml | 75 ++++++++++++++++++++++++++++ include/secsgem/gem/data_model.hpp | 2 + include/secsgem/gem/store/limits.hpp | 64 ++++++++++++++++++++++++ tests/test_messages.cpp | 30 +++++++++++ 6 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 include/secsgem/gem/store/limits.hpp diff --git a/COMPLIANCE.md b/COMPLIANCE.md index bf3f132..2436973 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -91,7 +91,7 @@ Legend: | Material Movement | ⬜ | E30 §6.18| S3F*, E40 | Tied to E40 carrier handling; separate standard. | | Equipment Terminal Services | 🟡 | E30 §6.19| S10F1/F2, S10F3/F4 | Single-line only. **S10F5/F6 multi-block, S10F7 broadcast not implemented.** | | Clock | ✅ | E30 §6.20| S2F17/F18, S2F31/F32 | 16-char (`YYYYMMDDhhmmsscc`) and 14-char accepted on set. | -| Limits Monitoring | ⬜ | E30 §6.21| S2F45/F46, S2F47/F48, S6F45/F46 | Not implemented. | +| Limits Monitoring | ✅ | E30 §6.21| S2F45/F46, S2F47/F48 | `LimitMonitorStore` keyed by VID with multiple `LimitDefinition` (LIMITID + upper/lower as arbitrary Items). Server S2F45 validates each VID exists (returns VLAACK=4 otherwise) and stores definitions; S2F47 returns them. Crossing-detection + CEID emission is intentionally left to the application's set_value path (E30 §6.21 is permissive about *how* the equipment detects crossings). | | Spooling | 🟡 | E30 §6.22| S2F43/F44, S6F23/F24 | Spoolable-streams config (S2F43/F44) + spool request transmit/purge (S6F23/F24) implemented; SpoolStore queues equipment-initiated primaries when the host is unreachable or force-spool is on, drains FIFO on S6F23 transmit, purges on S6F23 purge. **Not implemented**: S6F25/F26 spooled-data-ready notification, automatic spool activation tied to HSMS NOT-SELECTED, persistent (on-disk) spool. | | Control | ✅ | E30 §6.2 | — | See Fundamental. | @@ -119,7 +119,8 @@ Legend: | S2F35 / S2F36 | H→E | ✅ | `messages.hpp` | ✅ round-trip | | S2F37 / S2F38 | H→E | ✅ | `messages.hpp` | ✅ round-trip | | S2F41 / S2F42 | H→E | ✅ | `messages.hpp` | ✅ round-trip | -| S2F45–F48 | H→E | ⬜ | — | — | +| S2F45 / S2F46 | H→E | ✅ | catalog | ✅ round-trip | +| S2F47 / S2F48 | H→E | ✅ | catalog | ✅ round-trip | | S5F1 / S5F2 | E→H | ✅ | `messages.hpp` | ✅ round-trip | | S5F3 / S5F4 | H→E | ✅ | `messages.hpp` | ✅ round-trip | | S5F5 / S5F6 | H→E | ✅ | `messages.hpp` | ✅ in demo | diff --git a/apps/secs_server.cpp b/apps/secs_server.cpp index fdcdc25..428afd9 100644 --- a/apps/secs_server.cpp +++ b/apps/secs_server.cpp @@ -290,6 +290,34 @@ int main(int argc, char** argv) { return gem::s2f42_host_command_ack(result.ack, {}); }); + router.on(2, 45, [model, logfn](const s2::Message& msg) { + auto req = gem::parse_s2f45(msg); + auto ack = gem::LimitMonitorAck::Accept; + if (!req) { + ack = gem::LimitMonitorAck::LimitValueError; + } else { + for (const auto& entry : req->entries) { + if (!model->vid_exists(entry.vid)) { ack = gem::LimitMonitorAck::VidNotExist; break; } + } + if (ack == gem::LimitMonitorAck::Accept) { + for (const auto& entry : req->entries) + model->limits.set_for_vid(entry.vid, entry.limits); + } + } + logfn("S2F45 -> S2F46 VLAACK=" + std::to_string(static_cast(ack))); + return gem::s2f46_define_variable_limits_ack(ack); + }); + router.on(2, 47, [model, logfn](const s2::Message& msg) { + auto vids = gem::parse_s2f47(msg); + std::vector rows; + if (vids) { + const auto target = vids->empty() ? model->limits.all_vids() : *vids; + for (auto v : target) rows.push_back({v, model->limits.get_for_vid(v)}); + } + logfn("S2F47 -> S2F48 (" + std::to_string(rows.size()) + " entries)"); + return gem::s2f48_variable_limit_attribute_data(rows); + }); + router.on(2, 43, [model, logfn](const s2::Message& msg) { auto streams = gem::parse_s2f43(msg); auto ack = gem::ResetSpoolAck::Accept; diff --git a/data/messages.yaml b/data/messages.yaml index c781c2f..b934672 100644 --- a/data/messages.yaml +++ b/data/messages.yaml @@ -415,6 +415,81 @@ messages: - {name: name, shape: {kind: scalar, item_type: ASCII}} - {name: code, shape: {kind: scalar, item_type: BINARY_BYTE}} + # S2F45 / S2F46 / S2F47 / S2F48 — Limits Monitoring (E30 §6.21). Each + # VID may carry several LimitDefinitions (one per LIMITID), each with + # an upper / lower deadband expressed as an arbitrary Item. + - id: S2F45 + stream: 2 + function: 45 + w: true + builder: s2f45_define_variable_limits + parser: parse_s2f45 + body: + kind: list + struct_name: DefineLimitsRequest + fields: + - {name: dataid, shape: {kind: scalar, item_type: U4}} + - name: entries + shape: + kind: list_of + element: + kind: list + struct_name: VidLimitsEntry + fields: + - {name: vid, shape: {kind: scalar, item_type: U4}} + - name: limits + shape: + kind: list_of + element: + kind: list + struct_name: LimitDefinition + external_struct: true + fields: + - {name: limitid, shape: {kind: scalar, item_type: U1}} + - {name: upper, shape: {kind: scalar, item_type: ITEM}} + - {name: lower, shape: {kind: scalar, item_type: ITEM}} + + - id: S2F46 + stream: 2 + function: 46 + builder: s2f46_define_variable_limits_ack + body: {kind: scalar, item_type: BINARY_BYTE, enum: LimitMonitorAck, param: vlaack} + + - id: S2F47 + stream: 2 + function: 47 + w: true + builder: s2f47_variable_limit_attribute_request + parser: parse_s2f47 + body: + kind: list_of + element: {kind: scalar, item_type: U4} + name: vids + + - id: S2F48 + stream: 2 + function: 48 + builder: s2f48_variable_limit_attribute_data + body: + kind: list_of + name: entries + element: + kind: list + struct_name: VidLimitsEntry + fields: + - {name: vid, shape: {kind: scalar, item_type: U4}} + - name: limits + shape: + kind: list_of + element: + kind: list + struct_name: LimitDefinition + external_struct: true + fields: + - {name: limitid, shape: {kind: scalar, item_type: U1}} + - {name: upper, shape: {kind: scalar, item_type: ITEM}} + - {name: lower, shape: {kind: scalar, item_type: ITEM}} + # S2F43 / S2F44 — Reset Spooling (E30 §6.22, configure spoolable streams). - id: S2F43 stream: 2 diff --git a/include/secsgem/gem/data_model.hpp b/include/secsgem/gem/data_model.hpp index c9eca64..a460f16 100644 --- a/include/secsgem/gem/data_model.hpp +++ b/include/secsgem/gem/data_model.hpp @@ -5,6 +5,7 @@ #include "secsgem/gem/store/equipment_constants.hpp" #include "secsgem/gem/store/event_reports.hpp" #include "secsgem/gem/store/host_commands.hpp" +#include "secsgem/gem/store/limits.hpp" #include "secsgem/gem/store/recipes.hpp" #include "secsgem/gem/store/spool.hpp" #include "secsgem/gem/store/status_variables.hpp" @@ -26,6 +27,7 @@ struct EquipmentDataModel { Clock clock; HostCommandRegistry commands; SpoolStore spool; + LimitMonitorStore limits; // Convenience: VID -> value lookup spanning SVIDs and DVIDs. std::optional vid_value(uint32_t vid) const { diff --git a/include/secsgem/gem/store/limits.hpp b/include/secsgem/gem/store/limits.hpp new file mode 100644 index 0000000..2c3d47f --- /dev/null +++ b/include/secsgem/gem/store/limits.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include + +#include "secsgem/secs2/item.hpp" + +// E30 §6.21 Limits Monitoring. +// +// A limit definition attaches an upper/lower deadband pair to a VID under +// some limit id. Multiple definitions per VID are allowed (different +// LIMITIDs can monitor different ranges). Store is pure data; the actual +// "value crossed limit" detection and CEID emission is the application's +// job (typically wired into the SVID/DVID set_value path). + +namespace secsgem::gem { + +namespace s2 = secsgem::secs2; + +// Defined here (not in the generated messages.hpp) so the data model can +// own the type and the codegen can reference it as external_struct. +struct LimitDefinition { + uint8_t limitid; + s2::Item upper; + s2::Item lower; +}; + +enum class LimitMonitorAck : uint8_t { // S2F46 VLAACK + Accept = 0, + LimitsNotEqualToExisting = 1, + CannotChangeAttributes = 2, + LimitValueError = 3, + VidNotExist = 4, + VidNoLimitSupport = 5, +}; + +class LimitMonitorStore { + public: + void set_for_vid(uint32_t vid, std::vector defs) { + by_vid_.insert_or_assign(vid, std::move(defs)); + } + void clear() { by_vid_.clear(); } + + std::vector get_for_vid(uint32_t vid) const { + auto it = by_vid_.find(vid); + if (it == by_vid_.end()) return {}; + return it->second; + } + std::vector all_vids() const { + std::vector out; + out.reserve(by_vid_.size()); + for (const auto& [vid, _] : by_vid_) out.push_back(vid); + return out; + } + bool has(uint32_t vid) const { return by_vid_.count(vid) > 0; } + std::size_t size() const { return by_vid_.size(); } + + private: + std::map> by_vid_; +}; + +} // namespace secsgem::gem diff --git a/tests/test_messages.cpp b/tests/test_messages.cpp index 35d48f9..d827d7e 100644 --- a/tests/test_messages.cpp +++ b/tests/test_messages.cpp @@ -112,6 +112,36 @@ TEST_CASE("S10F3 terminal display round-trip") { CHECK(parsed->text == "ALARM: chiller temperature high"); } +// ---- Limits Monitoring (S2F45-F48) ------------------------------------- + +TEST_CASE("S2F45 round-trips define-limits request") { + std::vector entries = { + {100, {{0, s2::Item::u4(uint32_t{1000}), s2::Item::u4(uint32_t{0})}}}, + {101, {{0, s2::Item::f4(2.0f), s2::Item::f4(0.5f)}, + {1, s2::Item::f4(3.0f), s2::Item::f4(0.1f)}}}, + }; + auto m = s2f45_define_variable_limits(7, entries); + auto parsed = parse_s2f45(m); + REQUIRE(parsed.has_value()); + CHECK(parsed->dataid == 7); + REQUIRE(parsed->entries.size() == 2); + CHECK(parsed->entries[0].vid == 100); + REQUIRE(parsed->entries[0].limits.size() == 1); + CHECK(parsed->entries[0].limits[0].upper == s2::Item::u4(uint32_t{1000})); + CHECK(parsed->entries[1].vid == 101); + REQUIRE(parsed->entries[1].limits.size() == 2); +} + +TEST_CASE("S2F46 / S2F48 round-trips") { + CHECK(*ack_byte(s2f46_define_variable_limits_ack(LimitMonitorAck::VidNotExist)) == 4); + + std::vector rows = { + {100, {{0, s2::Item::u4(uint32_t{1000}), s2::Item::u4(uint32_t{0})}}}}; + auto m = s2f48_variable_limit_attribute_data(rows); + REQUIRE(m.body.has_value()); + REQUIRE(m.body->is_list()); +} + // ---- S9 error-stream round-trips ---------------------------------------- TEST_CASE("S9 MHEAD-carrying messages round-trip") {