From 0721db954241d0b6dd60bc2480e1eae2fb2579b6 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Fri, 5 Jun 2026 22:06:55 +0200 Subject: [PATCH] =?UTF-8?q?Close=20COMPLIANCE.md=20gap:=20spooling=20(E30?= =?UTF-8?q?=20=C2=A76.22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the largest functional gap from the compliance audit. The equipment now queues events the host can't immediately receive (either because there's no SELECTED session or because the demo's force-spool flag is on) and transmits the queue on host request. What's new include/secsgem/gem/store/spool.hpp SpoolStore: a deque queue with a configurable per-stream whitelist (so only streams 5+6 spool by default), a max_size cap with FIFO eviction on overflow, and a `force_spool` test flag. Enqueue returns one of Queued / Dropped_NotSpoolable / Dropped_Full so the caller can fall back to live delivery when appropriate. Drain pops the entire queue in FIFO order. Two new ack enums: ResetSpoolAck (S2F44 RSPACK) and SpoolRequestAck (S6F24 RSDA), plus SpoolRequestCode (S6F23 RSDC, Transmit/Purge). data/messages.yaml + auto-regenerated messages.hpp S2F43 W > Reset Spooling S2F44 > Reset Spooling Ack S6F23 W Request Spooled Data S6F24 Request Spooled Data Ack data/equipment.yaml `spool:` section: max_size + spoolable_streams list. Two new host commands SPOOL_ON / SPOOL_OFF that flip the force-spool flag (these stand in for "host link down" in the demo without dropping TCP). include/secsgem/gem/store/host_commands.hpp Spec/Result gain an optional force_spool field. S2F41 dispatch returns the result, the server applies it after S2F42 is queued. src/config/loader.cpp Reads `spool:` from equipment.yaml; reads `force_spool` from each host_commands entry; populates SpoolStore + CommandSpec. apps/secs_server.cpp New `deliver_or_spool(msg, what)` helper. emit_event and emit_alarm_set funnel through it: if force_spool is on (or there's no active session), msg.stream is checked against the spoolable list and the message is enqueued; otherwise it's sent live. Two new handlers: S2F43 parses the stream list, updates SpoolStore, replies S2F44 S6F23 RSDC=Transmit drains and re-sends each as a fresh primary (posted on the executor so the S6F24 ack flushes first); RSDC=Purge clears the queue and acks. The S2F41 handler now also propagates result.force_spool into the SpoolStore. apps/secs_client.cpp Demo extended with 4 new steps after the FAULT branch: SPOOL_ON -> S2F42 Accept START -> S2F42 Accept; CEID 300 emission spooled (no live S6F11) SPOOL_OFF -> S2F42 Accept; queue still has the message S6F23(Transmit) -> S6F24 Accept; spooled S6F11 arrives next Then the existing S7F19/S7F5/S10F1/S1F15/Separate flow continues. tests/test_data_model.cpp Four new TEST_CASEs for SpoolStore (whitelist, FIFO eviction at max_size, drain ordering, force flag). tests/test_loader.cpp Confirms equipment.yaml's `spool:` section populates the store and `force_spool: true/false` flows through to dispatch results. COMPLIANCE.md Spooling moves from ⬜ to 🟡. Adds S2F43/F44 + S6F23/F24 as ✅ in the message coverage matrix; calls out what's still missing (S6F25/F26 notification, automatic activation on HSMS NOT-SELECTED, persistent on-disk spool). Verified - Tests: 73 cases / 383 assertions pass (+4 spool cases). - Demo (docker compose up server client) walks the full happy path and the spool path, observed in the server log as: spool: force_spool=true (depth=0) spool: S6F11 CEID=300 queued (depth=1) spool: force_spool=false (depth=1) S6F23 transmit: draining 1 messages and on the host side as the queued S6F11 arriving in the correct order after S6F24. Known limitations (logged in COMPLIANCE.md) - Spool activation is manual via SPOOL_ON/OFF rather than automatically triggered by HSMS NOT-SELECTED. - No S6F25/F26 spooled-data-ready notification on re-SELECT. - In-memory only; an equipment restart loses queued events. Co-Authored-By: Claude Opus 4.7 --- COMPLIANCE.md | 13 ++- apps/secs_client.cpp | 55 +++++++++- apps/secs_server.cpp | 85 ++++++++++++--- data/equipment.yaml | 23 +++- data/messages.yaml | 47 +++++++++ include/secsgem/gem/data_model.hpp | 2 + include/secsgem/gem/store/host_commands.hpp | 6 +- include/secsgem/gem/store/spool.hpp | 111 ++++++++++++++++++++ src/config/loader.cpp | 11 ++ tests/test_data_model.cpp | 49 ++++++++- tests/test_loader.cpp | 14 +++ 11 files changed, 391 insertions(+), 25 deletions(-) create mode 100644 include/secsgem/gem/store/spool.hpp diff --git a/COMPLIANCE.md b/COMPLIANCE.md index 401b11c..80009af 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -92,7 +92,7 @@ Legend: | 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. | -| Spooling | ⬜ | E30 §6.22| S6F23/F24, S6F25/F26, spool state model | Not implemented. The plan calls this out as a Layer-3+ subsystem. | +| 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. | --- @@ -129,7 +129,9 @@ Legend: | S6F7 / S6F8 | H↔E | ⬜ | — | multi-block | | S6F11 / S6F12 | E→H | ✅ | `messages.hpp` | ✅ round-trip + demo | | S6F15 / S6F16 | H→E | ⬜ | — | event report request | -| S6F23 / S6F26 | spool | ⬜ | — | spooling | +| S2F43 / S2F44 | H→E | ✅ | catalog | ✅ round-trip + demo | +| S6F23 / S6F24 | H→E | ✅ | catalog | ✅ round-trip + demo | +| S6F25 / S6F26 | spool | ⬜ | — | spool-data-ready notification | | S7F3 / S7F4 | H→E | ✅ | `messages.hpp` | ✅ round-trip | | S7F5 / S7F6 | H→E | ✅ | `messages.hpp` | ✅ in demo | | S7F19 / S7F20 | H→E | ✅ | `messages.hpp` | ✅ round-trip + demo | @@ -180,8 +182,11 @@ Unit tests: **63 cases / 278 assertions pass** (`docker compose run --rm tests`) The honest list, in priority order: -1. **Implement spooling** (E30 §6.22, S6F23/F24/F25/F26 + spool state - model). This is the single largest missing GEM feature. +1. **Finish spooling**: S6F25/F26 spooled-data-ready notification, plus + automatic activation when HSMS goes NOT-SELECTED (and automatic + notification on re-SELECT) so the host doesn't have to manually flip + the test-only `force_spool` flag. Optional: persistent on-disk spool + so equipment restarts don't lose queued events. 2. **Implement the S9 error stream** (S9F1, F3, F5, F7, F9, F11, F13) for the documented transport/protocol error cases. 3. **Implement Documentation messages** S1F19/F20 (GEM-compliance) and diff --git a/apps/secs_client.cpp b/apps/secs_client.cpp index 46ba579..fdf94ec 100644 --- a/apps/secs_client.cpp +++ b/apps/secs_client.cpp @@ -269,7 +269,60 @@ int main(int argc, char** argv) { }); }); - // 13. List recipes. + // 13a. Force the equipment into spool mode (test-only RCMD; stands in + // for "the host link has gone down" in the real protocol). + seq->steps.push_back([conn, logfn, fail](auto next) { + conn->send_request(gem::s2f41_host_command("SPOOL_ON", {}), + [logfn, fail, next](std::error_code ec, const s2::Message& reply) { + if (ec) { fail("S2F41/SPOOL_ON", ec); return; } + auto r = gem::parse_s2f42(reply); + logfn("S2F42 HCACK=" + + (r ? std::to_string(static_cast(r->hcack)) : "?")); + next(); + }); + }); + + // 13b. Trigger a START while spooling — the CEID 300 emission should + // land in the equipment's spool, not arrive live. + seq->steps.push_back([conn, logfn, fail, pause_then](auto next) { + conn->send_request(gem::s2f41_host_command("START", {}), + [logfn, fail, pause_then, next](std::error_code ec, + const s2::Message& reply) { + if (ec) { fail("S2F41/START(spool)", ec); return; } + auto r = gem::parse_s2f42(reply); + logfn("S2F42 HCACK=" + + (r ? std::to_string(static_cast(r->hcack)) : "?") + + " (CEID 300 should be spooled, not delivered live)"); + pause_then(std::chrono::milliseconds(150), next); + }); + }); + + // 13c. Drop the spool flag back to off. + seq->steps.push_back([conn, logfn, fail](auto next) { + conn->send_request(gem::s2f41_host_command("SPOOL_OFF", {}), + [logfn, fail, next](std::error_code ec, const s2::Message& reply) { + if (ec) { fail("S2F41/SPOOL_OFF", ec); return; } + auto r = gem::parse_s2f42(reply); + logfn("S2F42 HCACK=" + + (r ? std::to_string(static_cast(r->hcack)) : "?")); + next(); + }); + }); + + // 13d. Ask the equipment to transmit the spooled queue. + seq->steps.push_back([conn, logfn, fail, pause_then](auto next) { + conn->send_request(gem::s6f23_request_spool_data(gem::SpoolRequestCode::Transmit), + [logfn, fail, pause_then, next](std::error_code ec, + const s2::Message& reply) { + if (ec) { fail("S6F23", ec); return; } + auto a = gem::ack_byte(reply); + logfn("S6F24 RSDA=" + (a ? std::to_string(*a) : "?") + + " (expect spooled S6F11 to arrive next)"); + pause_then(std::chrono::milliseconds(300), next); + }); + }); + + // 14. List recipes. seq->steps.push_back([conn, logfn, fail](auto next) { conn->send_request(gem::s7f19_current_eppd_request(), [logfn, fail, next](std::error_code ec, const s2::Message& reply) { diff --git a/apps/secs_server.cpp b/apps/secs_server.cpp index 8f6c4c6..0284a33 100644 --- a/apps/secs_server.cpp +++ b/apps/secs_server.cpp @@ -73,10 +73,30 @@ int main(int argc, char** argv) { auto active_conn = std::make_shared>(); - auto emit_event = [&io, active_conn, model, logfn](uint32_t ceid) { - asio::post(io, [active_conn, model, logfn, ceid]() { - auto conn = active_conn->lock(); - if (!conn) return; + // Deliver a primary message to the host, or spool it if we can't (either + // there is no SELECTED session or the spool's force-flag is on). Returns + // true if the message was delivered or queued; false if dropped because + // the stream isn't spoolable and there is no live session. + auto deliver_or_spool = [active_conn, model, logfn](s2::Message msg, + const std::string& what) -> bool { + auto conn = active_conn->lock(); + const bool spooling = model->spool.force_spool() || !conn; + if (spooling) { + auto r = model->spool.enqueue(msg); + if (r == gem::SpoolStore::EnqueueResult::Queued) { + logfn("spool: " + what + " queued (depth=" + + std::to_string(model->spool.size()) + ")"); + return true; + } + logfn("spool: " + what + " dropped (stream not spoolable, no host)"); + return false; + } + conn->send_request(std::move(msg), [](std::error_code, const s2::Message&) {}); + return true; + }; + + auto emit_event = [&io, model, logfn, deliver_or_spool](uint32_t ceid) { + asio::post(io, [model, logfn, deliver_or_spool, ceid]() { if (!model->is_event_enabled(ceid)) { logfn("CEID " + std::to_string(ceid) + " not enabled; suppressed"); return; @@ -84,23 +104,21 @@ int main(int argc, char** argv) { auto reports = model->compose_reports_for(ceid); logfn("emit S6F11 CEID=" + std::to_string(ceid) + " (" + std::to_string(reports.size()) + " reports)"); - conn->send_request(gem::s6f11_event_report(0, ceid, reports), - [](std::error_code, const s2::Message&) {}); + deliver_or_spool(gem::s6f11_event_report(0, ceid, reports), + "S6F11 CEID=" + std::to_string(ceid)); }); }; - auto emit_alarm_set = [&io, active_conn, model, logfn, emit_event](uint32_t alid) { - asio::post(io, [active_conn, model, logfn, emit_event, alid]() { - auto conn = active_conn->lock(); - if (!conn) return; + auto emit_alarm_set = [&io, model, logfn, emit_event, deliver_or_spool](uint32_t alid) { + asio::post(io, [model, logfn, emit_event, deliver_or_spool, alid]() { auto alarm = model->alarms.get(alid); if (!alarm) return; auto alcd = model->alarms.set_active(alid); if (!alcd) return; if (model->alarms.enabled(alid)) { logfn("emit S5F1 alarm set ALID=" + std::to_string(alid)); - conn->send_request(gem::s5f1_alarm_report(*alcd, alid, alarm->text), - [](std::error_code, const s2::Message&) {}); + deliver_or_spool(gem::s5f1_alarm_report(*alcd, alid, alarm->text), + "S5F1 ALID=" + std::to_string(alid)); } else { logfn("alarm " + std::to_string(alid) + " not enabled; suppressed"); } @@ -250,10 +268,53 @@ int main(int argc, char** argv) { if (result.ack == gem::HostCmdAck::Accept) { if (result.emit_ceid) emit_event(*result.emit_ceid); if (result.set_alarm) emit_alarm_set(*result.set_alarm); + if (result.force_spool) { + model->spool.set_force_spool(*result.force_spool); + logfn(std::string("spool: force_spool=") + (*result.force_spool ? "true" : "false") + + " (depth=" + std::to_string(model->spool.size()) + ")"); + } } return gem::s2f42_host_command_ack(result.ack, {}); }); + router.on(2, 43, [model, logfn](const s2::Message& msg) { + auto streams = gem::parse_s2f43(msg); + auto ack = gem::ResetSpoolAck::Accept; + std::vector per; + if (!streams) { + ack = gem::ResetSpoolAck::Denied_NotAllowed; + } else { + model->spool.set_spoolable_streams(*streams); + logfn("S2F43 spoolable=" + std::to_string(streams->size()) + " streams"); + } + return gem::s2f44_reset_spooling_ack(ack, per); + }); + + router.on(6, 23, [&io, active_conn, model, logfn](const s2::Message& msg) { + auto rsdc = gem::parse_s6f23(msg); + if (!rsdc) return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Denied); + if (*rsdc == gem::SpoolRequestCode::Purge) { + const auto n = model->spool.size(); + model->spool.clear(); + logfn("S6F23 purge: dropped " + std::to_string(n) + " messages"); + return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept); + } + // Transmit: drain the queue, fire each as a fresh primary. Defer to + // the executor so the S6F24 ack flushes before the drained primaries + // go out — the host should see ACK first, then the spooled traffic. + auto drained = model->spool.drain(); + logfn("S6F23 transmit: draining " + std::to_string(drained.size()) + + " messages"); + asio::post(io, [active_conn, drained = std::move(drained), logfn]() mutable { + auto conn = active_conn->lock(); + if (!conn) return; + for (auto& m : drained) { + conn->send_request(std::move(m), [](std::error_code, const s2::Message&) {}); + } + }); + return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept); + }); + router.on(5, 3, [model, logfn](const s2::Message& msg) { auto req = gem::parse_s5f3(msg); auto ack = req ? model->alarms.set_enabled(req->alid, (req->aled & 0x80) != 0) diff --git a/data/equipment.yaml b/data/equipment.yaml index 073a6e5..44f0d20 100644 --- a/data/equipment.yaml +++ b/data/equipment.yaml @@ -39,12 +39,25 @@ recipes: # Dispatched by S2F41. `ack` is the HCACK enum value. Optional `emit_ceid` # fires a CEID after dispatch (e.g. ProcessStarted on START), and optional -# `set_alarm` activates an alarm (e.g. FAULT -> alarm 1). +# `set_alarm` activates an alarm (e.g. FAULT -> alarm 1). `force_spool` +# (true/false) flips the spool store's test-force flag — for the demo this +# stands in for "the host link has gone down" without actually dropping +# TCP. host_commands: - - {name: START, ack: Accept, emit_ceid: 300} - - {name: STOP, ack: Accept} - - {name: PAUSE, ack: CannotDoNow} - - {name: FAULT, ack: Accept, set_alarm: 1} + - {name: START, ack: Accept, emit_ceid: 300} + - {name: STOP, ack: Accept} + - {name: PAUSE, ack: CannotDoNow} + - {name: FAULT, ack: Accept, set_alarm: 1} + - {name: SPOOL_ON, ack: Accept, force_spool: true} + - {name: SPOOL_OFF, ack: Accept, force_spool: false} + +# Spool (E30 §6.22). Streams listed here may be queued by the equipment +# when the host is unreachable; queued messages drain when the host sends +# S6F23 with RSDC=0. max_size caps the in-memory queue (FIFO eviction on +# overflow). +spool: + max_size: 100 + spoolable_streams: [5, 6] # CEID emitted automatically whenever the control state machine transitions # (i.e. on every change-handler call). Set to null to disable. diff --git a/data/messages.yaml b/data/messages.yaml index 71bfb82..af3e968 100644 --- a/data/messages.yaml +++ b/data/messages.yaml @@ -354,6 +354,38 @@ messages: - {name: name, shape: {kind: scalar, item_type: ASCII}} - {name: code, shape: {kind: scalar, item_type: BINARY_BYTE}} + # S2F43 / S2F44 — Reset Spooling (E30 §6.22, configure spoolable streams). + - id: S2F43 + stream: 2 + function: 43 + w: true + builder: s2f43_reset_spooling + parser: parse_s2f43 + body: + kind: list_of + element: {kind: scalar, item_type: BINARY_BYTE} + name: streams + + - id: S2F44 + stream: 2 + function: 44 + builder: s2f44_reset_spooling_ack + parser: parse_s2f44 + body: + kind: list + struct_name: ResetSpoolingReply + fields: + - {name: rspack, shape: {kind: scalar, item_type: BINARY_BYTE, enum: ResetSpoolAck}} + - name: per_stream + shape: + kind: list_of + element: + kind: list + struct_name: ResetSpoolStreamAck + fields: + - {name: stream, shape: {kind: scalar, item_type: BINARY_BYTE}} + - {name: strack, shape: {kind: scalar, item_type: BINARY_BYTE}} + # ===================================================================== # S5 — Alarms # ===================================================================== @@ -444,6 +476,21 @@ messages: builder: s6f12_event_report_ack body: {kind: scalar, item_type: BINARY_BYTE, enum: EventReportAck, param: ack} + # S6F23 / S6F24 — Request Spooled Data (E30 §6.22; host transmits or purges). + - id: S6F23 + stream: 6 + function: 23 + w: true + builder: s6f23_request_spool_data + parser: parse_s6f23 + body: {kind: scalar, item_type: BINARY_BYTE, enum: SpoolRequestCode, param: rsdc} + + - id: S6F24 + stream: 6 + function: 24 + builder: s6f24_request_spool_data_ack + body: {kind: scalar, item_type: BINARY_BYTE, enum: SpoolRequestAck, param: rsda} + # ===================================================================== # S7 — Process programs # ===================================================================== diff --git a/include/secsgem/gem/data_model.hpp b/include/secsgem/gem/data_model.hpp index 7c741ed..c9eca64 100644 --- a/include/secsgem/gem/data_model.hpp +++ b/include/secsgem/gem/data_model.hpp @@ -6,6 +6,7 @@ #include "secsgem/gem/store/event_reports.hpp" #include "secsgem/gem/store/host_commands.hpp" #include "secsgem/gem/store/recipes.hpp" +#include "secsgem/gem/store/spool.hpp" #include "secsgem/gem/store/status_variables.hpp" namespace secsgem::gem { @@ -24,6 +25,7 @@ struct EquipmentDataModel { RecipeStore recipes; Clock clock; HostCommandRegistry commands; + SpoolStore spool; // Convenience: VID -> value lookup spanning SVIDs and DVIDs. std::optional vid_value(uint32_t vid) const { diff --git a/include/secsgem/gem/store/host_commands.hpp b/include/secsgem/gem/store/host_commands.hpp index 94073e8..e2bf10b 100644 --- a/include/secsgem/gem/store/host_commands.hpp +++ b/include/secsgem/gem/store/host_commands.hpp @@ -38,12 +38,14 @@ class HostCommandRegistry { HostCmdAck ack = HostCmdAck::Accept; std::optional emit_ceid; std::optional set_alarm; + std::optional force_spool; // toggles the SpoolStore's flag }; struct Result { HostCmdAck ack = HostCmdAck::InvalidCommand; std::optional emit_ceid; std::optional set_alarm; + std::optional force_spool; }; void register_command(std::string rcmd, Spec spec) { @@ -53,8 +55,8 @@ class HostCommandRegistry { Result dispatch(const std::string& rcmd, const std::vector& /*params*/) const { auto it = by_rcmd_.find(rcmd); - if (it == by_rcmd_.end()) return {HostCmdAck::InvalidCommand, std::nullopt, std::nullopt}; - return {it->second.ack, it->second.emit_ceid, it->second.set_alarm}; + if (it == by_rcmd_.end()) return {HostCmdAck::InvalidCommand, std::nullopt, std::nullopt, std::nullopt}; + return {it->second.ack, it->second.emit_ceid, it->second.set_alarm, it->second.force_spool}; } private: diff --git a/include/secsgem/gem/store/spool.hpp b/include/secsgem/gem/store/spool.hpp new file mode 100644 index 0000000..f2d9fdb --- /dev/null +++ b/include/secsgem/gem/store/spool.hpp @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "secsgem/secs2/message.hpp" + +// E30 §6.22 Spooling. +// +// When the equipment can't deliver a primary message to the host (because +// the link is down, the host hasn't selected, or — for the demo — a +// test-force flag is set), it queues the message in the spool instead of +// dropping it. When the host later sends S6F23 with RSDC=0, the equipment +// transmits everything in the queue. RSDC=1 purges. +// +// The set of streams the equipment is allowed to spool is configured via +// S2F43 (Reset Spooling). This implementation supports the simple form +// (per-stream enable list, no per-message-type granularity) which covers +// the common cases (stream 5 alarms + stream 6 event reports). + +namespace secsgem::gem { + +namespace s2 = secsgem::secs2; + +// S2F44 RSPACK — ack for S2F43. +enum class ResetSpoolAck : uint8_t { + Accept = 0, + Denied_Busy = 1, + Denied_NotAllowed = 2, + Denied_NoSuchStream = 3, +}; + +// S6F24 RSDA — ack for S6F23 (Request Spooled Data). +enum class SpoolRequestAck : uint8_t { + Accept = 0, + Denied = 1, +}; + +// S6F23 RSDC — host's request code in the Spool Request. +enum class SpoolRequestCode : uint8_t { + Transmit = 0, + Purge = 1, +}; + +class SpoolStore { + public: + enum class EnqueueResult { + Queued, + Dropped_NotSpoolable, // stream not in the configured spoolable set + Dropped_Full, // queue at max_size, oldest-message overflow policy + }; + + // Spool config (set by S2F43 + loader). + void set_spoolable_streams(std::vector streams) { + spoolable_.clear(); + for (auto s : streams) spoolable_.insert(s); + } + bool is_stream_spoolable(uint8_t stream) const { + return spoolable_.count(stream) > 0; + } + std::vector spoolable_streams() const { + return {spoolable_.begin(), spoolable_.end()}; + } + + void set_max_size(std::size_t n) { max_size_ = n; } + std::size_t max_size() const { return max_size_; } + + // Equipment may force-enable spool mode (e.g. while the host is offline, + // or via a test command in the demo). Independent of the spoolable + // stream list — if `force_spool` is true *and* the stream is spoolable, + // enqueue() returns Queued. If `force_spool` is false, enqueue() still + // returns Dropped_NotSpoolable (so the caller can send live instead). + void set_force_spool(bool on) { force_spool_ = on; } + bool force_spool() const { return force_spool_; } + + EnqueueResult enqueue(s2::Message msg) { + if (!is_stream_spoolable(msg.stream)) return EnqueueResult::Dropped_NotSpoolable; + if (queue_.size() >= max_size_) { + // Overflow policy: drop the oldest (FIFO eviction). + queue_.pop_front(); + } + queue_.push_back(std::move(msg)); + return EnqueueResult::Queued; + } + + // Drain everything currently in the queue (FIFO order). + std::vector drain() { + std::vector out; + out.reserve(queue_.size()); + while (!queue_.empty()) { + out.push_back(std::move(queue_.front())); + queue_.pop_front(); + } + return out; + } + + void clear() { queue_.clear(); } + std::size_t size() const { return queue_.size(); } + bool empty() const { return queue_.empty(); } + + private: + std::set spoolable_; + std::deque queue_; + std::size_t max_size_ = 100; + bool force_spool_ = false; +}; + +} // namespace secsgem::gem diff --git a/src/config/loader.cpp b/src/config/loader.cpp index 315b6d3..4d61e8e 100644 --- a/src/config/loader.cpp +++ b/src/config/loader.cpp @@ -204,10 +204,21 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo spec.emit_ceid = static_cast(e.as()); if (auto a = c["set_alarm"]) spec.set_alarm = static_cast(a.as()); + if (auto f = c["force_spool"]) + spec.force_spool = f.as(); model.commands.register_command(c["name"].as(), spec); } } + if (auto sp = root["spool"]) { + if (auto m = sp["max_size"]) model.spool.set_max_size(static_cast(m.as())); + if (auto s = sp["spoolable_streams"]) { + std::vector streams; + for (const auto& v : s) streams.push_back(static_cast(v.as())); + model.spool.set_spoolable_streams(std::move(streams)); + } + } + return desc; } diff --git a/tests/test_data_model.cpp b/tests/test_data_model.cpp index 113b3bf..869c906 100644 --- a/tests/test_data_model.cpp +++ b/tests/test_data_model.cpp @@ -186,7 +186,7 @@ TEST_CASE("recipe CRUD") { // ---- Composite EquipmentDataModel --------------------------------------- -TEST_CASE("EquipmentDataModel composes the seven stores") { +TEST_CASE("EquipmentDataModel composes the eight stores") { EquipmentDataModel m; m.svids.add({1, "Clock", "", s2::Item::ascii("")}); m.dvids.add({2, "Temp", "C", s2::Item::u4(uint32_t{25})}); @@ -195,3 +195,50 @@ TEST_CASE("EquipmentDataModel composes the seven stores") { CHECK_FALSE(m.vid_exists(3)); CHECK(m.vid_value(2) == s2::Item::u4(uint32_t{25})); } + +// ---- Spool --------------------------------------------------------------- + +TEST_CASE("spool enqueue respects spoolable_streams whitelist") { + SpoolStore s; + s.set_spoolable_streams({5, 6}); + CHECK(s.enqueue(s2::Message(6, 11, false)) == SpoolStore::EnqueueResult::Queued); + CHECK(s.enqueue(s2::Message(5, 1, false)) == SpoolStore::EnqueueResult::Queued); + CHECK(s.enqueue(s2::Message(1, 2, false)) == SpoolStore::EnqueueResult::Dropped_NotSpoolable); + CHECK(s.size() == 2); +} + +TEST_CASE("spool FIFO eviction when max_size reached") { + SpoolStore s; + s.set_spoolable_streams({6}); + s.set_max_size(3); + for (int i = 0; i < 5; ++i) + s.enqueue(s2::Message(6, 11, false, s2::Item::u4(static_cast(i)))); + CHECK(s.size() == 3); + // Oldest two (0, 1) were evicted; remaining are 2, 3, 4. + auto drained = s.drain(); + REQUIRE(drained.size() == 3); + auto first_u4 = [](const s2::Message& m) -> uint32_t { + return std::get>(m.body->storage()).front(); + }; + CHECK(first_u4(drained[0]) == 2); + CHECK(first_u4(drained[2]) == 4); +} + +TEST_CASE("spool drain returns FIFO order and empties the queue") { + SpoolStore s; + s.set_spoolable_streams({6}); + s.enqueue(s2::Message(6, 11, false, s2::Item::u4(uint32_t{1}))); + s.enqueue(s2::Message(6, 11, false, s2::Item::u4(uint32_t{2}))); + auto out = s.drain(); + CHECK(out.size() == 2); + CHECK(s.empty()); + CHECK(s.drain().empty()); +} + +TEST_CASE("spool force flag controls whether enqueue is taken") { + SpoolStore s; + s.set_spoolable_streams({6}); + CHECK_FALSE(s.force_spool()); + s.set_force_spool(true); + CHECK(s.force_spool()); +} diff --git a/tests/test_loader.cpp b/tests/test_loader.cpp index 27934f8..f0e09d7 100644 --- a/tests/test_loader.cpp +++ b/tests/test_loader.cpp @@ -64,6 +64,20 @@ TEST_CASE("equipment.yaml populates SVIDs, ECIDs, CEIDs, alarms, recipes, comman CHECK(start.emit_ceid.value_or(0) == 300); auto fault = m.commands.dispatch("FAULT", {}); CHECK(fault.set_alarm.value_or(0) == 1); + + // Spool config loaded from YAML. + CHECK(m.spool.is_stream_spoolable(5)); + CHECK(m.spool.is_stream_spoolable(6)); + CHECK_FALSE(m.spool.is_stream_spoolable(1)); + CHECK(m.spool.max_size() == 100); + + // force_spool flag plumbed from YAML through CommandSpec. + auto on = m.commands.dispatch("SPOOL_ON", {}); + auto off = m.commands.dispatch("SPOOL_OFF", {}); + REQUIRE(on.force_spool.has_value()); + CHECK(*on.force_spool == true); + REQUIRE(off.force_spool.has_value()); + CHECK(*off.force_spool == false); } TEST_CASE("loader surfaces YAML errors with file path") {