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:
@@ -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())
|
||||
Reference in New Issue
Block a user