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,30 @@
|
||||
# secsgem-client
|
||||
|
||||
A complete GEM tool integration in plain Python. The
|
||||
[`secs_gemd`](../../docs/DAEMON_ROADMAP.md) daemon owns everything SEMI —
|
||||
the HSMS link to the host, the GEM state machines, formats, timers,
|
||||
spooling; this client tells it about your tool and reacts to the host.
|
||||
|
||||
```python
|
||||
from secsgem_client import Equipment
|
||||
|
||||
eq = Equipment("localhost:50051")
|
||||
|
||||
eq.set(ChamberPressure=2.5) # host sees it on its next poll
|
||||
eq.fire("ProcessStarted") # S6F11 to the host, report auto-assembled
|
||||
eq.alarm("chiller_temp_high") # S5F1 (set), eq.clear(...) for clear
|
||||
|
||||
@eq.on("START") # host remote commands -> your function
|
||||
def start(cmd):
|
||||
run_recipe(cmd.params.get("PPID"))
|
||||
eq.fire("ProcessStarted") # the host's real completion signal
|
||||
|
||||
eq.listen() # block and dispatch (background=True for a thread)
|
||||
```
|
||||
|
||||
Names are the ones from your `equipment.yaml`; values are plain Python
|
||||
(`float`, `int`, `bool`, `str`, `bytes`, lists). Errors raise
|
||||
`SecsGemError` with the daemon's explanation ("no variable named ...").
|
||||
|
||||
No compiled extension, no SEMI knowledge, no C++ toolchain — `pip install`
|
||||
and a running daemon is the whole setup.
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A complete GEM tool in ~25 lines.
|
||||
|
||||
Run secs_gemd, then this. The fab host can poll ChamberPressure, receive
|
||||
ProcessStarted events, get alarms above the threshold, and START the tool —
|
||||
all SEMI plumbing handled by the daemon.
|
||||
"""
|
||||
import random
|
||||
import time
|
||||
|
||||
from secsgem_client import Equipment
|
||||
|
||||
eq = Equipment("localhost:50051")
|
||||
|
||||
|
||||
@eq.on("START")
|
||||
def start(cmd):
|
||||
print("host says START", cmd.params)
|
||||
eq.fire("ProcessStarted")
|
||||
|
||||
|
||||
eq.listen(background=True)
|
||||
|
||||
while True:
|
||||
pressure = round(random.uniform(1.0, 3.0), 3)
|
||||
eq.set(ChamberPressure=pressure)
|
||||
if pressure > 2.9:
|
||||
eq.alarm("chiller_temp_high")
|
||||
else:
|
||||
eq.clear("chiller_temp_high")
|
||||
time.sleep(1)
|
||||
@@ -0,0 +1,14 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "secsgem-client"
|
||||
version = "0.1.0"
|
||||
description = "Drive a secs_gemd SECS/GEM equipment daemon from plain Python"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = ["grpcio>=1.50", "protobuf>=4.21"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["secsgem_client*"]
|
||||
@@ -0,0 +1,12 @@
|
||||
"""secsgem_client — drive a SECS/GEM equipment daemon from plain Python.
|
||||
|
||||
The secs_gemd daemon speaks SEMI (HSMS, GEM state machines, SECS-II) to the
|
||||
fab host; this client speaks plain Python to the daemon. You never touch a
|
||||
stream/function, a format code, or a numeric id — just the names from your
|
||||
equipment.yaml.
|
||||
"""
|
||||
|
||||
from ._client import Command, Equipment, Health, SecsGemError
|
||||
|
||||
__all__ = ["Equipment", "Command", "Health", "SecsGemError"]
|
||||
__version__ = "0.1.0"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,214 @@
|
||||
"""The Equipment client: a complete GEM tool integration in plain Python.
|
||||
|
||||
The secs_gemd daemon owns everything SEMI — the HSMS link to the host, the
|
||||
GEM state machines, message formats, timers, spooling. This client only ever
|
||||
does two things: tell the equipment about itself (variables, events, alarms)
|
||||
and react to what the host asks (commands). Names are the ones from your
|
||||
equipment.yaml; values are plain Python.
|
||||
|
||||
from secsgem_client import Equipment
|
||||
|
||||
eq = Equipment("localhost:50051")
|
||||
eq.set(ChamberPressure=2.5)
|
||||
eq.fire("ProcessStarted")
|
||||
|
||||
@eq.on("START")
|
||||
def start(cmd):
|
||||
run_recipe(cmd.params.get("PPID"))
|
||||
|
||||
eq.listen() # blocks, dispatching host commands to your handlers
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Dict, Iterator, Optional
|
||||
|
||||
import grpc
|
||||
|
||||
from ._proto import equipment_pb2 as pb
|
||||
from ._proto import equipment_pb2_grpc as rpc
|
||||
from ._values import from_value, to_value
|
||||
|
||||
|
||||
class SecsGemError(RuntimeError):
|
||||
"""The daemon declined a request (unknown name, bad value, wrong state)."""
|
||||
|
||||
def __init__(self, code: int, message: str):
|
||||
super().__init__(message or pb.Ack.Code.Name(code))
|
||||
self.code = code
|
||||
|
||||
|
||||
def _check(ack: pb.Ack) -> None:
|
||||
if ack.code != pb.Ack.ACCEPT:
|
||||
raise SecsGemError(ack.code, ack.message)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Command:
|
||||
"""A remote command from the host (e.g. START). The host has already been
|
||||
told "accepted, will finish later" — report the real outcome by firing an
|
||||
event (success) or raising an alarm (failure) from your handler."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
params: Dict[str, object]
|
||||
_eq: "Equipment"
|
||||
|
||||
def done(self, ok: bool = True) -> None:
|
||||
"""Mark the command complete (for the daemon's audit trail). Called
|
||||
automatically after your handler returns; call early if you want."""
|
||||
self._eq._complete(self.id, ok)
|
||||
self._done = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class Health:
|
||||
link: str # "DISCONNECTED" | "CONNECTED" | "SELECTED"
|
||||
control_state: str # "HOST_OFFLINE" | "ONLINE_REMOTE" | ...
|
||||
spool_depth: int
|
||||
|
||||
|
||||
class Equipment:
|
||||
"""A connection to the secs_gemd daemon — your equipment, by name."""
|
||||
|
||||
def __init__(self, address: str = "localhost:50051",
|
||||
connect_timeout: float = 10.0):
|
||||
self._channel = grpc.insecure_channel(address)
|
||||
grpc.channel_ready_future(self._channel).result(timeout=connect_timeout)
|
||||
self._stub = rpc.EquipmentStub(self._channel)
|
||||
self._handlers: Dict[str, Callable[[Command], object]] = {}
|
||||
self._listen_thread: Optional[threading.Thread] = None
|
||||
self._stop = threading.Event()
|
||||
|
||||
# ---- report state to the host -------------------------------------------
|
||||
|
||||
def set(self, values: Optional[Dict[str, object]] = None, **kwargs) -> None:
|
||||
"""Update status/data variables by name: eq.set(ChamberPressure=2.5).
|
||||
The host sees the new values immediately when it polls."""
|
||||
merged = dict(values or {})
|
||||
merged.update(kwargs)
|
||||
req = pb.VariableUpdate()
|
||||
for name, v in merged.items():
|
||||
req.values[name].CopyFrom(to_value(v))
|
||||
_check(self._stub.SetVariables(req))
|
||||
|
||||
def get(self, *names: str) -> Dict[str, object]:
|
||||
"""Read variables back from the equipment. No names = all of them."""
|
||||
try:
|
||||
snap = self._stub.GetVariables(pb.VariableQuery(names=list(names)))
|
||||
except grpc.RpcError as e:
|
||||
raise SecsGemError(pb.Ack.PARAMETER_INVALID, e.details()) from None
|
||||
return {k: from_value(v) for k, v in snap.values.items()}
|
||||
|
||||
def __setitem__(self, name: str, value) -> None:
|
||||
self.set({name: value})
|
||||
|
||||
def __getitem__(self, name: str):
|
||||
return self.get(name)[name]
|
||||
|
||||
def fire(self, event: str, **data) -> None:
|
||||
"""Fire a collection event by name; the daemon assembles the configured
|
||||
report and sends it to the host. kwargs set variable values for this
|
||||
event first: eq.fire("WaferComplete", Thickness=1.2)."""
|
||||
req = pb.Event(name=event)
|
||||
for name, v in data.items():
|
||||
req.data[name].CopyFrom(to_value(v))
|
||||
_check(self._stub.FireEvent(req))
|
||||
|
||||
def alarm(self, name: str) -> None:
|
||||
"""Raise an alarm by its config name (or stringified ALID)."""
|
||||
_check(self._stub.SetAlarm(pb.Alarm(name=name)))
|
||||
|
||||
def clear(self, name: str) -> None:
|
||||
"""Clear a previously raised alarm."""
|
||||
_check(self._stub.ClearAlarm(pb.Alarm(name=name)))
|
||||
|
||||
# ---- control state & health ---------------------------------------------
|
||||
|
||||
@property
|
||||
def control_state(self) -> str:
|
||||
"""The GEM control state, e.g. "ONLINE_REMOTE"."""
|
||||
cs = self._stub.GetControlState(pb.Empty())
|
||||
return pb.ControlState.State.Name(cs.state)
|
||||
|
||||
def request_control_state(self, desired: str) -> None:
|
||||
"""Operator-panel transition, e.g. eq.request_control_state("HOST_OFFLINE")
|
||||
before maintenance. Raises if the E30 table says no from here."""
|
||||
req = pb.ControlStateRequest(
|
||||
desired=pb.ControlState.State.Value(desired))
|
||||
_check(self._stub.RequestControlState(req))
|
||||
|
||||
def health(self) -> Health:
|
||||
"""One health snapshot (link / control state / spool depth)."""
|
||||
for h in self._stub.WatchHealth(pb.Empty()):
|
||||
return Health(pb.Health.LinkState.Name(h.link),
|
||||
pb.ControlState.State.Name(h.control_state),
|
||||
h.spool_depth)
|
||||
raise SecsGemError(pb.Ack.CANNOT_DO_NOW, "health stream ended")
|
||||
|
||||
def watch_health(self) -> Iterator[Health]:
|
||||
"""Yields a Health snapshot on every link/state change."""
|
||||
for h in self._stub.WatchHealth(pb.Empty()):
|
||||
yield Health(pb.Health.LinkState.Name(h.link),
|
||||
pb.ControlState.State.Name(h.control_state),
|
||||
h.spool_depth)
|
||||
|
||||
# ---- react to the host ----------------------------------------------------
|
||||
|
||||
def on(self, command: str):
|
||||
"""Decorator: run this function when the host sends `command`.
|
||||
Use "*" to catch commands with no specific handler."""
|
||||
|
||||
def register(fn: Callable[[Command], object]):
|
||||
self._handlers[command] = fn
|
||||
return fn
|
||||
|
||||
return register
|
||||
|
||||
def listen(self, background: bool = False) -> None:
|
||||
"""Consume host requests and dispatch them to @eq.on handlers.
|
||||
Blocks; pass background=True to run on a daemon thread instead."""
|
||||
if background:
|
||||
self._listen_thread = threading.Thread(target=self._listen_loop,
|
||||
daemon=True)
|
||||
self._listen_thread.start()
|
||||
return
|
||||
self._listen_loop()
|
||||
|
||||
def close(self) -> None:
|
||||
self._stop.set()
|
||||
self._channel.close()
|
||||
|
||||
# ---- internals -------------------------------------------------------------
|
||||
|
||||
def _listen_loop(self) -> None:
|
||||
try:
|
||||
for hr in self._stub.Subscribe(pb.SubscribeRequest(client="secsgem_client")):
|
||||
if self._stop.is_set():
|
||||
return
|
||||
if not hr.HasField("command"):
|
||||
continue # future HostRequest variants (jobs, carriers, ...)
|
||||
cmd = hr.command
|
||||
handler = self._handlers.get(cmd.name) or self._handlers.get("*")
|
||||
command = Command(cmd.id, cmd.name,
|
||||
{k: from_value(v) for k, v in cmd.params.items()},
|
||||
self)
|
||||
ok = True
|
||||
try:
|
||||
if handler is not None:
|
||||
handler(command)
|
||||
except Exception:
|
||||
ok = False
|
||||
raise
|
||||
finally:
|
||||
if not getattr(command, "_done", False):
|
||||
self._complete(cmd.id, ok)
|
||||
except grpc.RpcError:
|
||||
if not self._stop.is_set():
|
||||
raise
|
||||
|
||||
def _complete(self, command_id: str, ok: bool) -> None:
|
||||
ack = pb.Ack(code=pb.Ack.ACCEPT if ok else pb.Ack.REJECTED)
|
||||
self._stub.CompleteCommand(pb.CommandResult(id=command_id, ack=ack))
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,546 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
from . import equipment_pb2 as equipment__pb2
|
||||
|
||||
|
||||
class EquipmentStub(object):
|
||||
"""=============================================================================
|
||||
SECS/GEM Equipment API
|
||||
|
||||
The daemon *is* one piece of SEMI equipment: it owns the HSMS link to the
|
||||
host (MES) and speaks GEM on your behalf. Your tool software connects as a
|
||||
client and only ever does two things:
|
||||
|
||||
• tell the equipment about itself — set variables, fire events, raise alarms
|
||||
• react to what the host asks for — receive commands/jobs, answer them
|
||||
|
||||
Everything SECS lives inside the daemon: message framing, report definitions,
|
||||
the GEM state machines, timers, spooling. You need no SEMI knowledge to use
|
||||
this API. Items are addressed by the human names from your equipment config
|
||||
(e.g. "chamber_pressure"), never by numeric SVID / CEID / ALID.
|
||||
|
||||
CAPABILITY TIERS — wire up only what your equipment is:
|
||||
• Universal — variables, events, alarms, control state, commands. Every tool.
|
||||
• Carriers — E87 carrier/load-port flows. Only carrier-based equipment.
|
||||
• Recipes — S7 process-program transfer. Only recipe-driven equipment.
|
||||
• Jobs — E40 process jobs. Only job-based process/front-end equipment.
|
||||
If a tier doesn't apply, you simply never receive its HostRequest variants and
|
||||
never call its report RPCs.
|
||||
=============================================================================
|
||||
|
||||
---- Universal: report state to the host --------------------------------
|
||||
"""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.SetVariables = channel.unary_unary(
|
||||
'/secsgem.v1.Equipment/SetVariables',
|
||||
request_serializer=equipment__pb2.VariableUpdate.SerializeToString,
|
||||
response_deserializer=equipment__pb2.Ack.FromString,
|
||||
)
|
||||
self.GetVariables = channel.unary_unary(
|
||||
'/secsgem.v1.Equipment/GetVariables',
|
||||
request_serializer=equipment__pb2.VariableQuery.SerializeToString,
|
||||
response_deserializer=equipment__pb2.VariableSnapshot.FromString,
|
||||
)
|
||||
self.FireEvent = channel.unary_unary(
|
||||
'/secsgem.v1.Equipment/FireEvent',
|
||||
request_serializer=equipment__pb2.Event.SerializeToString,
|
||||
response_deserializer=equipment__pb2.Ack.FromString,
|
||||
)
|
||||
self.SetAlarm = channel.unary_unary(
|
||||
'/secsgem.v1.Equipment/SetAlarm',
|
||||
request_serializer=equipment__pb2.Alarm.SerializeToString,
|
||||
response_deserializer=equipment__pb2.Ack.FromString,
|
||||
)
|
||||
self.ClearAlarm = channel.unary_unary(
|
||||
'/secsgem.v1.Equipment/ClearAlarm',
|
||||
request_serializer=equipment__pb2.Alarm.SerializeToString,
|
||||
response_deserializer=equipment__pb2.Ack.FromString,
|
||||
)
|
||||
self.GetControlState = channel.unary_unary(
|
||||
'/secsgem.v1.Equipment/GetControlState',
|
||||
request_serializer=equipment__pb2.Empty.SerializeToString,
|
||||
response_deserializer=equipment__pb2.ControlState.FromString,
|
||||
)
|
||||
self.RequestControlState = channel.unary_unary(
|
||||
'/secsgem.v1.Equipment/RequestControlState',
|
||||
request_serializer=equipment__pb2.ControlStateRequest.SerializeToString,
|
||||
response_deserializer=equipment__pb2.Ack.FromString,
|
||||
)
|
||||
self.Subscribe = channel.unary_stream(
|
||||
'/secsgem.v1.Equipment/Subscribe',
|
||||
request_serializer=equipment__pb2.SubscribeRequest.SerializeToString,
|
||||
response_deserializer=equipment__pb2.HostRequest.FromString,
|
||||
)
|
||||
self.CompleteCommand = channel.unary_unary(
|
||||
'/secsgem.v1.Equipment/CompleteCommand',
|
||||
request_serializer=equipment__pb2.CommandResult.SerializeToString,
|
||||
response_deserializer=equipment__pb2.Ack.FromString,
|
||||
)
|
||||
self.ReportProcessJob = channel.unary_unary(
|
||||
'/secsgem.v1.Equipment/ReportProcessJob',
|
||||
request_serializer=equipment__pb2.ProcessJobState.SerializeToString,
|
||||
response_deserializer=equipment__pb2.Ack.FromString,
|
||||
)
|
||||
self.ReportCarrier = channel.unary_unary(
|
||||
'/secsgem.v1.Equipment/ReportCarrier',
|
||||
request_serializer=equipment__pb2.CarrierState.SerializeToString,
|
||||
response_deserializer=equipment__pb2.Ack.FromString,
|
||||
)
|
||||
self.WatchHealth = channel.unary_stream(
|
||||
'/secsgem.v1.Equipment/WatchHealth',
|
||||
request_serializer=equipment__pb2.Empty.SerializeToString,
|
||||
response_deserializer=equipment__pb2.Health.FromString,
|
||||
)
|
||||
|
||||
|
||||
class EquipmentServicer(object):
|
||||
"""=============================================================================
|
||||
SECS/GEM Equipment API
|
||||
|
||||
The daemon *is* one piece of SEMI equipment: it owns the HSMS link to the
|
||||
host (MES) and speaks GEM on your behalf. Your tool software connects as a
|
||||
client and only ever does two things:
|
||||
|
||||
• tell the equipment about itself — set variables, fire events, raise alarms
|
||||
• react to what the host asks for — receive commands/jobs, answer them
|
||||
|
||||
Everything SECS lives inside the daemon: message framing, report definitions,
|
||||
the GEM state machines, timers, spooling. You need no SEMI knowledge to use
|
||||
this API. Items are addressed by the human names from your equipment config
|
||||
(e.g. "chamber_pressure"), never by numeric SVID / CEID / ALID.
|
||||
|
||||
CAPABILITY TIERS — wire up only what your equipment is:
|
||||
• Universal — variables, events, alarms, control state, commands. Every tool.
|
||||
• Carriers — E87 carrier/load-port flows. Only carrier-based equipment.
|
||||
• Recipes — S7 process-program transfer. Only recipe-driven equipment.
|
||||
• Jobs — E40 process jobs. Only job-based process/front-end equipment.
|
||||
If a tier doesn't apply, you simply never receive its HostRequest variants and
|
||||
never call its report RPCs.
|
||||
=============================================================================
|
||||
|
||||
---- Universal: report state to the host --------------------------------
|
||||
"""
|
||||
|
||||
def SetVariables(self, request, context):
|
||||
"""Update one or more status/data variables by name. The daemon remembers the
|
||||
values, so the host always sees the latest when it polls (S1F3).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetVariables(self, request, context):
|
||||
"""Read back what the daemon currently holds (useful on tool restart/reconnect).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def FireEvent(self, request, context):
|
||||
"""Fire a collection event by name. The daemon assembles the configured report
|
||||
and sends S6F11. Values in `data` override current values for this one event.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def SetAlarm(self, request, context):
|
||||
"""Raise (S5F1 set) or clear an alarm by name.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def ClearAlarm(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def GetControlState(self, request, context):
|
||||
"""---- Universal: control state -------------------------------------------
|
||||
|
||||
Current GEM control state (ONLINE/LOCAL/REMOTE/OFFLINE).
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def RequestControlState(self, request, context):
|
||||
"""Request a transition — e.g. an operator panel taking the tool OFFLINE for
|
||||
maintenance, or back ONLINE. The daemon applies E30 rules and may decline.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def Subscribe(self, request, context):
|
||||
"""---- Universal: react to the host ---------------------------------------
|
||||
|
||||
Subscribe to everything the host asks of this equipment. The daemon streams
|
||||
HostRequest messages for as long as the call stays open.
|
||||
|
||||
Delivery contract (v1):
|
||||
- firehose: every subscriber receives every host request;
|
||||
- NO buffering: a command arriving while no client is subscribed is
|
||||
answered with its declarative ack from the equipment config (the
|
||||
pre-daemon behaviour) and is NOT replayed on reconnect — the daemon
|
||||
never tells the host "will finish later" for work no tool will do;
|
||||
- when a Command does arrive here, the host has ALREADY been answered
|
||||
with S2F42 HCACK=4; report the real outcome via FireEvent/SetAlarm.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def CompleteCommand(self, request, context):
|
||||
"""Report the outcome of a Command delivered on the stream, quoting its `id`.
|
||||
NOTE the contract (SEMI-conformant, non-blocking): the daemon has ALREADY
|
||||
answered the host with S2F42 HCACK=4 ("accepted, will finish later") when
|
||||
it pushed the command onto the stream — the host's transaction is closed.
|
||||
CompleteCommand therefore correlates/audits the command lifecycle; the
|
||||
host learns the real outcome via the events/alarms you fire (FireEvent /
|
||||
SetAlarm), exactly as E30 intends. A synchronous gating mode (tool decides
|
||||
the HCACK before S2F42 goes out) is a possible v2 extension.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def ReportProcessJob(self, request, context):
|
||||
"""---- Jobs / Carriers: report progress of work the host asked for --------
|
||||
Keyed by the durable id (job_id / carrier_id), not a per-message id — these
|
||||
are long-lived objects you report against as the physical work proceeds.
|
||||
|
||||
E40 — job-based tools
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def ReportCarrier(self, request, context):
|
||||
"""E87 — carrier-based tools
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def WatchHealth(self, request, context):
|
||||
"""---- Diagnostics --------------------------------------------------------
|
||||
|
||||
Live daemon/link status: distinguishes "host went offline" from "cable
|
||||
unplugged" from "spool filling up". Streams a snapshot on every change.
|
||||
"""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_EquipmentServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'SetVariables': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.SetVariables,
|
||||
request_deserializer=equipment__pb2.VariableUpdate.FromString,
|
||||
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||
),
|
||||
'GetVariables': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetVariables,
|
||||
request_deserializer=equipment__pb2.VariableQuery.FromString,
|
||||
response_serializer=equipment__pb2.VariableSnapshot.SerializeToString,
|
||||
),
|
||||
'FireEvent': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.FireEvent,
|
||||
request_deserializer=equipment__pb2.Event.FromString,
|
||||
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||
),
|
||||
'SetAlarm': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.SetAlarm,
|
||||
request_deserializer=equipment__pb2.Alarm.FromString,
|
||||
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||
),
|
||||
'ClearAlarm': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ClearAlarm,
|
||||
request_deserializer=equipment__pb2.Alarm.FromString,
|
||||
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||
),
|
||||
'GetControlState': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.GetControlState,
|
||||
request_deserializer=equipment__pb2.Empty.FromString,
|
||||
response_serializer=equipment__pb2.ControlState.SerializeToString,
|
||||
),
|
||||
'RequestControlState': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.RequestControlState,
|
||||
request_deserializer=equipment__pb2.ControlStateRequest.FromString,
|
||||
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||
),
|
||||
'Subscribe': grpc.unary_stream_rpc_method_handler(
|
||||
servicer.Subscribe,
|
||||
request_deserializer=equipment__pb2.SubscribeRequest.FromString,
|
||||
response_serializer=equipment__pb2.HostRequest.SerializeToString,
|
||||
),
|
||||
'CompleteCommand': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.CompleteCommand,
|
||||
request_deserializer=equipment__pb2.CommandResult.FromString,
|
||||
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||
),
|
||||
'ReportProcessJob': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ReportProcessJob,
|
||||
request_deserializer=equipment__pb2.ProcessJobState.FromString,
|
||||
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||
),
|
||||
'ReportCarrier': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.ReportCarrier,
|
||||
request_deserializer=equipment__pb2.CarrierState.FromString,
|
||||
response_serializer=equipment__pb2.Ack.SerializeToString,
|
||||
),
|
||||
'WatchHealth': grpc.unary_stream_rpc_method_handler(
|
||||
servicer.WatchHealth,
|
||||
request_deserializer=equipment__pb2.Empty.FromString,
|
||||
response_serializer=equipment__pb2.Health.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'secsgem.v1.Equipment', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class Equipment(object):
|
||||
"""=============================================================================
|
||||
SECS/GEM Equipment API
|
||||
|
||||
The daemon *is* one piece of SEMI equipment: it owns the HSMS link to the
|
||||
host (MES) and speaks GEM on your behalf. Your tool software connects as a
|
||||
client and only ever does two things:
|
||||
|
||||
• tell the equipment about itself — set variables, fire events, raise alarms
|
||||
• react to what the host asks for — receive commands/jobs, answer them
|
||||
|
||||
Everything SECS lives inside the daemon: message framing, report definitions,
|
||||
the GEM state machines, timers, spooling. You need no SEMI knowledge to use
|
||||
this API. Items are addressed by the human names from your equipment config
|
||||
(e.g. "chamber_pressure"), never by numeric SVID / CEID / ALID.
|
||||
|
||||
CAPABILITY TIERS — wire up only what your equipment is:
|
||||
• Universal — variables, events, alarms, control state, commands. Every tool.
|
||||
• Carriers — E87 carrier/load-port flows. Only carrier-based equipment.
|
||||
• Recipes — S7 process-program transfer. Only recipe-driven equipment.
|
||||
• Jobs — E40 process jobs. Only job-based process/front-end equipment.
|
||||
If a tier doesn't apply, you simply never receive its HostRequest variants and
|
||||
never call its report RPCs.
|
||||
=============================================================================
|
||||
|
||||
---- Universal: report state to the host --------------------------------
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def SetVariables(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/SetVariables',
|
||||
equipment__pb2.VariableUpdate.SerializeToString,
|
||||
equipment__pb2.Ack.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def GetVariables(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/GetVariables',
|
||||
equipment__pb2.VariableQuery.SerializeToString,
|
||||
equipment__pb2.VariableSnapshot.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def FireEvent(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/FireEvent',
|
||||
equipment__pb2.Event.SerializeToString,
|
||||
equipment__pb2.Ack.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def SetAlarm(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/SetAlarm',
|
||||
equipment__pb2.Alarm.SerializeToString,
|
||||
equipment__pb2.Ack.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def ClearAlarm(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/ClearAlarm',
|
||||
equipment__pb2.Alarm.SerializeToString,
|
||||
equipment__pb2.Ack.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def GetControlState(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/GetControlState',
|
||||
equipment__pb2.Empty.SerializeToString,
|
||||
equipment__pb2.ControlState.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def RequestControlState(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/RequestControlState',
|
||||
equipment__pb2.ControlStateRequest.SerializeToString,
|
||||
equipment__pb2.Ack.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def Subscribe(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_stream(request, target, '/secsgem.v1.Equipment/Subscribe',
|
||||
equipment__pb2.SubscribeRequest.SerializeToString,
|
||||
equipment__pb2.HostRequest.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def CompleteCommand(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/CompleteCommand',
|
||||
equipment__pb2.CommandResult.SerializeToString,
|
||||
equipment__pb2.Ack.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def ReportProcessJob(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/ReportProcessJob',
|
||||
equipment__pb2.ProcessJobState.SerializeToString,
|
||||
equipment__pb2.Ack.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def ReportCarrier(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/ReportCarrier',
|
||||
equipment__pb2.CarrierState.SerializeToString,
|
||||
equipment__pb2.Ack.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def WatchHealth(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_stream(request, target, '/secsgem.v1.Equipment/WatchHealth',
|
||||
equipment__pb2.Empty.SerializeToString,
|
||||
equipment__pb2.Health.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Plain Python values <-> the wire's Value message.
|
||||
|
||||
The daemon owns all SECS-II knowledge (it converts to each variable's
|
||||
declared wire format); this layer only maps Python types onto the Value
|
||||
oneof. Order matters in to_value: bool is checked before int because
|
||||
isinstance(True, int) is True in Python.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ._proto import equipment_pb2 as pb
|
||||
|
||||
|
||||
def to_value(v) -> pb.Value:
|
||||
if isinstance(v, pb.Value):
|
||||
return v
|
||||
if isinstance(v, bool):
|
||||
return pb.Value(boolean=v)
|
||||
if isinstance(v, int):
|
||||
return pb.Value(integer=v)
|
||||
if isinstance(v, float):
|
||||
return pb.Value(real=v)
|
||||
if isinstance(v, str):
|
||||
return pb.Value(text=v)
|
||||
if isinstance(v, (bytes, bytearray)):
|
||||
return pb.Value(binary=bytes(v))
|
||||
if isinstance(v, (list, tuple)):
|
||||
return pb.Value(list=pb.List(items=[to_value(e) for e in v]))
|
||||
raise TypeError(f"cannot convert {type(v).__name__} to a SECS value")
|
||||
|
||||
|
||||
def from_value(v: pb.Value):
|
||||
kind = v.WhichOneof("kind")
|
||||
if kind == "text":
|
||||
return v.text
|
||||
if kind == "integer":
|
||||
return v.integer
|
||||
if kind == "real":
|
||||
return v.real
|
||||
if kind == "boolean":
|
||||
return v.boolean
|
||||
if kind == "binary":
|
||||
return v.binary
|
||||
if kind == "list":
|
||||
return [from_value(e) for e in v.list.items]
|
||||
return None # unset
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Conversion round-trips for the Value layer. Plain asserts — run directly."""
|
||||
import sys
|
||||
|
||||
from secsgem_client._proto import equipment_pb2 as pb
|
||||
from secsgem_client._values import from_value, to_value
|
||||
|
||||
|
||||
def roundtrip(v):
|
||||
return from_value(to_value(v))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
assert roundtrip(2.5) == 2.5
|
||||
assert roundtrip(7) == 7
|
||||
assert roundtrip(-3) == -3
|
||||
assert roundtrip(True) is True # bool BEFORE int: must stay boolean
|
||||
assert roundtrip(False) is False
|
||||
assert to_value(True).WhichOneof("kind") == "boolean"
|
||||
assert to_value(1).WhichOneof("kind") == "integer"
|
||||
assert roundtrip("wafer-17") == "wafer-17"
|
||||
assert roundtrip(b"\x01\x02") == b"\x01\x02"
|
||||
assert roundtrip([1, 2.5, "x", [True]]) == [1, 2.5, "x", [True]]
|
||||
assert from_value(pb.Value()) is None # unset oneof
|
||||
try:
|
||||
to_value(object())
|
||||
raise SystemExit("expected TypeError for unconvertible type")
|
||||
except TypeError:
|
||||
pass
|
||||
print("values: all conversion checks passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+16
-7
@@ -30,7 +30,7 @@ host and stays conformant while the tool software restarts/upgrades/crashes.
|
||||
| Daemon interop vs **secs4j** (Java) | ⬜ | mirror the secsgem-py harness against `interop/secs4j` |
|
||||
| `Subscribe` host→tool command stream + `CompleteCommand` | ✅ | HCACK-4 contract implemented + tested in-process AND live vs secsgem-py (full loop: S2F41 → stream → complete → S6F11) |
|
||||
| Universal RPC surface complete (vars/events/alarms/control-state/health) | ✅ | Phase A done; daemon tests 101 assertions, interop 15 checks |
|
||||
| Python client package (the "beautiful API") | ⬜ | thin wrapper over generated stubs |
|
||||
| Python client package (the "beautiful API") | ✅ | `clients/python` (`secsgem-client`); 13-check interop green via the published API |
|
||||
|
||||
## Known issues (found in the 2026-06-10 audit; honest list)
|
||||
|
||||
@@ -173,12 +173,21 @@ debts tax every later phase, and the most valuable tests aren't automated.
|
||||
7. ⬜ Java interop: `secs4j` host variant of the same scenario.
|
||||
|
||||
### Phase C — the beautiful Python client
|
||||
8. ⬜ `clients/python/` package (`pip install secsgem-client`): wraps generated
|
||||
stubs in the agreed API — `eq.set(chamber_pressure=2.5)`, `eq.fire("wafer_complete", thickness=1.2)`,
|
||||
`eq.alarm("pressure_high")`, `@eq.on("START")` consuming the stream,
|
||||
`eq.health()`. Pure Python (no compiled ext). Ship stubs pre-generated.
|
||||
9. ⬜ Example: rewrite a minimal `pvd_tool`-equivalent in ~40 lines of Python
|
||||
against the daemon; also migrate the C++ `pvd_tool` to `set_handler`.
|
||||
8. ✅ `clients/python/` — pip-installable `secsgem-client`, pure Python,
|
||||
stubs pre-generated (relative-import fixed). The full agreed API:
|
||||
`eq.set(ChamberPressure=2.5)` kwargs + `eq["..."]` item syntax, `eq.get`,
|
||||
`eq.fire(event, **data)`, `eq.alarm`/`eq.clear`, `eq.control_state`,
|
||||
`eq.request_control_state`, `eq.health()`/`watch_health()`, and
|
||||
`@eq.on("START")` + `eq.listen(background=...)` with auto-CompleteCommand.
|
||||
Errors raise `SecsGemError` carrying the daemon's explanation.
|
||||
PROOF: `interop/pyclient_interop.py` drives the PUBLISHED package against
|
||||
a live daemon with secsgem-py as the host — 13 checks all green (S6F11/
|
||||
S5F1 set+clear on the wire, HCACK-4 command loop through the decorator,
|
||||
operator offline). Conversion layer unit-tested (bool-before-int etc).
|
||||
Wired into tools/run_interop.sh as the `pyclient` step.
|
||||
9. 🚧 `clients/python/examples/mini_tool.py` — a complete GEM tool in ~25
|
||||
lines ✅. Migrating the C++ `pvd_tool` to EquipmentRuntime + capability
|
||||
registration + `set_handler` ⬜.
|
||||
|
||||
### Phase D — GEM300 in-the-loop (process/carrier tools)
|
||||
10. ⬜ Settle job/carrier semantics (who acks S16F5/S3F17, gate vs observe —
|
||||
|
||||
@@ -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())
|
||||
@@ -89,6 +89,20 @@ compose run --rm --no-deps interop \
|
||||
record daemon $?
|
||||
compose stop gemd >/dev/null 2>&1
|
||||
|
||||
# ---- Python client package vs secs_gemd --------------------------------------
|
||||
note "pyclient: secsgem_client package + secsgem-py host vs secs_gemd"
|
||||
(
|
||||
set -e
|
||||
compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \
|
||||
python3 /app/clients/python/tests/test_values.py
|
||||
compose up -d --no-deps gemd
|
||||
sleep 2
|
||||
compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \
|
||||
python3 pyclient_interop.py --grpc gemd:50051 --hsms-host gemd
|
||||
)
|
||||
record pyclient $?
|
||||
compose stop gemd >/dev/null 2>&1
|
||||
|
||||
# ---- spool persistence across restart ---------------------------------------
|
||||
note "spool: persistence across server restart"
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user