Files
secs-gem/apps/secs_client.cpp
T
raphael 0721db9542
tests / build-and-test (push) Failing after 42s
Close COMPLIANCE.md gap: spooling (E30 §6.22)
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>
2026-06-05 22:06:55 +02:00

388 lines
17 KiB
C++

// Active SECS/GEM host: connects to equipment and walks the full GEM core
// demo. After establishing communication and going online, the host configures
// dynamic event reporting (define report -> link CEID -> enable), triggers a
// host command that fires the linked CEID, exercises alarm enable + alarm
// triggering, fetches the recipe list and a single recipe body, sends a
// terminal display, and finally requests OFFLINE and separates.
#include <asio.hpp>
#include <chrono>
#include <cstdint>
#include <functional>
#include <iostream>
#include <memory>
#include <string>
#include <system_error>
#include <vector>
#include "secsgem/endpoint.hpp"
#include "secsgem/gem/messages.hpp"
#include "secsgem/secs2/codec.hpp"
#include "secsgem/secs2/message.hpp"
using namespace secsgem;
namespace s2 = secsgem::secs2;
namespace gem = secsgem::gem;
namespace {
std::string arg(int argc, char** argv, const std::string& key, const std::string& def) {
for (int i = 1; i + 1 < argc; ++i)
if (key == argv[i]) return argv[i + 1];
return def;
}
constexpr const char* kHostMdln = "GEMHOST";
constexpr const char* kHostRev = "0.1.0";
// Demo report / event subscription ids the host will install.
constexpr uint32_t kDataIdReports = 7;
constexpr uint32_t kRptidStatus = 1000;
constexpr uint32_t kCeidProcessStarted = 300;
constexpr uint32_t kCeidAlarmSetEvent = 200;
constexpr uint32_t kAlarmChiller = 1;
struct Sequence : std::enable_shared_from_this<Sequence> {
using Step = std::function<void(std::function<void()>)>;
std::vector<Step> steps;
std::size_t i = 0;
void run() {
if (i >= steps.size()) return;
auto self = shared_from_this();
steps[i]([self] {
++self->i;
self->run();
});
}
};
} // namespace
int main(int argc, char** argv) {
Client::Config cfg;
cfg.host = arg(argc, argv, "--host", "127.0.0.1");
cfg.port = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--port", "5000")));
cfg.device_id = static_cast<uint16_t>(std::stoi(arg(argc, argv, "--device", "0")));
cfg.timers.linktest = std::chrono::milliseconds(0);
asio::io_context io;
Client client(io, cfg);
auto logfn = [](const std::string& m) { std::cout << "[host] " << m << std::endl; };
client.on_log(logfn);
client.on_connection([&io, logfn](std::shared_ptr<Connection> conn) {
// Inbound primaries from the equipment.
conn->set_message_handler(
[logfn](const s2::Message& msg) -> std::optional<s2::Message> {
// S10F3: terminal display from equipment.
if (msg.stream == 10 && msg.function == 3) {
auto td = gem::parse_s10f3(msg);
if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text);
return gem::s10f4_terminal_display_ack(gem::TerminalAck::Accepted);
}
// S6F11: event report from equipment.
if (msg.stream == 6 && msg.function == 11) {
auto er = gem::parse_s6f11(msg);
if (er) {
logfn("EVENT CEID=" + std::to_string(er->ceid) + " (" +
std::to_string(er->reports.size()) + " reports)");
for (const auto& r : er->reports) {
std::string s = " RPTID " + std::to_string(r.rptid) + ":";
for (const auto& v : r.values) s += " " + s2::to_sml(v);
logfn(s);
}
}
return gem::s6f12_event_report_ack(gem::EventReportAck::Accept);
}
// S5F1: alarm send from equipment.
if (msg.stream == 5 && msg.function == 1) {
auto a = gem::parse_s5f1(msg);
if (a) {
logfn(std::string("ALARM ") + ((a->alcd & 0x80) ? "SET" : "CLR") +
" ALID=" + std::to_string(a->alid) +
" cat=" + std::to_string(a->alcd & 0x7F) + " \"" + a->altx + "\"");
}
return gem::s5f2_alarm_ack(gem::AlarmAck::Accept);
}
if (msg.reply_expected) return s2::Message(msg.stream, 0, false);
return std::nullopt;
});
auto seq = std::make_shared<Sequence>();
auto svids = std::make_shared<std::vector<uint32_t>>();
auto pacing = std::make_shared<asio::steady_timer>(io);
auto fail = [conn, logfn](const char* where, std::error_code ec) {
logfn(std::string(where) + " failed: " + ec.message());
conn->close(std::string(where) + " failed");
};
auto pause_then = [pacing](std::chrono::milliseconds dt, std::function<void()> cb) {
pacing->expires_after(dt);
pacing->async_wait([cb = std::move(cb)](std::error_code ec) {
if (!ec) cb();
});
};
// 1. Establish communications.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(gem::s1f13_establish_comms(kHostMdln, kHostRev),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S1F13", ec); return; }
logfn("S1F14 reply: " + reply.sml());
next();
});
});
// 2. Request ONLINE.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(gem::s1f17_request_online(),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S1F17", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S1F18 ONLACK=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 3. Discover SVIDs.
seq->steps.push_back([conn, logfn, fail, svids](auto next) {
conn->send_request(gem::s1f11_status_namelist_request({}),
[logfn, fail, svids, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S1F11", ec); return; }
auto parsed = gem::parse_s1f12(reply);
if (parsed) {
for (const auto& sn : *parsed) {
svids->push_back(sn.id);
logfn(" SVID " + std::to_string(sn.id) + " " + sn.name);
}
}
next();
});
});
// 4. Read SVID values.
seq->steps.push_back([conn, logfn, fail, svids](auto next) {
conn->send_request(gem::s1f3_selected_status_request(*svids),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S1F3", ec); return; }
logfn("S1F4 values: " + reply.sml());
next();
});
});
// 5. EC namelist.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(gem::s2f29_ec_namelist_request({}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S2F29", ec); return; }
logfn("S2F30 namelist: " + reply.sml());
next();
});
});
// 6. Define a report covering all SVIDs.
seq->steps.push_back([conn, logfn, fail, svids](auto next) {
conn->send_request(gem::s2f33_define_report(kDataIdReports, {{kRptidStatus, *svids}}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S2F33", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S2F34 DRACK=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 7. Link CEIDs to the report.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(
gem::s2f35_link_event_report(kDataIdReports,
{{kCeidProcessStarted, {kRptidStatus}},
{kCeidAlarmSetEvent, {kRptidStatus}}}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S2F35", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S2F36 LRACK=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 8. Enable the linked CEIDs.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(
gem::s2f37_enable_event(true, {kCeidProcessStarted, kCeidAlarmSetEvent}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S2F37", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S2F38 ERACK=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 9. Host command START -> equipment fires CEID kCeidProcessStarted.
seq->steps.push_back([conn, logfn, fail, pause_then](auto next) {
std::vector<gem::CommandParameter> params = {
{"LOTID", s2::Item::ascii("LOT-42")},
{"PPID", s2::Item::ascii("RECIPE-A")},
};
conn->send_request(gem::s2f41_host_command("START", params),
[logfn, fail, pause_then, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S2F41/START", ec); return; }
auto r = gem::parse_s2f42(reply);
logfn("S2F42 HCACK=" +
(r ? std::to_string(static_cast<int>(r->hcack)) : "?"));
pause_then(std::chrono::milliseconds(300), next);
});
});
// 10. List alarm directory.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(gem::s5f5_list_alarms_request({}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S5F5", ec); return; }
logfn("S5F6 alarms: " + reply.sml());
next();
});
});
// 11. Enable alarm 1 (so the equipment is allowed to send S5F1 for it).
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(gem::s5f3_enable_alarm(gem::kAlarmEnableByte, kAlarmChiller),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S5F3", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S5F4 ACKC5=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 12. Host command FAULT -> equipment sets alarm 1 and emits S5F1 + S6F11.
seq->steps.push_back([conn, logfn, fail, pause_then](auto next) {
conn->send_request(gem::s2f41_host_command("FAULT", {}),
[logfn, fail, pause_then, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S2F41/FAULT", ec); return; }
auto r = gem::parse_s2f42(reply);
logfn("S2F42 HCACK=" +
(r ? std::to_string(static_cast<int>(r->hcack)) : "?"));
pause_then(std::chrono::milliseconds(300), next);
});
});
// 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) {
conn->send_request(gem::s7f19_current_eppd_request(),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S7F19", ec); return; }
auto list = gem::parse_s7f20(reply);
if (list) {
logfn("S7F20: " + std::to_string(list->size()) + " recipes");
for (const auto& p : *list) logfn(" PPID " + p);
}
next();
});
});
// 14. Fetch a single recipe body.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(gem::s7f5_process_program_request("RECIPE-A"),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S7F5", ec); return; }
auto pp = gem::parse_s7f6(reply);
if (pp)
logfn("S7F6 PPID=" + pp->ppid + " body=" +
std::to_string(pp->ppbody.size()) + " bytes");
next();
});
});
// 15. Send a terminal display to the equipment.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(gem::s10f1_terminal_display_single(0, "Hello equipment!"),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S10F1", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S10F2 ACKC10=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 16. Request OFFLINE.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(gem::s1f15_request_offline(),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S1F15", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S1F16 OFLACK=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 17. Separate.
seq->steps.push_back([conn, logfn](auto) {
logfn("flow complete; separating");
conn->separate();
});
conn->set_selected_handler([seq]() { seq->run(); });
});
client.start();
io.run();
std::cout << "[host] exiting" << std::endl;
return 0;
}