feat(client): the Python client — a GEM tool in plain Python (Phase C)
clients/python: pip-installable "secsgem-client", pure Python (stubs
pre-generated from equipment.proto, import made package-relative; no
compiled extension, no SEMI knowledge, no C++ toolchain). The API the whole
effort aimed at:
eq = Equipment("localhost:50051")
eq.set(ChamberPressure=2.5); eq["WaferCounter"] = 7
eq.fire("ProcessStarted", ChamberPressure=2.75)
eq.alarm("chiller_temp_high"); eq.clear("chiller_temp_high")
@eq.on("START")
def start(cmd): ... # auto-CompleteCommand after return
eq.listen(background=True)
eq.control_state; eq.request_control_state("HOST_OFFLINE"); eq.health()
Errors raise SecsGemError carrying the daemon's message ("no variable named
..."). bool checked before int in conversion (isinstance(True, int)).
examples/mini_tool.py is a complete GEM tool in ~25 lines.
PROOF — interop/pyclient_interop.py drives the PUBLISHED package (not raw
stubs) against a live secs_gemd with secsgem-py as the fab host: 13 checks
all green on first run — set/get round-trips, item syntax, SecsGemError on
unknown names, control state, health, fire->S6F11 on the host's wire,
alarm/clear->S5F1 with correct set bit, the full command loop (host S2F41 ->
HCACK=4 -> @eq.on handler -> completion event back at the host), operator
offline. Conversion layer unit-tested standalone; both wired into
tools/run_interop.sh as the pyclient step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""End-to-end validation of the secsgem_client Python package.
|
||||
|
||||
The PUBLISHED client API (not raw stubs) plays the tool against a live
|
||||
secs_gemd, while secsgem-py — the reference GEM implementation — plays the
|
||||
fab host over HSMS. Every beautiful-API call is asserted against what the
|
||||
host actually receives on the wire:
|
||||
|
||||
eq.set / eq["..."] -> S1F3-visible values, GetVariables round-trip
|
||||
eq.fire -> host receives S6F11 with the configured report
|
||||
eq.alarm / eq.clear -> host receives S5F1 set/clear
|
||||
@eq.on + eq.listen -> host's S2F41 gets HCACK=4, handler runs,
|
||||
completion event reaches the host
|
||||
eq.control_state / request_control_state / eq.health
|
||||
|
||||
Exits 0 on success.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, "/app/clients/python")
|
||||
|
||||
import secsgem.common
|
||||
import secsgem.gem
|
||||
import secsgem.hsms
|
||||
import secsgem.secs
|
||||
|
||||
from secsgem_client import Equipment, SecsGemError
|
||||
|
||||
LOG = logging.getLogger("pyclient-interop")
|
||||
F = secsgem.secs.functions
|
||||
|
||||
|
||||
def run(grpc_addr: str, hsms_host: str, hsms_port: int) -> int:
|
||||
failures: list[str] = []
|
||||
|
||||
def check(label: str, ok: bool, detail: str = "") -> None:
|
||||
LOG.info("[%s] %s%s", "OK " if ok else "FAIL", label,
|
||||
f" — {detail}" if detail else "")
|
||||
if not ok:
|
||||
failures.append(label)
|
||||
|
||||
# ---- the tool, via the published client API ----
|
||||
eq = Equipment(grpc_addr)
|
||||
|
||||
started = threading.Event()
|
||||
|
||||
@eq.on("START")
|
||||
def _start(cmd): # noqa: ANN001
|
||||
started.set()
|
||||
eq.fire("ProcessStarted") # the host's real completion signal
|
||||
|
||||
eq.listen(background=True)
|
||||
|
||||
# ---- the host, via secsgem-py ----
|
||||
settings = secsgem.hsms.HsmsSettings(
|
||||
address=hsms_host, port=hsms_port, session_id=0,
|
||||
connect_mode=secsgem.hsms.HsmsConnectMode.ACTIVE,
|
||||
device_type=secsgem.common.DeviceType.HOST)
|
||||
host = secsgem.gem.GemHostHandler(settings)
|
||||
|
||||
ceid300 = threading.Event()
|
||||
s5f1 = {}
|
||||
s5f1_seen = threading.Event()
|
||||
|
||||
def on_s6f11(_h, message):
|
||||
body = host.settings.streams_functions.decode(message).get()
|
||||
if isinstance(body, dict) and body.get("CEID") == 300:
|
||||
ceid300.set()
|
||||
host.send_response(F.SecsS06F12(0), message.header.system)
|
||||
|
||||
def on_s5f1(_h, message):
|
||||
body = host.settings.streams_functions.decode(message).get()
|
||||
if isinstance(body, dict):
|
||||
s5f1.update(body)
|
||||
s5f1_seen.set()
|
||||
host.send_response(F.SecsS05F02(0), message.header.system)
|
||||
|
||||
host.register_stream_function(6, 11, on_s6f11)
|
||||
host.register_stream_function(5, 1, on_s5f1)
|
||||
host.enable()
|
||||
try:
|
||||
if not host.waitfor_communicating(timeout=15):
|
||||
check("HSMS establish-communications", False)
|
||||
return 1
|
||||
check("HSMS establish-communications", True)
|
||||
host.send_and_waitfor_response(F.SecsS01F17())
|
||||
time.sleep(0.3)
|
||||
|
||||
# ---- variables: kwargs, item syntax, read-back, errors ----
|
||||
eq.set(ChamberPressure=2.5, WaferCounter=7)
|
||||
check("eq.set(kwargs) + eq.get round-trip",
|
||||
eq.get("ChamberPressure", "WaferCounter") ==
|
||||
{"ChamberPressure": 2.5, "WaferCounter": 7})
|
||||
eq["ChamberPressure"] = 1.25
|
||||
check('eq["..."] item syntax', eq["ChamberPressure"] == 1.25)
|
||||
try:
|
||||
eq.set(NoSuchVariable=1)
|
||||
check("unknown variable raises SecsGemError", False)
|
||||
except SecsGemError as e:
|
||||
check("unknown variable raises SecsGemError", "NoSuchVariable" in str(e))
|
||||
|
||||
# ---- control state / health ----
|
||||
check("eq.control_state", eq.control_state == "ONLINE_REMOTE",
|
||||
eq.control_state)
|
||||
h = eq.health()
|
||||
check("eq.health()", h.link == "SELECTED" and
|
||||
h.control_state == "ONLINE_REMOTE", str(h))
|
||||
|
||||
# ---- events: configure a report host-side, fire client-side ----
|
||||
host.send_and_waitfor_response(
|
||||
F.SecsS02F33({"DATAID": 1, "DATA": [{"RPTID": 1, "VID": [101]}]}))
|
||||
host.send_and_waitfor_response(
|
||||
F.SecsS02F35({"DATAID": 1, "DATA": [{"CEID": 300, "RPTID": [1]}]}))
|
||||
host.send_and_waitfor_response(F.SecsS02F37({"CEED": True, "CEID": [300]}))
|
||||
eq.fire("ProcessStarted", ChamberPressure=2.75)
|
||||
check("eq.fire -> host receives S6F11", ceid300.wait(timeout=10))
|
||||
|
||||
# ---- alarms ----
|
||||
host.send_and_waitfor_response(F.SecsS05F03({"ALED": 0x80, "ALID": 1}))
|
||||
eq.alarm("chiller_temp_high")
|
||||
got = s5f1_seen.wait(timeout=10)
|
||||
check("eq.alarm -> host receives S5F1 (set)",
|
||||
got and s5f1.get("ALID") == 1 and (int(s5f1.get("ALCD") or 0) & 0x80))
|
||||
s5f1_seen.clear()
|
||||
eq.clear("chiller_temp_high")
|
||||
got = s5f1_seen.wait(timeout=10)
|
||||
check("eq.clear -> host receives S5F1 (clear)",
|
||||
got and not (int(s5f1.get("ALCD") or 0) & 0x80))
|
||||
|
||||
# ---- the command loop through @eq.on + eq.listen ----
|
||||
ceid300.clear()
|
||||
rsp = host.send_and_waitfor_response(
|
||||
F.SecsS02F41({"RCMD": "START", "PARAMS": []}))
|
||||
body = host.settings.streams_functions.decode(rsp).get()
|
||||
check("host S2F41 -> HCACK=4",
|
||||
isinstance(body, dict) and int(body.get("HCACK") or -1) == 4)
|
||||
check("@eq.on('START') handler ran", started.wait(timeout=10))
|
||||
check("handler's eq.fire reached the host (completion signal)",
|
||||
ceid300.wait(timeout=10))
|
||||
|
||||
# ---- operator offline via the client ----
|
||||
eq.request_control_state("HOST_OFFLINE")
|
||||
check("request_control_state(HOST_OFFLINE)",
|
||||
eq.control_state == "HOST_OFFLINE", eq.control_state)
|
||||
finally:
|
||||
host.disable()
|
||||
eq.close()
|
||||
|
||||
if failures:
|
||||
LOG.error("FAILURES (%d): %s", len(failures), failures)
|
||||
return 1
|
||||
LOG.info("all secsgem_client interop checks passed")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--grpc", default="gemd:50051")
|
||||
ap.add_argument("--hsms-host", default="gemd")
|
||||
ap.add_argument("--hsms-port", type=int, default=5000)
|
||||
args = ap.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logging.getLogger("communication").setLevel(logging.WARNING)
|
||||
logging.getLogger("hsms_connection").setLevel(logging.WARNING)
|
||||
return run(args.grpc, args.hsms_host, args.hsms_port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user