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:
+9
-4
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
Reference in New Issue
Block a user