#pragma once #include #include #include #include #include #include #include #include #include #include #include #include #include #include "secsgem/secs2/codec.hpp" #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). // // Persistence (opt-in): when `enable_persistence(dir)` is called, each // enqueued message is also written as a single file `.spool` in // the configured directory; on restart, `enable_persistence` replays // the directory back into the in-memory queue. Drain and clear delete // the corresponding files. Writes are atomic: write to `.tmp`, fsync, // rename. 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_; } // ---- Persistence ---------------------------------------------------- // Enable file-backed spooling at `dir`. Creates the directory if it // doesn't exist; scans existing `*.spool` files and replays them // back into the queue (sorted by seq) before returning. Subsequent // enqueue / drain / clear calls keep the directory in sync. Errors // (permission, malformed records) are silently skipped per file so a // single bad record can't poison the whole spool — the worst case is // dropping that one message. void enable_persistence(std::filesystem::path dir) { namespace fs = std::filesystem; journal_dir_ = std::move(dir); persistent_ = true; std::error_code ec; fs::create_directories(journal_dir_, ec); // Replay: collect all .spool files, sort by integer seq prefix. std::vector> existing; for (auto& entry : fs::directory_iterator(journal_dir_, ec)) { if (!entry.is_regular_file()) continue; const auto& p = entry.path(); if (p.extension() != ".spool") continue; uint64_t seq = 0; try { seq = std::stoull(p.stem().string()); } catch (...) { continue; } existing.emplace_back(seq, p); } std::sort(existing.begin(), existing.end(), [](const auto& a, const auto& b) { return a.first < b.first; }); for (auto& [seq, path] : existing) { auto msg = load_record(path); if (!msg) { // unreadable / malformed; drop the file so it doesn't linger. fs::remove(path, ec); continue; } queue_.push_back({std::move(*msg), path}); if (seq >= next_seq_) next_seq_ = seq + 1; } } bool persistence_enabled() const { return persistent_; } std::filesystem::path journal_dir() const { return journal_dir_; } 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). if (persistent_ && !queue_.front().path.empty()) { std::error_code ec; std::filesystem::remove(queue_.front().path, ec); } queue_.pop_front(); } std::filesystem::path path; if (persistent_) { path = write_record(msg); } queue_.push_back({std::move(msg), std::move(path)}); return EnqueueResult::Queued; } // Drain everything currently in the queue (FIFO order). Also deletes // the matching journal files when persistence is enabled. std::vector drain() { std::vector out; out.reserve(queue_.size()); std::error_code ec; while (!queue_.empty()) { auto& slot = queue_.front(); out.push_back(std::move(slot.msg)); if (persistent_ && !slot.path.empty()) { std::filesystem::remove(slot.path, ec); } queue_.pop_front(); } return out; } void clear() { if (persistent_) { std::error_code ec; for (auto& slot : queue_) { if (!slot.path.empty()) std::filesystem::remove(slot.path, ec); } } queue_.clear(); } std::size_t size() const { return queue_.size(); } bool empty() const { return queue_.empty(); } private: struct Slot { s2::Message msg; std::filesystem::path path; // empty when persistence is disabled }; // Journal record (v1) layout (little-endian-free; all bytes explicit): // [u8 magic = 0xE5] // [u8 version] // [u8 stream] // [u8 function] // [u8 reply_expected] // [u32 body_length, big-endian] // [body_length bytes] // // Upgrade discipline: loader accepts any version in [1, kVersion]; // future fields append behind an `if (version >= N)` gate. static constexpr uint8_t kMagic = 0xE5; static constexpr uint8_t kVersion = 0x01; std::filesystem::path write_record(const s2::Message& msg) { namespace fs = std::filesystem; auto seq = next_seq_++; char namebuf[32]; std::snprintf(namebuf, sizeof(namebuf), "%010llu.spool", static_cast(seq)); fs::path final_path = journal_dir_ / namebuf; fs::path tmp_path = final_path; tmp_path += ".tmp"; auto body_bytes = msg.body ? secs2::encode(*msg.body) : std::vector{}; const uint32_t blen = static_cast(body_bytes.size()); std::ofstream out(tmp_path, std::ios::binary | std::ios::trunc); if (!out) return {}; uint8_t hdr[5] = {kMagic, kVersion, msg.stream, msg.function, static_cast(msg.reply_expected ? 1 : 0)}; out.write(reinterpret_cast(hdr), sizeof(hdr)); uint8_t blen_be[4] = { static_cast((blen >> 24) & 0xFF), static_cast((blen >> 16) & 0xFF), static_cast((blen >> 8) & 0xFF), static_cast(blen & 0xFF)}; out.write(reinterpret_cast(blen_be), sizeof(blen_be)); if (blen) out.write(reinterpret_cast(body_bytes.data()), blen); out.flush(); out.close(); std::error_code ec; fs::rename(tmp_path, final_path, ec); if (ec) { fs::remove(tmp_path, ec); return {}; } return final_path; } static std::optional load_record(const std::filesystem::path& p) { std::ifstream in(p, std::ios::binary); if (!in) return std::nullopt; uint8_t hdr[5]; in.read(reinterpret_cast(hdr), sizeof(hdr)); if (!in || hdr[0] != kMagic || hdr[1] < 1 || hdr[1] > kVersion) return std::nullopt; uint8_t blen_be[4]; in.read(reinterpret_cast(blen_be), sizeof(blen_be)); if (!in) return std::nullopt; const uint32_t blen = (uint32_t{blen_be[0]} << 24) | (uint32_t{blen_be[1]} << 16) | (uint32_t{blen_be[2]} << 8) | uint32_t{blen_be[3]}; std::vector body(blen); if (blen) { in.read(reinterpret_cast(body.data()), blen); if (!in) return std::nullopt; } try { return s2::Message::from_body(hdr[2], hdr[3], hdr[4] != 0, body); } catch (...) { return std::nullopt; } } std::set spoolable_; std::deque queue_; std::size_t max_size_ = 100; bool force_spool_ = false; bool persistent_ = false; std::filesystem::path journal_dir_; uint64_t next_seq_ = 0; }; } // namespace secsgem::gem