DD2: raw GEM 300 interop harness

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 > <SCALAR> >` 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 23:55:25 +02:00
parent 0bbc7b7acf
commit 64faac73bb
+392
View File
@@ -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, S14F1F4). 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())