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())