Files
secs-gem/clients/python/secsgem_client/_client.py
T
raphael d22bbc4ab2 fix(daemon)+fix(client): close four fool-proofing gaps
C++ (equipment_service.hpp):
- ReportSubstrate ARRIVED: check CreateResult and return INVALID_OBJECT
  when the substrate ID already exists, instead of silently doing nothing
- ReportSubstrate/ReportModule default switch branches: return false
  (→ CANNOT_DO_NOW) for unknown enum values instead of silently accepting

Python (_client.py):
- @eq.command: raise NameError (client-side name validation) instead of
  SecsGemError (which means "daemon declined a request") — wrong type
- Module docstring: update example to show @eq.command / eq.names API

Test (test_daemon_service.cpp):
- Add duplicate-ARRIVED check (expects INVALID_OBJECT)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 21:48:29 +02:00

349 lines
14 KiB
Python

"""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(eq.names.event.ProcessStarted) # typo-safe; plain strings also work
@eq.command # function name IS the RCMD name,
def START(cmd): # validated against equipment at import
run_recipe(cmd.params.get("PPID"))
eq.fire(eq.names.event.ProcessStarted)
eq.listen() # blocks, dispatching host commands to your handlers
"""
from __future__ import annotations
import difflib
import threading
from dataclasses import dataclass
from types import SimpleNamespace
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 ProcessJob:
"""An E40 process job the host wants run (or stopped/aborted). Do the
physical work, then report progress with Equipment.report_job()."""
id: str
action: str # "START" | "STOP" | "PAUSE" | "RESUME" | "ABORT"
recipe: str
carriers: list
@dataclass
class Health:
link: str # "DISCONNECTED" | "CONNECTED" | "SELECTED"
control_state: str # "HOST_OFFLINE" | "ONLINE_REMOTE" | ...
spool_depth: int
class _Names:
"""An autocomplete-able, typo-safe view of the equipment's real names
(from Describe). `eq.names.event.ProcessStarted` returns "ProcessStarted";
a wrong name raises AttributeError with close-match suggestions, and the
set shows up in REPL/IDE completion via __dir__."""
def __init__(self, kind: str, names):
self._kind = kind
self._names = set(names)
def __getattr__(self, attr: str) -> str:
if attr in self.__dict__.get("_names", ()):
return attr
close = difflib.get_close_matches(attr, self._names, n=3)
hint = f" Did you mean {', '.join(close)}?" if close else ""
raise AttributeError(
f"no {self._kind} named '{attr}'.{hint}")
def __dir__(self):
return sorted(self._names)
def __contains__(self, name: str) -> bool:
return name in self._names
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._job_handler: Optional[Callable[[ProcessJob], object]] = None
self._recipe_handler: Optional[Callable[[str, bytes], object]] = None
self._constant_handler: Optional[Callable[[str, object], object]] = None
self._listen_thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._names_cache: Optional[SimpleNamespace] = None
# ---- 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()}
@property
def names(self) -> SimpleNamespace:
"""The equipment's real names, by category, fetched once from the
daemon (Describe): eq.names.event.* / .alarm.* / .command.* / .var.* /
.constant.*. Autocomplete-friendly and typo-safe — handy instead of
bare string literals."""
if self._names_cache is None:
d = self._stub.Describe(pb.Empty())
self._names_cache = SimpleNamespace(
var=_Names("variable", d.variables),
event=_Names("event", d.events),
alarm=_Names("alarm", d.alarms),
command=_Names("command", d.commands),
constant=_Names("constant", d.constants))
return self._names_cache
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 command(self, fn: Callable[[Command], object]):
"""Bind a handler to the host command of the SAME NAME as the
function — no string literal:
@eq.command
def START(cmd): ... # binds the "START" RCMD
The function name is validated against the equipment's real command
set (via Describe), so a typo fails loudly at decoration time."""
name = fn.__name__
if name not in self.names.command:
close = difflib.get_close_matches(name, dir(self.names.command), n=3)
hint = f" Did you mean {', '.join(close)}?" if close else ""
raise NameError(f"no host command '{name}' to bind @eq.command to.{hint}")
self._handlers[name] = fn
return fn
def on_process_job(self, fn: Callable[[ProcessJob], object]):
"""Decorator: the host wants a process job run/stopped (E40).
Report progress with report_job()."""
self._job_handler = fn
return fn
def on_recipe(self, fn: Callable[[str, bytes], object]):
"""Decorator: the host downloaded a recipe (ppid, body)."""
self._recipe_handler = fn
return fn
def on_constant_change(self, fn: Callable[[str, object], object]):
"""Decorator: the host changed an equipment constant (name, value)."""
self._constant_handler = fn
return fn
def report_job(self, job_id: str, state: str) -> None:
"""Report physical job progress: "SETTING_UP" | "PROCESSING" |
"COMPLETE" | "ABORTED". The daemon drives the E40 state machine and
notifies the host (S16F9)."""
req = pb.ProcessJobState(
job_id=job_id, state=pb.ProcessJobState.State.Value(state))
_check(self._stub.ReportProcessJob(req))
def report_substrate(self, substrate_id: str, milestone: str,
carrier_id: str = "", slot: int = 0) -> None:
"""E90 wafer tracking. milestone: ARRIVED | AT_WORK | PROCESSING |
PROCESSED | AT_DESTINATION. The daemon drives the E90 FSMs and emits
the standard CEIDs to the host."""
req = pb.SubstrateReport(
substrate_id=substrate_id,
milestone=pb.SubstrateReport.Milestone.Value(milestone),
carrier_id=carrier_id, slot=slot)
_check(self._stub.ReportSubstrate(req))
def report_module(self, module_id: str, state: str) -> None:
"""E157 module tracking. state: NOT_EXECUTING | GENERAL_EXECUTING |
STEP_EXECUTING | STEP_COMPLETED. Auto-creates the module on first
report."""
req = pb.ModuleReport(
module_id=module_id,
state=pb.ModuleReport.State.Value(state))
_check(self._stub.ReportModule(req))
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 hr.HasField("process_job") and self._job_handler:
j = hr.process_job
self._job_handler(ProcessJob(
j.job_id, pb.ProcessJob.Action.Name(j.action),
j.recipe, list(j.carriers)))
continue
if hr.HasField("process_program") and self._recipe_handler:
self._recipe_handler(hr.process_program.ppid,
hr.process_program.body)
continue
if hr.HasField("constant") and self._constant_handler:
self._constant_handler(hr.constant.name,
from_value(hr.constant.value))
continue
if not hr.HasField("command"):
continue # carriers etc.: future variants
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))