Files
secs-gem/interop/raw_gem300_harness.py
raphael 64faac73bb 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>
2026-06-08 23:55:25 +02:00

393 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())