29db1caedb
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>
383 lines
13 KiB
Python
383 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate include/secsgem/gem/messages.hpp from data/messages.yaml.
|
|
|
|
Reads a SECS-II message catalog and emits an inline-only C++ header with
|
|
builders + parsers for each message. The catalog is the single source of
|
|
truth for SxFy body shapes.
|
|
|
|
Shape grammar (used recursively for `body` and `element` and field types):
|
|
|
|
none -> message has no body (header-only)
|
|
|
|
{kind: scalar,
|
|
item_type: <T>,
|
|
enum: <E>?, # optional C++ enum type
|
|
param: <name>?, # builder parameter name (default 'value')
|
|
parser_struct_field: ...} # only valid inside a list's `fields`
|
|
|
|
{kind: list,
|
|
fields: [{name, shape}, ...],
|
|
struct_name: <Name>?, # if set, parser returns std::optional<Name>
|
|
external_struct: bool?} # if true, the struct is assumed to already exist
|
|
|
|
{kind: list_of,
|
|
element: <shape>,
|
|
name: <param-name>?} # builder param + parser value name
|
|
|
|
`item_type` is one of:
|
|
ASCII, BINARY, BINARY_BYTE, BOOLEAN, ITEM,
|
|
U1, U2, U4, U8, I1, I2, I4, I8, F4, F8
|
|
|
|
The generated file is overwrite-safe and entirely inline (no .cpp).
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
|
|
|
|
# --- Type table -----------------------------------------------------------
|
|
|
|
# Maps item_type -> (cpp_param_type, item_factory, parse_helper)
|
|
# parse_helper(item_expr) -> C++ expression evaluating to std::optional<T>
|
|
SCALAR_TABLE = {
|
|
"ASCII": ("std::string", "secs2::Item::ascii({v})",
|
|
"as_ascii({i})"),
|
|
"BINARY_BYTE": ("uint8_t", "secs2::Item::binary({{ {v} }})",
|
|
"as_binary_first({i})"),
|
|
"BINARY": ("std::string", "secs2::Item::binary(std::vector<uint8_t>({v}.begin(), {v}.end()))",
|
|
"as_binary_string({i})"),
|
|
"BOOLEAN": ("bool", "secs2::Item::boolean({v})",
|
|
"as_boolean({i})"),
|
|
"ITEM": ("secs2::Item", "{v}",
|
|
"std::optional<secs2::Item>({i})"),
|
|
"U1": ("uint8_t", "secs2::Item::u1({v})",
|
|
"as_u1_scalar({i})"),
|
|
"U2": ("uint16_t", "secs2::Item::u2({v})",
|
|
"as_u2_scalar({i})"),
|
|
"U4": ("uint32_t", "secs2::Item::u4({v})",
|
|
"as_u4_scalar({i})"),
|
|
"U8": ("uint64_t", "secs2::Item::u8({v})",
|
|
"as_u8_scalar({i})"),
|
|
"I1": ("int8_t", "secs2::Item::i1({v})",
|
|
"as_i1_scalar({i})"),
|
|
"I2": ("int16_t", "secs2::Item::i2({v})",
|
|
"as_i2_scalar({i})"),
|
|
"I4": ("int32_t", "secs2::Item::i4({v})",
|
|
"as_i4_scalar({i})"),
|
|
"I8": ("int64_t", "secs2::Item::i8({v})",
|
|
"as_i8_scalar({i})"),
|
|
"F4": ("float", "secs2::Item::f4({v})",
|
|
"as_f4_scalar({i})"),
|
|
"F8": ("double", "secs2::Item::f8({v})",
|
|
"as_f8_scalar({i})"),
|
|
}
|
|
|
|
|
|
# --- Shape inspection -----------------------------------------------------
|
|
|
|
def is_scalar(shape): return shape and shape.get("kind") == "scalar"
|
|
def is_list(shape): return shape and shape.get("kind") == "list"
|
|
def is_list_of(shape): return shape and shape.get("kind") == "list_of"
|
|
|
|
|
|
def cpp_type(shape):
|
|
"""C++ type used for a parameter / parser-return / struct field."""
|
|
if is_scalar(shape):
|
|
base, _, _ = SCALAR_TABLE[shape["item_type"]]
|
|
return shape.get("enum", base)
|
|
if is_list(shape):
|
|
name = shape.get("struct_name")
|
|
if not name:
|
|
raise ValueError(f"list shape needs `struct_name`: {shape}")
|
|
return name
|
|
if is_list_of(shape):
|
|
return f"std::vector<{cpp_type(shape['element'])}>"
|
|
raise ValueError(f"unknown shape: {shape}")
|
|
|
|
|
|
def field_struct_name(field, message_name):
|
|
return field.get("struct_name") or message_name
|
|
|
|
|
|
# --- Code emission --------------------------------------------------------
|
|
|
|
class Emit:
|
|
def __init__(self):
|
|
self.lines = []
|
|
self.indent = 0
|
|
self.emitted_structs = set()
|
|
|
|
def line(self, s=""):
|
|
self.lines.append(" " * self.indent + s if s else "")
|
|
|
|
def block(self, header, body_fn, footer="}"):
|
|
self.line(header)
|
|
self.indent += 1
|
|
body_fn()
|
|
self.indent -= 1
|
|
self.line(footer)
|
|
|
|
|
|
def collect_structs(shape, out):
|
|
"""Post-order traversal of the shape tree, appending any (name, fields) for
|
|
list-with-struct_name nodes that aren't marked external. Post-order ensures
|
|
deeper structs are emitted before the structs that reference them."""
|
|
if not shape:
|
|
return
|
|
if is_list(shape):
|
|
for f in shape["fields"]:
|
|
collect_structs(f["shape"], out)
|
|
name = shape.get("struct_name")
|
|
if name and not shape.get("external_struct"):
|
|
out.append((name, shape["fields"]))
|
|
elif is_list_of(shape):
|
|
collect_structs(shape["element"], out)
|
|
|
|
|
|
def emit_struct(e, name, fields):
|
|
if name in e.emitted_structs:
|
|
return
|
|
e.emitted_structs.add(name)
|
|
e.line(f"struct {name} {{")
|
|
e.indent += 1
|
|
for f in fields:
|
|
e.line(f"{cpp_type(f['shape'])} {f['name']};")
|
|
e.indent -= 1
|
|
e.line("};")
|
|
e.line()
|
|
|
|
|
|
def emit_build_expr(shape, var, depth=0):
|
|
"""Return a C++ expression that constructs an Item from `var`."""
|
|
if is_scalar(shape):
|
|
_, factory, _ = SCALAR_TABLE[shape["item_type"]]
|
|
if "enum" in shape:
|
|
# Cast the typed enum to its underlying byte before constructing.
|
|
base, _, _ = SCALAR_TABLE[shape["item_type"]]
|
|
return factory.format(v=f"static_cast<{base}>({var})")
|
|
return factory.format(v=var)
|
|
if is_list(shape):
|
|
parts = [emit_build_expr(f["shape"], f"{var}.{f['name']}", depth)
|
|
for f in shape["fields"]]
|
|
return "secs2::Item::list({" + ", ".join(parts) + "})"
|
|
if is_list_of(shape):
|
|
c = f"__c_{depth}"
|
|
ev = f"__e_{depth}"
|
|
elem_expr = emit_build_expr(shape["element"], ev, depth + 1)
|
|
return (f"[&]{{ secs2::Item::List {c}; "
|
|
f"for (const auto& {ev} : {var}) {c}.push_back({elem_expr}); "
|
|
f"return secs2::Item::list(std::move({c})); }}()")
|
|
raise ValueError(f"unknown shape: {shape}")
|
|
|
|
|
|
def emit_parse_expr(shape, item_var, depth=0):
|
|
"""Return a C++ expression of type std::optional<T> that parses item_var."""
|
|
if is_scalar(shape):
|
|
_, _, helper = SCALAR_TABLE[shape["item_type"]]
|
|
expr = helper.format(i=item_var)
|
|
if "enum" in shape:
|
|
enum_t = shape["enum"]
|
|
t = f"__t_{depth}"
|
|
return (f"[&]() -> std::optional<{enum_t}> {{ auto {t} = {expr}; "
|
|
f"if (!{t}) return std::nullopt; "
|
|
f"return static_cast<{enum_t}>(*{t}); }}()")
|
|
return expr
|
|
if is_list(shape):
|
|
sname = shape["struct_name"]
|
|
n = len(shape["fields"])
|
|
L = f"__l_{depth}"
|
|
R = f"__r_{depth}"
|
|
V = f"__v_{depth}"
|
|
parts = [
|
|
f"if (!{item_var}.is_list() || {item_var}.as_list().size() != {n}) return std::nullopt;",
|
|
f"const auto& {L} = {item_var}.as_list();",
|
|
f"{sname} {R};",
|
|
]
|
|
for i, f in enumerate(shape["fields"]):
|
|
sub = emit_parse_expr(f["shape"], f"{L}[{i}]", depth + 1)
|
|
parts.append(f"{{ auto {V} = {sub}; if (!{V}) return std::nullopt; {R}.{f['name']} = *{V}; }}")
|
|
parts.append(f"return std::optional<{sname}>({R});")
|
|
return "[&]() -> std::optional<" + sname + "> { " + " ".join(parts) + " }()"
|
|
if is_list_of(shape):
|
|
elem_t = cpp_type(shape["element"])
|
|
c = f"__c_{depth}"
|
|
v = f"__v_{depth}"
|
|
out = f"__out_{depth}"
|
|
sub = emit_parse_expr(shape["element"], c, depth + 1)
|
|
return ("[&]() -> std::optional<std::vector<" + elem_t + ">> { "
|
|
f"if (!{item_var}.is_list()) return std::nullopt; "
|
|
f"std::vector<{elem_t}> {out}; "
|
|
f"for (const auto& {c} : {item_var}.as_list()) {{ "
|
|
f" auto {v} = {sub}; if (!{v}) return std::nullopt; "
|
|
f" {out}.push_back(*{v}); }} "
|
|
f"return {out}; }}()")
|
|
raise ValueError(f"unknown shape: {shape}")
|
|
|
|
|
|
def emit_message(e, m):
|
|
stream, fn = m["stream"], m["function"]
|
|
w = "true" if m.get("w") else "false"
|
|
builder = m["builder"]
|
|
body = m.get("body")
|
|
|
|
# Emit any structs referenced by the body.
|
|
structs = []
|
|
if body:
|
|
collect_structs(body, structs)
|
|
for name, fields in structs:
|
|
emit_struct(e, name, fields)
|
|
|
|
# ---- Builder -------------------------------------------------------
|
|
if body is None:
|
|
e.line(f"inline secs2::Message {builder}() {{")
|
|
e.indent += 1
|
|
e.line(f"return secs2::Message({stream}, {fn}, {w});")
|
|
e.indent -= 1
|
|
e.line("}")
|
|
e.line()
|
|
elif is_scalar(body):
|
|
ptype = cpp_type(body)
|
|
pname = body.get("param", "value")
|
|
build = emit_build_expr(body, pname)
|
|
e.line(f"inline secs2::Message {builder}({ptype} {pname}) {{")
|
|
e.indent += 1
|
|
e.line(f"return secs2::Message({stream}, {fn}, {w}, {build});")
|
|
e.indent -= 1
|
|
e.line("}")
|
|
e.line()
|
|
elif is_list(body):
|
|
fields = body["fields"]
|
|
sname = body.get("struct_name")
|
|
# Always emit a positional-parameter builder (no struct needed).
|
|
param_decl = ", ".join(
|
|
f"const {cpp_type(f['shape'])}& {f['name']}" for f in fields)
|
|
parts = [emit_build_expr(f["shape"], f["name"]) for f in fields]
|
|
body_expr = "secs2::Item::list({" + ", ".join(parts) + "})"
|
|
e.line(f"inline secs2::Message {builder}({param_decl}) {{")
|
|
e.indent += 1
|
|
e.line(f"return secs2::Message({stream}, {fn}, {w}, {body_expr});")
|
|
e.indent -= 1
|
|
e.line("}")
|
|
# If a struct exists, also emit an overload that takes it.
|
|
if sname:
|
|
forward = ", ".join(f"v.{f['name']}" for f in fields)
|
|
e.line(f"inline secs2::Message {builder}(const {sname}& v) {{")
|
|
e.indent += 1
|
|
e.line(f"return {builder}({forward});")
|
|
e.indent -= 1
|
|
e.line("}")
|
|
e.line()
|
|
elif is_list_of(body):
|
|
elem_t = cpp_type(body["element"])
|
|
pname = body.get("name", "values")
|
|
e.line(f"inline secs2::Message {builder}(const std::vector<{elem_t}>& {pname}) {{")
|
|
e.indent += 1
|
|
e.line(f"return secs2::Message({stream}, {fn}, {w}, {emit_build_expr(body, pname)});")
|
|
e.indent -= 1
|
|
e.line("}")
|
|
e.line()
|
|
else:
|
|
raise ValueError(f"unknown body kind for {m['id']}: {body}")
|
|
|
|
# ---- Parser --------------------------------------------------------
|
|
parser = m.get("parser")
|
|
if not parser:
|
|
return
|
|
if body is None:
|
|
# no body to parse
|
|
return
|
|
|
|
if is_scalar(body):
|
|
ret_t = cpp_type(body)
|
|
e.line(f"inline std::optional<{ret_t}> {parser}(const secs2::Message& m) {{")
|
|
e.indent += 1
|
|
e.line("if (!m.body) return std::nullopt;")
|
|
e.line(f"return {emit_parse_expr(body, '(*m.body)')};")
|
|
e.indent -= 1
|
|
e.line("}")
|
|
e.line()
|
|
elif is_list(body):
|
|
sname = body["struct_name"]
|
|
e.line(f"inline std::optional<{sname}> {parser}(const secs2::Message& m) {{")
|
|
e.indent += 1
|
|
e.line("if (!m.body) return std::nullopt;")
|
|
e.line(f"return {emit_parse_expr(body, '(*m.body)')};")
|
|
e.indent -= 1
|
|
e.line("}")
|
|
e.line()
|
|
elif is_list_of(body):
|
|
elem_t = cpp_type(body["element"])
|
|
e.line(f"inline std::optional<std::vector<{elem_t}>> {parser}(const secs2::Message& m) {{")
|
|
e.indent += 1
|
|
e.line("if (!m.body) return std::nullopt;")
|
|
e.line(f"return {emit_parse_expr(body, '(*m.body)')};")
|
|
e.indent -= 1
|
|
e.line("}")
|
|
e.line()
|
|
|
|
|
|
# --- Top level ------------------------------------------------------------
|
|
|
|
PREAMBLE = """\
|
|
// GENERATED by tools/gen_messages.py from data/messages.yaml.
|
|
// DO NOT EDIT — re-run the generator instead.
|
|
//
|
|
// Provides inline builders + parsers for every SECS-II message in the
|
|
// catalog. Hand-written helpers (scalar accessors, list-of-U4 helper,
|
|
// special-case messages) live in messages_helpers.hpp.
|
|
|
|
#pragma once
|
|
|
|
#include <cstdint>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include "secsgem/gem/control_state.hpp"
|
|
#include "secsgem/gem/data_model.hpp"
|
|
#include "secsgem/gem/messages_helpers.hpp"
|
|
#include "secsgem/secs2/item.hpp"
|
|
#include "secsgem/secs2/message.hpp"
|
|
|
|
namespace secsgem::gem {
|
|
|
|
namespace secs2 = secsgem::secs2;
|
|
|
|
"""
|
|
|
|
POSTAMBLE = """\
|
|
} // namespace secsgem::gem
|
|
"""
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--catalog", required=True, help="path to data/messages.yaml")
|
|
ap.add_argument("--out", required=True, help="output header path")
|
|
args = ap.parse_args()
|
|
|
|
catalog = yaml.safe_load(Path(args.catalog).read_text())
|
|
messages = catalog["messages"]
|
|
|
|
e = Emit()
|
|
e.lines.append(PREAMBLE.rstrip())
|
|
e.line()
|
|
|
|
for m in messages:
|
|
e.line(f"// ---- {m['id']} : {m['builder']} ----")
|
|
emit_message(e, m)
|
|
|
|
e.line(POSTAMBLE.rstrip())
|
|
|
|
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(args.out).write_text("\n".join(e.lines) + "\n")
|
|
print(f"generated {len(messages)} messages -> {args.out}", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|