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:
@@ -38,12 +38,14 @@ class HostCommandRegistry {
|
||||
HostCmdAck ack = HostCmdAck::Accept;
|
||||
std::optional<uint32_t> emit_ceid;
|
||||
std::optional<uint32_t> set_alarm;
|
||||
std::optional<bool> force_spool; // toggles the SpoolStore's flag
|
||||
};
|
||||
|
||||
struct Result {
|
||||
HostCmdAck ack = HostCmdAck::InvalidCommand;
|
||||
std::optional<uint32_t> emit_ceid;
|
||||
std::optional<uint32_t> set_alarm;
|
||||
std::optional<bool> 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<CommandParameter>& /*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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user