"""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())