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` |
|
| 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) |
|
| `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 |
|
| 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)
|
## 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.
|
7. ⬜ Java interop: `secs4j` host variant of the same scenario.
|
||||||
|
|
||||||
### Phase C — the beautiful Python client
|
### Phase C — the beautiful Python client
|
||||||
8. ⬜ `clients/python/` package (`pip install secsgem-client`): wraps generated
|
8. ✅ `clients/python/` — pip-installable `secsgem-client`, pure Python,
|
||||||
stubs in the agreed API — `eq.set(chamber_pressure=2.5)`, `eq.fire("wafer_complete", thickness=1.2)`,
|
stubs pre-generated (relative-import fixed). The full agreed API:
|
||||||
`eq.alarm("pressure_high")`, `@eq.on("START")` consuming the stream,
|
`eq.set(ChamberPressure=2.5)` kwargs + `eq["..."]` item syntax, `eq.get`,
|
||||||
`eq.health()`. Pure Python (no compiled ext). Ship stubs pre-generated.
|
`eq.fire(event, **data)`, `eq.alarm`/`eq.clear`, `eq.control_state`,
|
||||||
9. ⬜ Example: rewrite a minimal `pvd_tool`-equivalent in ~40 lines of Python
|
`eq.request_control_state`, `eq.health()`/`watch_health()`, and
|
||||||
against the daemon; also migrate the C++ `pvd_tool` to `set_handler`.
|
`@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)
|
### Phase D — GEM300 in-the-loop (process/carrier tools)
|
||||||
10. ⬜ Settle job/carrier semantics (who acks S16F5/S3F17, gate vs observe —
|
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 $?
|
record daemon $?
|
||||||
compose stop gemd >/dev/null 2>&1
|
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 ---------------------------------------
|
# ---- spool persistence across restart ---------------------------------------
|
||||||
note "spool: persistence across server restart"
|
note "spool: persistence across server restart"
|
||||||
(
|
(
|
||||||
|
|||||||
Reference in New Issue
Block a user