Files
secs-gem/apps/secs_client.cpp
T
raphael 29db1caedb #6 SxFy codegen from YAML message catalog
The bulk of the per-SxFy boilerplate — ~90 hand-written builders and parsers
across 30+ message pairs — is now generated at build time from a single YAML
catalog. Adding a new SECS-II message becomes a YAML edit; the C++ code is
generated, not maintained.

What changed
------------

data/messages.yaml
  The catalog. Describes every SxFy currently supported: stream, function,
  W-bit, builder name, optional parser name, and a recursive body shape
  grammar (scalar / list / list_of).  Shapes carry SECS-II item types
  (ASCII, BINARY_BYTE, U4, F8, ITEM, ...) and optional C++ enum types for
  typed ack codes.  Inner-most fields can be marked external_struct: true
  so structs already defined elsewhere (ReportData, CommandParameter) are
  referenced rather than redefined.

tools/gen_messages.py
  Python codegen.  Reads the catalog and emits one inline header.  Handles
  nested shapes via depth-unique variable names in the generated IIFEs, so
  S6F11's three-level nesting compiles without lambda capture conflicts.
  Post-order traversal ensures inner structs are emitted before outer ones
  that reference them.  Generates positional and (where applicable) struct
  builder overloads, plus struct-returning parsers for messages with a
  `parser:` entry.

CMakeLists.txt
  Custom command runs gen_messages.py at configure/build time and emits
  ${CMAKE_BINARY_DIR}/generated/secsgem/gem/messages.hpp.  Added to the
  secsgem target's include path so `#include "secsgem/gem/messages.hpp"`
  resolves to the generated file.  Depends on the YAML + the script, so
  edits trigger regen automatically.

Dockerfile
  Added python3 + python3-yaml to the toolchain image.

include/secsgem/gem/messages_helpers.hpp  (new)
  The small set of hand-written helpers the generated header relies on:
  scalar accessors (as_ascii / as_u4_scalar / ...), parse_u4_list_body,
  u4_list_item, ack_byte, ALED byte constants, and the two special-case
  messages whose shape doesn't fit the codegen schema (S1F4 needs
  per-row std::optional<Item> semantics; S5F6 needs a per-row ALCD
  callback).

include/secsgem/gem/messages.hpp  (deleted)
  The hand-written builder/parser file is gone. Its content now flows
  through the catalog + codegen.

include/secsgem/gem/data_model.hpp
  Moved CommandParameter to namespace scope so it can be shared between
  the data model and the messages.yaml's external_struct entry.  Added
  `using CommandParam = CommandParameter` for back-compat.

apps/secs_server.cpp + apps/secs_client.cpp
  Updated the call sites that the codegen renamed or restructured:
  - parse_terminal_display() split into parse_s10f1 / parse_s10f3.
  - s1f14_establish_comms_ack now takes a McAck struct for the nested
    identity (mdln, softrev) — call site uses brace init.
  - S2F33/S2F35 parsers return strongly-typed entries (DefineReportEntry,
    LinkEventEntry); the server adapts these to the model's pair-based
    API at the call site.
  - S2F15 parser returns vector<EcSet>; iterate by .ecid/.value.
  - S5F3 parser returns EnableAlarmRequest{aled, alid}; bool comes from
    (aled & 0x80) != 0.
  - AlarmReport's is_set()/category() methods removed; callers use the
    raw alcd byte with bit math (alcd & 0x80, alcd & 0x7F).
  - s2f42_host_command_ack and s2f41_host_command always take their
    second list argument explicitly (no defaulted arg from codegen).

tests/test_messages.cpp
  Updated to construct the generated typed structs (EcSet, StatusName,
  EnableAlarmRequest, CommandParameter, CommandParameterAck) and to read
  the new field names (.ecid/.value, .rptid/.vids, .ceid/.rptids,
  .name/.code).

Coverage
--------

Generated by codegen (44 SxFy in catalog):

  S1F1, S1F2, S1F3, S1F11, S1F12, S1F13, S1F14, S1F15, S1F16, S1F17, S1F18
  S2F13, S2F14, S2F15, S2F16, S2F17, S2F18, S2F29, S2F30, S2F31, S2F32
  S2F33, S2F34, S2F35, S2F36, S2F37, S2F38, S2F41, S2F42
  S5F1, S5F2, S5F3, S5F4, S5F5
  S6F11, S6F12
  S7F3, S7F4, S7F5, S7F6, S7F19, S7F20
  S10F1, S10F2, S10F3, S10F4

Hand-written (in messages_helpers.hpp):

  S1F4   list-of-optional-items shape (nullopt -> <L,0>)
  S5F6   per-row ALCD via callback

Adding a new SxFy
-----------------

Append a single entry to data/messages.yaml describing the body shape.
The builder + parser appear in messages.hpp after the next build.  The
host command above for S2F41 (or any other added SxFy) requires no C++
changes if the body fits the recursive scalar/list/list_of grammar.

Tests: 67 cases / 384 assertions still passing.
Demo: byte-for-byte identical behaviour (Select, Establish, Online,
S1F11/F3 namelist+values, S2F29 EC namelist, S2F33/F35/F37 dynamic event
subscription, S2F41 START -> S6F11 emission, S5F5/F3 alarm directory +
enable, S2F41 FAULT -> S5F1 alarm + S6F11, S7F19/F5 recipe ops, S10F1
terminal, S1F15 offline, Separate).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:43:36 +02:00

335 lines
14 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::EquipmentDataModel::CommandParam> 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);
});
});
// 13. 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;
}