Close COMPLIANCE.md gap: spooling (E30 §6.22)
tests / build-and-test (push) Failing after 42s

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   <L,n <B stream>>            Reset Spooling
    S2F44     <L,2 <B RSPACK> <L,a ...>>  Reset Spooling Ack
    S6F23 W   <B RSDC>                    Request Spooled Data
    S6F24     <B RSDA>                    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<bool> 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 22:06:55 +02:00
parent 16b734e946
commit 0721db9542
11 changed files with 391 additions and 25 deletions
+9 -4
View File
@@ -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.** | | 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. | | 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, 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. | | Control | ✅ | E30 §6.2 | — | See Fundamental. |
--- ---
@@ -129,7 +129,9 @@ Legend:
| S6F7 / S6F8 | H↔E | ⬜ | — | multi-block | | S6F7 / S6F8 | H↔E | ⬜ | — | multi-block |
| S6F11 / S6F12 | E→H | ✅ | `messages.hpp` | ✅ round-trip + demo | | S6F11 / S6F12 | E→H | ✅ | `messages.hpp` | ✅ round-trip + demo |
| S6F15 / S6F16 | H→E | ⬜ | — | event report request | | 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 | | S7F3 / S7F4 | H→E | ✅ | `messages.hpp` | ✅ round-trip |
| S7F5 / S7F6 | H→E | ✅ | `messages.hpp` | ✅ in demo | | S7F5 / S7F6 | H→E | ✅ | `messages.hpp` | ✅ in demo |
| S7F19 / S7F20 | H→E | ✅ | `messages.hpp` | ✅ round-trip + 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: The honest list, in priority order:
1. **Implement spooling** (E30 §6.22, S6F23/F24/F25/F26 + spool state 1. **Finish spooling**: S6F25/F26 spooled-data-ready notification, plus
model). This is the single largest missing GEM feature. 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) 2. **Implement the S9 error stream** (S9F1, F3, F5, F7, F9, F11, F13)
for the documented transport/protocol error cases. for the documented transport/protocol error cases.
3. **Implement Documentation messages** S1F19/F20 (GEM-compliance) and 3. **Implement Documentation messages** S1F19/F20 (GEM-compliance) and
+54 -1
View File
@@ -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<int>(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<int>(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<int>(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) { seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(gem::s7f19_current_eppd_request(), conn->send_request(gem::s7f19_current_eppd_request(),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) { [logfn, fail, next](std::error_code ec, const s2::Message& reply) {
+72 -11
View File
@@ -73,10 +73,30 @@ int main(int argc, char** argv) {
auto active_conn = std::make_shared<std::weak_ptr<Connection>>(); auto active_conn = std::make_shared<std::weak_ptr<Connection>>();
auto emit_event = [&io, active_conn, model, logfn](uint32_t ceid) { // Deliver a primary message to the host, or spool it if we can't (either
asio::post(io, [active_conn, model, logfn, ceid]() { // 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(); auto conn = active_conn->lock();
if (!conn) return; 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)) { if (!model->is_event_enabled(ceid)) {
logfn("CEID " + std::to_string(ceid) + " not enabled; suppressed"); logfn("CEID " + std::to_string(ceid) + " not enabled; suppressed");
return; return;
@@ -84,23 +104,21 @@ int main(int argc, char** argv) {
auto reports = model->compose_reports_for(ceid); auto reports = model->compose_reports_for(ceid);
logfn("emit S6F11 CEID=" + std::to_string(ceid) + " (" + logfn("emit S6F11 CEID=" + std::to_string(ceid) + " (" +
std::to_string(reports.size()) + " reports)"); std::to_string(reports.size()) + " reports)");
conn->send_request(gem::s6f11_event_report(0, ceid, reports), deliver_or_spool(gem::s6f11_event_report(0, ceid, reports),
[](std::error_code, const s2::Message&) {}); "S6F11 CEID=" + std::to_string(ceid));
}); });
}; };
auto emit_alarm_set = [&io, active_conn, model, logfn, emit_event](uint32_t alid) { auto emit_alarm_set = [&io, model, logfn, emit_event, deliver_or_spool](uint32_t alid) {
asio::post(io, [active_conn, model, logfn, emit_event, alid]() { asio::post(io, [model, logfn, emit_event, deliver_or_spool, alid]() {
auto conn = active_conn->lock();
if (!conn) return;
auto alarm = model->alarms.get(alid); auto alarm = model->alarms.get(alid);
if (!alarm) return; if (!alarm) return;
auto alcd = model->alarms.set_active(alid); auto alcd = model->alarms.set_active(alid);
if (!alcd) return; if (!alcd) return;
if (model->alarms.enabled(alid)) { if (model->alarms.enabled(alid)) {
logfn("emit S5F1 alarm set ALID=" + std::to_string(alid)); logfn("emit S5F1 alarm set ALID=" + std::to_string(alid));
conn->send_request(gem::s5f1_alarm_report(*alcd, alid, alarm->text), deliver_or_spool(gem::s5f1_alarm_report(*alcd, alid, alarm->text),
[](std::error_code, const s2::Message&) {}); "S5F1 ALID=" + std::to_string(alid));
} else { } else {
logfn("alarm " + std::to_string(alid) + " not enabled; suppressed"); 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.ack == gem::HostCmdAck::Accept) {
if (result.emit_ceid) emit_event(*result.emit_ceid); if (result.emit_ceid) emit_event(*result.emit_ceid);
if (result.set_alarm) emit_alarm_set(*result.set_alarm); 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, {}); 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<gem::ResetSpoolStreamAck> 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) { router.on(5, 3, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s5f3(msg); auto req = gem::parse_s5f3(msg);
auto ack = req ? model->alarms.set_enabled(req->alid, (req->aled & 0x80) != 0) auto ack = req ? model->alarms.set_enabled(req->alid, (req->aled & 0x80) != 0)
+14 -1
View File
@@ -39,12 +39,25 @@ recipes:
# Dispatched by S2F41. `ack` is the HCACK enum value. Optional `emit_ceid` # Dispatched by S2F41. `ack` is the HCACK enum value. Optional `emit_ceid`
# fires a CEID after dispatch (e.g. ProcessStarted on START), and optional # 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: host_commands:
- {name: START, ack: Accept, emit_ceid: 300} - {name: START, ack: Accept, emit_ceid: 300}
- {name: STOP, ack: Accept} - {name: STOP, ack: Accept}
- {name: PAUSE, ack: CannotDoNow} - {name: PAUSE, ack: CannotDoNow}
- {name: FAULT, ack: Accept, set_alarm: 1} - {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 # CEID emitted automatically whenever the control state machine transitions
# (i.e. on every change-handler call). Set to null to disable. # (i.e. on every change-handler call). Set to null to disable.
+47
View File
@@ -354,6 +354,38 @@ messages:
- {name: name, shape: {kind: scalar, item_type: ASCII}} - {name: name, shape: {kind: scalar, item_type: ASCII}}
- {name: code, shape: {kind: scalar, item_type: BINARY_BYTE}} - {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 # S5 — Alarms
# ===================================================================== # =====================================================================
@@ -444,6 +476,21 @@ messages:
builder: s6f12_event_report_ack builder: s6f12_event_report_ack
body: {kind: scalar, item_type: BINARY_BYTE, enum: EventReportAck, param: 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 # S7 — Process programs
# ===================================================================== # =====================================================================
+2
View File
@@ -6,6 +6,7 @@
#include "secsgem/gem/store/event_reports.hpp" #include "secsgem/gem/store/event_reports.hpp"
#include "secsgem/gem/store/host_commands.hpp" #include "secsgem/gem/store/host_commands.hpp"
#include "secsgem/gem/store/recipes.hpp" #include "secsgem/gem/store/recipes.hpp"
#include "secsgem/gem/store/spool.hpp"
#include "secsgem/gem/store/status_variables.hpp" #include "secsgem/gem/store/status_variables.hpp"
namespace secsgem::gem { namespace secsgem::gem {
@@ -24,6 +25,7 @@ struct EquipmentDataModel {
RecipeStore recipes; RecipeStore recipes;
Clock clock; Clock clock;
HostCommandRegistry commands; HostCommandRegistry commands;
SpoolStore spool;
// Convenience: VID -> value lookup spanning SVIDs and DVIDs. // Convenience: VID -> value lookup spanning SVIDs and DVIDs.
std::optional<s2::Item> vid_value(uint32_t vid) const { std::optional<s2::Item> vid_value(uint32_t vid) const {
+4 -2
View File
@@ -38,12 +38,14 @@ class HostCommandRegistry {
HostCmdAck ack = HostCmdAck::Accept; HostCmdAck ack = HostCmdAck::Accept;
std::optional<uint32_t> emit_ceid; std::optional<uint32_t> emit_ceid;
std::optional<uint32_t> set_alarm; std::optional<uint32_t> set_alarm;
std::optional<bool> force_spool; // toggles the SpoolStore's flag
}; };
struct Result { struct Result {
HostCmdAck ack = HostCmdAck::InvalidCommand; HostCmdAck ack = HostCmdAck::InvalidCommand;
std::optional<uint32_t> emit_ceid; std::optional<uint32_t> emit_ceid;
std::optional<uint32_t> set_alarm; std::optional<uint32_t> set_alarm;
std::optional<bool> force_spool;
}; };
void register_command(std::string rcmd, Spec spec) { void register_command(std::string rcmd, Spec spec) {
@@ -53,8 +55,8 @@ class HostCommandRegistry {
Result dispatch(const std::string& rcmd, Result dispatch(const std::string& rcmd,
const std::vector<CommandParameter>& /*params*/) const { const std::vector<CommandParameter>& /*params*/) const {
auto it = by_rcmd_.find(rcmd); auto it = by_rcmd_.find(rcmd);
if (it == by_rcmd_.end()) return {HostCmdAck::InvalidCommand, std::nullopt, std::nullopt}; 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}; return {it->second.ack, it->second.emit_ceid, it->second.set_alarm, it->second.force_spool};
} }
private: private:
+111
View File
@@ -0,0 +1,111 @@
#pragma once
#include <cstdint>
#include <deque>
#include <set>
#include <utility>
#include <vector>
#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<uint8_t> 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<uint8_t> 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<s2::Message> drain() {
std::vector<s2::Message> 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<uint8_t> spoolable_;
std::deque<s2::Message> queue_;
std::size_t max_size_ = 100;
bool force_spool_ = false;
};
} // namespace secsgem::gem
+11
View File
@@ -204,10 +204,21 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
spec.emit_ceid = static_cast<uint32_t>(e.as<int>()); spec.emit_ceid = static_cast<uint32_t>(e.as<int>());
if (auto a = c["set_alarm"]) if (auto a = c["set_alarm"])
spec.set_alarm = static_cast<uint32_t>(a.as<int>()); spec.set_alarm = static_cast<uint32_t>(a.as<int>());
if (auto f = c["force_spool"])
spec.force_spool = f.as<bool>();
model.commands.register_command(c["name"].as<std::string>(), spec); model.commands.register_command(c["name"].as<std::string>(), spec);
} }
} }
if (auto sp = root["spool"]) {
if (auto m = sp["max_size"]) model.spool.set_max_size(static_cast<std::size_t>(m.as<int>()));
if (auto s = sp["spoolable_streams"]) {
std::vector<uint8_t> streams;
for (const auto& v : s) streams.push_back(static_cast<uint8_t>(v.as<int>()));
model.spool.set_spoolable_streams(std::move(streams));
}
}
return desc; return desc;
} }
+48 -1
View File
@@ -186,7 +186,7 @@ TEST_CASE("recipe CRUD") {
// ---- Composite EquipmentDataModel --------------------------------------- // ---- Composite EquipmentDataModel ---------------------------------------
TEST_CASE("EquipmentDataModel composes the seven stores") { TEST_CASE("EquipmentDataModel composes the eight stores") {
EquipmentDataModel m; EquipmentDataModel m;
m.svids.add({1, "Clock", "", s2::Item::ascii("")}); m.svids.add({1, "Clock", "", s2::Item::ascii("")});
m.dvids.add({2, "Temp", "C", s2::Item::u4(uint32_t{25})}); 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_FALSE(m.vid_exists(3));
CHECK(m.vid_value(2) == s2::Item::u4(uint32_t{25})); 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<uint32_t>(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<std::vector<uint32_t>>(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());
}
+14
View File
@@ -64,6 +64,20 @@ TEST_CASE("equipment.yaml populates SVIDs, ECIDs, CEIDs, alarms, recipes, comman
CHECK(start.emit_ceid.value_or(0) == 300); CHECK(start.emit_ceid.value_or(0) == 300);
auto fault = m.commands.dispatch("FAULT", {}); auto fault = m.commands.dispatch("FAULT", {});
CHECK(fault.set_alarm.value_or(0) == 1); 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") { TEST_CASE("loader surfaces YAML errors with file path") {