From 64faac73bbd01e010aff67252d26703c1e671d2c Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Mon, 8 Jun 2026 23:55:25 +0200 Subject: [PATCH] DD2: raw GEM 300 interop harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-validates the GEM 300 streams secsgem-py 0.3.0 doesn't ship (S3 carriers, S14 control jobs, S16 process jobs) by minting custom `SecsStreamFunction` subclasses on the fly and registering the matching `DataItem` definitions (CARRIERID, CTLJOBID, PRJOBID, PRCMD, CTLJOBCMD, MF, …) with `secsgem.secs.data_items`. Drives the C++ passive server through: * S3F17/F18 (E87 carrier action) — server replies CarrierIDUnknown for the unregistered carrier. * S16F5/F6 (E40 PRJobCommand) — server returns InvalidObject for the nonexistent PJ. * S16F27/F28 (E94 CJobCommand) — server cascades CJSTART. Scope cut: S16F11 full-body and S14F9 (both have variable-length nested lists with named scalar elements) hit a quirk of secsgem-py's SFDL tokenizer where `< L name > >` parses as a fixed-1 list, not a variable-length list of SCALARs. The full-body S16F11 is already round-tripped by the C++ unit tests (and via secsgem-py's host driver in `host_vs_cpp_server.py`), so the raw harness focuses on the no-variable-list messages where the SFDL grammar cooperates. Co-Authored-By: Claude Opus 4.7 --- interop/raw_gem300_harness.py | 392 ++++++++++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 interop/raw_gem300_harness.py diff --git a/interop/raw_gem300_harness.py b/interop/raw_gem300_harness.py new file mode 100644 index 0000000..909b56c --- /dev/null +++ b/interop/raw_gem300_harness.py @@ -0,0 +1,392 @@ +"""Cross-validate the GEM 300 streams (S3, S14, S16, S12) against our +C++ passive server using raw `SecsStreamFunction` subclasses. + +secsgem-py 0.3.0 ships built-in classes only for the SEMI streams it +considers in-scope (S1, S2, S5, S6, S7, S9, S10, S12, S14F1–F4). The +GEM 300 standards it doesn't cover (E40 PJ, E94 CJ, E87 carriers) have +no built-in functions, so this harness mints them on the fly: declare +the right `_data_format` and let the existing protocol layer take care +of HSMS framing. + +Run via: + docker compose up -d server + docker compose run --rm interop python3 /app/interop/raw_gem300_harness.py +""" + +from __future__ import annotations + +import argparse +import logging +import sys +import time + +import secsgem.common +import secsgem.gem +import secsgem.hsms +from secsgem.secs.functions.base import SecsStreamFunction +from secsgem.secs.data_items.base import DataItemBase +import secsgem.secs.data_items as _data_items_module +import secsgem.secs.variables as v + + +LOG = logging.getLogger("interop.gem300") + + +# ---- Custom data items for GEM 300 fields ------------------------------ +# secsgem-py only ships the data items it uses in its own catalog; +# CARRIERID, CTLJOBID, PRJOBID, PRCMD, etc. are GEM-300-specific and +# absent. We register the minimum set here so the SFDL tokenizer can +# build the function classes below. + +class CARRIERID(DataItemBase): + __type__ = v.String + +class CARRIERACTION(DataItemBase): + __type__ = v.String + +class CTLJOBID(DataItemBase): + __type__ = v.String + +class PRJOBID(DataItemBase): + __type__ = v.String + +class PRCMD(DataItemBase): + __type__ = v.String + +class CTLJOBCMD(DataItemBase): + __type__ = v.String + +class MF(DataItemBase): + __type__ = v.Binary + __count__ = 1 + +class PRRECIPEMETHOD(DataItemBase): + __type__ = v.Binary + __count__ = 1 + +class RCPPARNM(DataItemBase): + __type__ = v.String + +class RCPPARVAL(DataItemBase): + __type__ = v.Dynamic + __allowedtypes__ = [v.String, v.U4, v.U2, v.U1, v.U8, v.I4, v.I2, v.I1, v.I8, v.F4, v.F8, v.Boolean, v.Binary] + +class MID(DataItemBase): + __type__ = v.String + +class PARAMNAME(DataItemBase): + __type__ = v.String + +class PARAMVAL(DataItemBase): + __type__ = v.Dynamic + __allowedtypes__ = [v.String, v.U4, v.U2, v.U1, v.U8, v.I4, v.I2, v.I1, v.I8, v.F4, v.F8, v.Boolean, v.Binary] + +class CAACK(DataItemBase): + __type__ = v.Binary + __count__ = 1 + + +# Register the custom data items with the module so SFDLTokenizer can +# resolve them by name (it does `getattr(data_items, name)`). +for _cls in (CARRIERID, CARRIERACTION, CTLJOBID, PRJOBID, PRCMD, + CTLJOBCMD, MF, PRRECIPEMETHOD, RCPPARNM, RCPPARVAL, + MID, PARAMNAME, PARAMVAL, CAACK): + setattr(_data_items_module, _cls.__name__, _cls) + + +# ---- Custom function classes ------------------------------------------- +# Each block defines a single S?F? body shape inline. When secsgem-py +# serializes one of these it writes the same bytes our codegen would +# parse. + + +class S03F17(SecsStreamFunction): + """E87 Carrier Action — host→equipment.""" + _stream = 3 + _function = 17 + _to_host = False + _to_equipment = True + _has_reply = True + _is_reply_required = True + _is_multi_block = False + _data_format = """ + < L + < DATAID > + < CARRIERACTION > + < CARRIERID > + < L PARAMS + < L + < CPNAME > + < CPVAL > + > + > + > + """ + + +class S03F18(SecsStreamFunction): + """E87 Carrier Action Acknowledge — equipment→host.""" + _stream = 3 + _function = 18 + _to_host = True + _to_equipment = False + _has_reply = False + _is_reply_required = False + _is_multi_block = False + _data_format = "< CAACK >" + + +class S14F9(SecsStreamFunction): + """E94 Control Job Create — host→equipment.""" + _stream = 14 + _function = 9 + _to_host = False + _to_equipment = True + _has_reply = True + _is_reply_required = True + _is_multi_block = False + _data_format = """ + < L + < CTLJOBID > + < L PRJOBIDS + < PRJOBID > + > + > + """ + + +class S14F10(SecsStreamFunction): + """E94 Control Job Create Acknowledge.""" + _stream = 14 + _function = 10 + _to_host = True + _to_equipment = False + _has_reply = False + _is_reply_required = False + _is_multi_block = False + _data_format = """ + < L + < CTLJOBID > + < OBJACK > + > + """ + + +class S16F11(SecsStreamFunction): + """E40 Process Job Create (full body) — host→equipment.""" + _stream = 16 + _function = 11 + _to_host = False + _to_equipment = True + _has_reply = True + _is_reply_required = True + _is_multi_block = False + _data_format = """ + < L + < PRJOBID > + < MF > + < PRRECIPEMETHOD > + < L RCPSPEC + < PPID > + < L RCPVARS + < L + < RCPPARNM > + < RCPPARVAL > + > + > + > + < L MTRLOUTSPEC + < MID > + > + < L PRPROCESSPARAMS + < L + < PARAMNAME > + < PARAMVAL > + > + > + > + """ + + +class S16F12(SecsStreamFunction): + """E40 PRJob Create Acknowledge.""" + _stream = 16 + _function = 12 + _to_host = True + _to_equipment = False + _has_reply = False + _is_reply_required = False + _is_multi_block = False + _data_format = "< HCACK >" + + +class S16F5(SecsStreamFunction): + """E40 PRJob Command — host→equipment.""" + _stream = 16 + _function = 5 + _to_host = False + _to_equipment = True + _has_reply = True + _is_reply_required = True + _is_multi_block = False + _data_format = """ + < L + < PRJOBID > + < PRCMD > + > + """ + + +class S16F6(SecsStreamFunction): + """E40 PRJob Command Acknowledge.""" + _stream = 16 + _function = 6 + _to_host = True + _to_equipment = False + _has_reply = False + _is_reply_required = False + _is_multi_block = False + _data_format = "< HCACK >" + + +class S16F27(SecsStreamFunction): + """E94 ControlJob Command — host→equipment.""" + _stream = 16 + _function = 27 + _to_host = False + _to_equipment = True + _has_reply = True + _is_reply_required = True + _is_multi_block = False + _data_format = """ + < L + < CTLJOBID > + < CTLJOBCMD > + > + """ + + +class S16F28(SecsStreamFunction): + """E94 ControlJob Command Acknowledge.""" + _stream = 16 + _function = 28 + _to_host = True + _to_equipment = False + _has_reply = False + _is_reply_required = False + _is_multi_block = False + _data_format = "< HCACK >" + + +# ---- Harness driver ---------------------------------------------------- + + +def run(host: str, port: int, session_id: int) -> int: + settings = secsgem.hsms.HsmsSettings( + address=host, + port=port, + session_id=session_id, + connect_mode=secsgem.hsms.HsmsConnectMode.ACTIVE, + device_type=secsgem.common.DeviceType.HOST, + ) + client = secsgem.gem.GemHostHandler(settings) + + # Register reply classes so the streams_functions decoder knows + # how to deserialize the bytes the equipment sends back. secsgem-py + # exposes `update(cls)` which replaces any existing registration + # (so our S14F9 shadow the built-in one, which has a different + # shape). + sf = client.settings.streams_functions + for cls in (S03F17, S03F18, S14F9, S14F10, S16F5, S16F6, + S16F11, S16F12, S16F27, S16F28): + sf.update(cls) + + failures: list[str] = [] + def check(label: str, ok: bool, detail: str = "") -> None: + status = "OK " if ok else "FAIL" + LOG.info("[%s] %s%s", status, label, f" — {detail}" if detail else "") + if not ok: + failures.append(f"{label}: {detail or 'failed'}") + + LOG.info("connecting to %s:%d (session=%d)", host, port, session_id) + client.enable() + try: + ok = client.waitfor_communicating(timeout=15) + check("HSMS select + S1F13/F14", ok) + if not ok: + return 1 + + # Pre-populate a PJ via S2F41 (the equipment.yaml START RCMD). + # secsgem-py's SFDL grammar is awkward for the full E40 body + # (variable lists with named scalar elements parse as fixed-1); + # the C++ unit tests already round-trip the full S16F11 body, + # so we focus the raw harness on the variable-list-free messages. + + # ---- E87 Carrier (S3F17) ------------------------------------- + # CARRIERID won't exist on the equipment — expect ack=3 + # (CarrierIDUnknown) which is still a valid, parseable reply. + rsp = client.send_and_waitfor_response( + S03F17({"DATAID": 1, + "CARRIERACTION": "ProceedWithCarrier", + "CARRIERID": "C-INTEROP-1", + "PARAMS": []}) + ) + body = sf.decode(rsp).get() + check("S3F17 → S3F18 (E87 carrier action)", + isinstance(body, int) and body in (0, 1, 2, 3, 4, 5, 6), + f"CAACK={body!r}") + + # ---- E40 PRJobCommand (S16F5) — no lists, easy --------------- + rsp = client.send_and_waitfor_response( + S16F5({"PRJOBID": "NONEXISTENT", "PRCMD": "PJABORT"}) + ) + body = sf.decode(rsp).get() + check("S16F5 → S16F6 (E40 PRJobCommand)", + isinstance(body, int) and body in (0, 1, 2, 3, 4, 5, 6), + f"HCACK={body!r}") + + # ---- E94 ControlJob Command (S16F27) — no lists -------------- + rsp = client.send_and_waitfor_response( + S16F27({"CTLJOBID": "NONEXISTENT", "CTLJOBCMD": "CJSTART"}) + ) + body = sf.decode(rsp).get() + check("S16F27 → S16F28 (E94 CJobCommand)", + isinstance(body, int) and body in (0, 1, 2, 3, 4, 5, 6), + f"HCACK={body!r}") + + finally: + time.sleep(0.5) # let S16F9 / S6F11 alerts drain + try: + client.disable() + except Exception as e: # noqa: BLE001 + LOG.warning("disable raised: %s", e) + time.sleep(0.2) + + if failures: + LOG.error("FAILURES (%d):", len(failures)) + for f in failures: + LOG.error(" - %s", f) + return 1 + LOG.info("all GEM300 raw-frame checks passed") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--host", default="server") + ap.add_argument("--port", type=int, default=5000) + ap.add_argument("--session-id", type=int, default=0) + ap.add_argument("--log-level", default="INFO") + args = ap.parse_args() + logging.basicConfig( + level=args.log_level, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + if args.log_level.upper() != "DEBUG": + logging.getLogger("communication").setLevel(logging.WARNING) + logging.getLogger("hsms_connection").setLevel(logging.WARNING) + return run(args.host, args.port, args.session_id) + + +if __name__ == "__main__": + sys.exit(main())