"""Drive the C++ passive secs_server with secsgem-py as the active host. Exercises a broad slice of streams that both implementations support: HSMS select/linktest/separate, S1/S2/S5/S6/S7/S10 message exchanges, event reports, alarms, recipes, host commands, variable limits, and the spool path. Exits 0 on success, non-zero on any failure. """ from __future__ import annotations import argparse import logging import sys import threading import time import secsgem.common import secsgem.gem import secsgem.hsms import secsgem.secs from secsgem.secs.functions.base import SecsStreamFunction from secsgem.secs.data_items.base import DataItemBase import secsgem.secs.data_items as _di import secsgem.secs.variables as _v LOG = logging.getLogger("interop") F = secsgem.secs.functions # secsgem-py 0.3.0 declares CMDA as Dynamic[U1, I1] only, but SEMI E5 # spells it as Binary in §10.18 and our C++ server emits it that way. # Widen the allowed type set so S2F22 decode succeeds. class _CMDA(DataItemBase): __type__ = _v.Dynamic __allowedtypes__ = [_v.Binary, _v.U1, _v.I1] _di.CMDA = _CMDA # secsgem-py's stock S2F21 ships with `_is_reply_required = False` and # the wire goes out with W=0; our server replies anyway but secsgem-py # never correlates the response so `send_and_waitfor_response` times # out. Override with the spec-correct W=1. class S02F21W(SecsStreamFunction): _stream = 2 _function = 21 _to_host = False _to_equipment = True _has_reply = True _is_reply_required = True _is_multi_block = False _data_format = "< RCMD >" 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) failures: list[str] = [] events_received: list[int] = [] alarms_received: list[tuple[int, int]] = [] # Per-CEID arrival events so we can wait for a specific event ID, not just # "any S6F11." The C++ server emits CEID 100 (control-state change) twice # during S1F17 (HostOffline -> AttemptOnline -> OnlineRemote), so a generic # "first message wins" gate races against the CEID 300 from RCMD=START. events_lock = threading.Lock() events_seen: dict[int, threading.Event] = {} def event_arrival(ceid: int) -> threading.Event: with events_lock: ev = events_seen.get(ceid) if ev is None: ev = threading.Event() events_seen[ceid] = ev return ev s5f1_event = threading.Event() def on_s6f11(_handler, message): decoded = client.settings.streams_functions.decode(message) body = decoded.get() ceid = body.get("CEID") if isinstance(body, dict) else None if ceid is not None: ceid_i = int(ceid) events_received.append(ceid_i) LOG.info("[evt] received S6F11 CEID=%s", ceid) event_arrival(ceid_i).set() client.send_response(F.SecsS06F12(0), message.header.system) def on_s5f1(_handler, message): decoded = client.settings.streams_functions.decode(message) body = decoded.get() alcd = body.get("ALCD") if isinstance(body, dict) else None alid = body.get("ALID") if isinstance(body, dict) else None if alid is not None: alarms_received.append((int(alcd or 0), int(alid))) LOG.info("[alm] received S5F1 ALID=%s ALCD=0x%02x", alid, alcd or 0) s5f1_event.set() client.send_response(F.SecsS05F02(0), message.header.system) # Register the primary-message handlers up-front. client.register_stream_function(6, 11, on_s6f11) client.register_stream_function(5, 1, on_s5f1) # Override secsgem-py's W=0 S2F21 so its host transactions wait for # the server's S2F22 reply. client.settings.streams_functions.update(S02F21W) 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'}") def round_trip(label: str, msg, expect=lambda body: True, detail_fn=str): rsp = client.send_and_waitfor_response(msg) decoded = client.settings.streams_functions.decode(rsp) body = decoded.get() try: ok = expect(body) except Exception as e: # noqa: BLE001 ok = False body = f"{body!r} (predicate raised {e!r})" check(label, ok, detail_fn(body)) return body LOG.info("enabling host handler (active connect to %s:%d, session=%d)", host, port, session_id) client.enable() try: ok = client.waitfor_communicating(timeout=15) check("HSMS select + S1F13/F14 establish-communications", ok) if not ok: return 1 # ---- S1 ---- round_trip("S1F1 → S1F2 (Are You There)", F.SecsS01F01(), lambda b: isinstance(b, list) and len(b) == 2) round_trip("S1F3 → S1F4 (all SVIDs)", F.SecsS01F03([]), lambda b: isinstance(b, list) and len(b) >= 1, lambda b: f"{len(b)} values") round_trip("S1F11 → S1F12 (status namelist)", F.SecsS01F11([]), lambda b: isinstance(b, list) and len(b) >= 1, lambda b: f"{len(b)} rows") round_trip("S1F17 → S1F18 (online req)", F.SecsS01F17(), lambda b: b in (0, 1, 2), lambda b: f"ONLACK={b!r}") round_trip("S1F23 → S1F24 (collection event namelist)", F.SecsS01F23([]), lambda b: isinstance(b, list), lambda b: f"{len(b) if isinstance(b, list) else 0} CEIDs") # ---- S2 (constants, time, events) ---- round_trip("S2F13 → S2F14 (EC values, all)", F.SecsS02F13([]), lambda b: isinstance(b, list), lambda b: f"{len(b)} values") round_trip("S2F17 → S2F18 (clock)", F.SecsS02F17(), lambda b: isinstance(b, str) and len(b) in (12, 16), lambda b: f"TIME={b!r}") round_trip("S2F29 → S2F30 (EC namelist)", F.SecsS02F29([]), lambda b: isinstance(b, list) and len(b) >= 1, lambda b: f"{len(b)} ECs") # Define + link + enable reports. Equipment.yaml maps RCMD=START # to CEID=300, so we link RPTID 1 to CEIDs 100 and 300 and enable # both — that way we exercise both the CEID-already-defined and # the freshly-defined linkage paths. round_trip( "S2F33 → S2F34 (define RPTID 1 → SVID 2)", F.SecsS02F33({"DATAID": 1, "DATA": [{"RPTID": 1, "VID": [2]}]}), lambda b: b == 0, lambda b: f"DRACK={b!r}", ) round_trip( "S2F35 → S2F36 (link CEIDs 100,300 → RPTID 1)", F.SecsS02F35({"DATAID": 1, "DATA": [ {"CEID": 100, "RPTID": [1]}, {"CEID": 300, "RPTID": [1]}, ]}), lambda b: b == 0, lambda b: f"LRACK={b!r}", ) round_trip( "S2F37 → S2F38 (enable CEIDs 100,300)", F.SecsS02F37({"CEED": True, "CEID": [100, 300]}), lambda b: b == 0, lambda b: f"ERACK={b!r}", ) # Per equipment.yaml: RCMD=START → emit_ceid=300. round_trip( "S2F41 → S2F42 (RCMD=START)", F.SecsS02F41({"RCMD": "START", "PARAMS": []}), lambda b: isinstance(b, dict) and b.get("HCACK") == 0, lambda b: f"HCACK={b.get('HCACK') if isinstance(b, dict) else b!r}", ) # Wait specifically for CEID 300 — we may have seen CEID 100 from # earlier control-state transitions, so a generic "any S6F11" gate # races against the START-triggered event. if event_arrival(300).wait(timeout=2.0): check( "S6F11 received after RCMD=START", True, f"observed CEIDs={events_received}", ) else: check("S6F11 received after RCMD=START", False, f"timeout (observed={events_received})") # ---- S5 alarms ---- round_trip("S5F5 → S5F6 (list all alarms)", F.SecsS05F05([]), lambda b: isinstance(b, list) and len(b) >= 1, lambda b: f"{len(b)} alarms") round_trip("S5F7 → S5F8 (list enabled alarms)", F.SecsS05F07(), lambda b: isinstance(b, list), lambda b: f"{len(b)} enabled") # Enable alarm 1: ALED bit 7 set (0x80) means enable. round_trip("S5F3 → S5F4 (enable ALID=1)", F.SecsS05F03({"ALED": 0x80, "ALID": 1}), lambda b: b == 0, lambda b: f"ACKC5={b!r}") # Fire FAULT host-command -> S5F1 alarm-set. round_trip("S2F41 → S2F42 (RCMD=FAULT)", F.SecsS02F41({"RCMD": "FAULT", "PARAMS": []}), lambda b: isinstance(b, dict) and b.get("HCACK") == 0, lambda b: f"HCACK={b.get('HCACK') if isinstance(b, dict) else b!r}") if s5f1_event.wait(timeout=2.0): check( "S5F1 received after RCMD=FAULT", len(alarms_received) >= 1, f"alarms={alarms_received}", ) else: check("S5F1 received after RCMD=FAULT", False, "timeout") # ---- S2F21 legacy remote command (no params) ---- # Use the W=1 override (secsgem-py's stock S2F21 has W=0 and # never awaits our reply); routes through the same # HostCommandRegistry as S2F41. round_trip("S2F21 → S2F22 (legacy RCMD=START)", S02F21W("START"), lambda b: b == 0, lambda b: f"CMDA={b!r}") # ---- S6 host-initiated report queries ------------------------- # S6F15 → S6F16: pull current event-report payload by CEID. # CEID 300 was linked to RPTID 1 (SVID 2 = Clock) above. round_trip( "S6F15 → S6F16 (event report request CEID=300)", F.SecsS06F15(300), lambda b: isinstance(b, dict) and b.get("CEID") == 300, lambda b: f"CEID={b.get('CEID') if isinstance(b, dict) else b!r}", ) # S6F19 → S6F20: pull RPTID 1 directly (just values, no annotation). round_trip( "S6F19 → S6F20 (individual report request RPTID=1)", F.SecsS06F19(1), lambda b: isinstance(b, list) and len(b) >= 1, lambda b: f"{len(b) if isinstance(b, list) else 0} values", ) # S6F21 → S6F22: same but with (VID, value) annotation. round_trip( "S6F21 → S6F22 (annotated individual report RPTID=1)", F.SecsS06F21(1), lambda b: isinstance(b, list) and len(b) >= 1, lambda b: f"{len(b) if isinstance(b, list) else 0} annotated values", ) # ---- S7 recipes ---- # S7F1/F2: ask permission before sending a PP of LENGTH bytes. round_trip( "S7F1 → S7F2 (PP load inquire)", F.SecsS07F01({"PPID": "RECIPE-Y", "LENGTH": 64}), lambda b: b in (0, 1, 2, 3, 4, 5, 6), lambda b: f"PPGNT={b!r}", ) round_trip("S7F19 → S7F20 (current PPID list)", F.SecsS07F19(), lambda b: isinstance(b, list), lambda b: f"{len(b)} PPIDs") round_trip( "S7F3 → S7F4 (send PP RECIPE-X)", F.SecsS07F03({"PPID": "RECIPE-X", "PPBODY": b"hello"}), lambda b: b == 0, lambda b: f"ACKC7={b!r}", ) round_trip("S7F5 → S7F6 (read back RECIPE-X)", F.SecsS07F05("RECIPE-X"), lambda b: isinstance(b, dict) and b.get("PPID") == "RECIPE-X", lambda b: f"got {b!r}") # S7F17/F18: delete the PP we just installed. round_trip( "S7F17 → S7F18 (delete RECIPE-X)", F.SecsS07F17(["RECIPE-X"]), lambda b: b == 0, lambda b: f"ACKC7={b!r}", ) # ---- S10 terminal display ---- round_trip( "S10F1 → S10F2 (terminal one-liner)", F.SecsS10F01({"TID": 0, "TEXT": "hello equipment"}), lambda b: b == 0, lambda b: f"ACKC10={b!r}", ) round_trip( "S10F3 → S10F4 (terminal multi-line)", F.SecsS10F03({"TID": 0, "TEXT": "line A\nline B"}), lambda b: b == 0, lambda b: f"ACKC10={b!r}", ) # ---- S1 control: go offline cleanly ---- round_trip("S1F15 → S1F16 (offline)", F.SecsS01F15(), lambda b: b in (0, 1, 2), lambda b: f"OFLACK={b!r}") finally: LOG.info("disabling host handler (separate)") 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 fail in failures: LOG.error(" - %s", fail) return 1 LOG.info("all 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", ) # Silence the noisy secsgem 'communication' INFO logger (one line per packet) # unless the user explicitly asked for DEBUG. 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())