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:
2026-06-10 22:57:55 +02:00
parent 912304966f
commit 8686654b15
19 changed files with 1235 additions and 7 deletions
+30
View File
@@ -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.
+31
View File
@@ -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)
+14
View File
@@ -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*"]
+12
View File
@@ -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"
+214
View File
@@ -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))
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)
+46
View File
@@ -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
+35
View File
@@ -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
View File
@@ -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 —
+175
View File
@@ -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())
+14
View File
@@ -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"
(