feat(client)+feat(daemon): eq.names, @eq.command, E90/E157 RPCs

Python client:
- eq.names.event.* / .alarm.* / .command.* / .var.* / .constant.*  —
  autocomplete-able, typo-safe name lookup backed by the Describe RPC
  (lazy, cached; AttributeError on bad name with close-match hints)
- @eq.command decorator — binds a handler by function name, validated
  against the equipment's real command set at decoration time
- eq.report_substrate() — E90 wafer milestone reporting
- eq.report_module() — E157 module state reporting (auto-create)

Daemon (C++ service):
- ReportSubstrate RPC — drives E90 location + processing FSMs
- ReportModule RPC — drives E157 module FSM (auto-create on first report)
- ack_from_outcome() helper — consistent Ack mapping for read_sync results

Proto: SubstrateReport, ModuleReport, EquipmentDescription,
       SpoolFlushRequest, TerminalMessage; Describe, FlushSpool,
       SendTerminalMessage RPCs

Tests: C++ FSM test (journey + ghost rejection + E157 illegal jump);
       interop coverage for names API and E90/E157 round-trip

Docs: ch42 RPC table + Python example updated; ch16 daemon-path section added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 21:43:07 +02:00
parent 9876dd9b5a
commit a2ebbf7c65
14 changed files with 602 additions and 27 deletions
+11 -5
View File
@@ -39,16 +39,22 @@ Three ways in, same engine underneath:
from secsgem_client import Equipment from secsgem_client import Equipment
eq = Equipment("localhost:50051") eq = Equipment("localhost:50051")
eq.set(ChamberPressure=2.5) # host sees it on its next poll eq.set(ChamberPressure=2.5) # variables: kwargs, not strings
eq.fire("ProcessStarted") # S6F11, report auto-assembled
@eq.on("START") # host remote commands -> your function @eq.command # the function name IS the command,
def start(cmd): def START(cmd): # validated against the real equipment
run_recipe(cmd.params.get("PPID")) run_recipe(cmd.params.get("PPID")) # — so a typo fails at startup
eq.fire(eq.names.event.ProcessStarted) # autocomplete + typo-safe
eq.listen() eq.listen()
``` ```
Names come from *your* `equipment.yaml`. `@eq.command` binds a handler by
its function name; `eq.names.event.*` / `.alarm.*` / `.command.*` are
autocomplete-able, typo-checked views fetched from the live daemon — so
you rarely type a bare string. (The plain forms — `@eq.on("START")`,
`eq.fire("ProcessStarted")` — still work.)
A complete tool is ~25 lines: [clients/python/examples/mini_tool.py](clients/python/examples/mini_tool.py). A complete tool is ~25 lines: [clients/python/examples/mini_tool.py](clients/python/examples/mini_tool.py).
2. **Any language over gRPC** — `secs_gemd` exposes the name-based API in 2. **Any language over gRPC** — `secs_gemd` exposes the name-based API in
+6 -5
View File
@@ -13,10 +13,10 @@ from secsgem_client import Equipment
eq = Equipment("localhost:50051") eq = Equipment("localhost:50051")
@eq.on("START") @eq.command
def start(cmd): def START(cmd):
print("host says START", cmd.params) print("host says START", cmd.params)
eq.fire("ProcessStarted") eq.fire(eq.names.event.ProcessStarted)
eq.listen(background=True) eq.listen(background=True)
@@ -25,7 +25,8 @@ while True:
pressure = round(random.uniform(1.0, 3.0), 3) pressure = round(random.uniform(1.0, 3.0), 3)
eq.set(ChamberPressure=pressure) eq.set(ChamberPressure=pressure)
if pressure > 2.9: if pressure > 2.9:
eq.alarm("chiller_temp_high") eq.alarm(eq.names.alarm.chiller_temp_high)
else: else:
eq.clear("chiller_temp_high") eq.clear(eq.names.alarm.chiller_temp_high)
time.sleep(1) time.sleep(1)
+83
View File
@@ -21,8 +21,10 @@ equipment.yaml; values are plain Python.
from __future__ import annotations from __future__ import annotations
import difflib
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
from types import SimpleNamespace
from typing import Callable, Dict, Iterator, Optional from typing import Callable, Dict, Iterator, Optional
import grpc import grpc
@@ -81,6 +83,31 @@ class Health:
spool_depth: int 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: class Equipment:
"""A connection to the secs_gemd daemon — your equipment, by name.""" """A connection to the secs_gemd daemon — your equipment, by name."""
@@ -95,6 +122,7 @@ class Equipment:
self._constant_handler: Optional[Callable[[str, object], object]] = None self._constant_handler: Optional[Callable[[str, object], object]] = None
self._listen_thread: Optional[threading.Thread] = None self._listen_thread: Optional[threading.Thread] = None
self._stop = threading.Event() self._stop = threading.Event()
self._names_cache: Optional[SimpleNamespace] = None
# ---- report state to the host ------------------------------------------- # ---- report state to the host -------------------------------------------
@@ -116,6 +144,22 @@ class Equipment:
raise SecsGemError(pb.Ack.PARAMETER_INVALID, e.details()) from None raise SecsGemError(pb.Ack.PARAMETER_INVALID, e.details()) from None
return {k: from_value(v) for k, v in snap.values.items()} 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: def __setitem__(self, name: str, value) -> None:
self.set({name: value}) self.set({name: value})
@@ -181,6 +225,25 @@ class Equipment:
return register 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 SecsGemError(
pb.Ack.PARAMETER_INVALID,
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]): def on_process_job(self, fn: Callable[[ProcessJob], object]):
"""Decorator: the host wants a process job run/stopped (E40). """Decorator: the host wants a process job run/stopped (E40).
Report progress with report_job().""" Report progress with report_job()."""
@@ -205,6 +268,26 @@ class Equipment:
job_id=job_id, state=pb.ProcessJobState.State.Value(state)) job_id=job_id, state=pb.ProcessJobState.State.Value(state))
_check(self._stub.ReportProcessJob(req)) _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: def listen(self, background: bool = False) -> None:
"""Consume host requests and dispatch them to @eq.on handlers. """Consume host requests and dispatch them to @eq.on handlers.
Blocks; pass background=True to run on a daemon thread instead.""" Blocks; pass background=True to run on a daemon thread instead."""
File diff suppressed because one or more lines are too long
@@ -94,11 +94,36 @@ class EquipmentStub(object):
request_serializer=equipment__pb2.CarrierState.SerializeToString, request_serializer=equipment__pb2.CarrierState.SerializeToString,
response_deserializer=equipment__pb2.Ack.FromString, response_deserializer=equipment__pb2.Ack.FromString,
) )
self.ReportSubstrate = channel.unary_unary(
'/secsgem.v1.Equipment/ReportSubstrate',
request_serializer=equipment__pb2.SubstrateReport.SerializeToString,
response_deserializer=equipment__pb2.Ack.FromString,
)
self.ReportModule = channel.unary_unary(
'/secsgem.v1.Equipment/ReportModule',
request_serializer=equipment__pb2.ModuleReport.SerializeToString,
response_deserializer=equipment__pb2.Ack.FromString,
)
self.WatchHealth = channel.unary_stream( self.WatchHealth = channel.unary_stream(
'/secsgem.v1.Equipment/WatchHealth', '/secsgem.v1.Equipment/WatchHealth',
request_serializer=equipment__pb2.Empty.SerializeToString, request_serializer=equipment__pb2.Empty.SerializeToString,
response_deserializer=equipment__pb2.Health.FromString, response_deserializer=equipment__pb2.Health.FromString,
) )
self.Describe = channel.unary_unary(
'/secsgem.v1.Equipment/Describe',
request_serializer=equipment__pb2.Empty.SerializeToString,
response_deserializer=equipment__pb2.EquipmentDescription.FromString,
)
self.FlushSpool = channel.unary_unary(
'/secsgem.v1.Equipment/FlushSpool',
request_serializer=equipment__pb2.SpoolFlushRequest.SerializeToString,
response_deserializer=equipment__pb2.Ack.FromString,
)
self.SendTerminalMessage = channel.unary_unary(
'/secsgem.v1.Equipment/SendTerminalMessage',
request_serializer=equipment__pb2.TerminalMessage.SerializeToString,
response_deserializer=equipment__pb2.Ack.FromString,
)
class EquipmentServicer(object): class EquipmentServicer(object):
@@ -227,7 +252,30 @@ class EquipmentServicer(object):
raise NotImplementedError('Method not implemented!') raise NotImplementedError('Method not implemented!')
def ReportCarrier(self, request, context): def ReportCarrier(self, request, context):
"""E87 — carrier-based tools """E87 — carrier-based tools. WAITING announces a physically-arrived
carrier (creates it; idempotent — re-announce updates the slot map);
IN_ACCESS / COMPLETE drive the access FSM. The host's S3F17 decisions
(ProceedWithCarrier / CancelCarrier) come back on the Subscribe stream
as CarrierAction.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ReportSubstrate(self, request, context):
"""E90 — substrate (wafer) tracking. Report each milestone of a wafer's
journey; the daemon drives the E90 FSMs and emits the standard CEIDs to
the host. ARRIVED creates the substrate. Only for tools that track
individual substrates.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ReportModule(self, request, context):
"""E157 — module process tracking. Report a module's execution state; the
daemon drives the E157 FSM and emits the standard CEIDs. The module is
auto-created on first report. Only for tools with tracked modules.
""" """
context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!') context.set_details('Method not implemented!')
@@ -243,6 +291,30 @@ class EquipmentServicer(object):
context.set_details('Method not implemented!') context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!') raise NotImplementedError('Method not implemented!')
def Describe(self, request, context):
"""Everything this equipment is configured with, by name — for tooling,
diagnostics, and client-side validation/autocomplete.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def FlushSpool(self, request, context):
"""Flush the spool: purge=true discards queued messages, purge=false drains
them toward the host (requires a SELECTED session).
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def SendTerminalMessage(self, request, context):
"""Equipment-initiated operator message to the host (S10F1). Fails with
CANNOT_DO_NOW when no host is connected and stream 10 isn't spoolable.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_EquipmentServicer_to_server(servicer, server): def add_EquipmentServicer_to_server(servicer, server):
rpc_method_handlers = { rpc_method_handlers = {
@@ -301,11 +373,36 @@ def add_EquipmentServicer_to_server(servicer, server):
request_deserializer=equipment__pb2.CarrierState.FromString, request_deserializer=equipment__pb2.CarrierState.FromString,
response_serializer=equipment__pb2.Ack.SerializeToString, response_serializer=equipment__pb2.Ack.SerializeToString,
), ),
'ReportSubstrate': grpc.unary_unary_rpc_method_handler(
servicer.ReportSubstrate,
request_deserializer=equipment__pb2.SubstrateReport.FromString,
response_serializer=equipment__pb2.Ack.SerializeToString,
),
'ReportModule': grpc.unary_unary_rpc_method_handler(
servicer.ReportModule,
request_deserializer=equipment__pb2.ModuleReport.FromString,
response_serializer=equipment__pb2.Ack.SerializeToString,
),
'WatchHealth': grpc.unary_stream_rpc_method_handler( 'WatchHealth': grpc.unary_stream_rpc_method_handler(
servicer.WatchHealth, servicer.WatchHealth,
request_deserializer=equipment__pb2.Empty.FromString, request_deserializer=equipment__pb2.Empty.FromString,
response_serializer=equipment__pb2.Health.SerializeToString, response_serializer=equipment__pb2.Health.SerializeToString,
), ),
'Describe': grpc.unary_unary_rpc_method_handler(
servicer.Describe,
request_deserializer=equipment__pb2.Empty.FromString,
response_serializer=equipment__pb2.EquipmentDescription.SerializeToString,
),
'FlushSpool': grpc.unary_unary_rpc_method_handler(
servicer.FlushSpool,
request_deserializer=equipment__pb2.SpoolFlushRequest.FromString,
response_serializer=equipment__pb2.Ack.SerializeToString,
),
'SendTerminalMessage': grpc.unary_unary_rpc_method_handler(
servicer.SendTerminalMessage,
request_deserializer=equipment__pb2.TerminalMessage.FromString,
response_serializer=equipment__pb2.Ack.SerializeToString,
),
} }
generic_handler = grpc.method_handlers_generic_handler( generic_handler = grpc.method_handlers_generic_handler(
'secsgem.v1.Equipment', rpc_method_handlers) 'secsgem.v1.Equipment', rpc_method_handlers)
@@ -528,6 +625,40 @@ class Equipment(object):
options, channel_credentials, options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata) insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def ReportSubstrate(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/ReportSubstrate',
equipment__pb2.SubstrateReport.SerializeToString,
equipment__pb2.Ack.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def ReportModule(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/ReportModule',
equipment__pb2.ModuleReport.SerializeToString,
equipment__pb2.Ack.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod @staticmethod
def WatchHealth(request, def WatchHealth(request,
target, target,
@@ -544,3 +675,54 @@ class Equipment(object):
equipment__pb2.Health.FromString, equipment__pb2.Health.FromString,
options, channel_credentials, options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata) insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def Describe(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/Describe',
equipment__pb2.Empty.SerializeToString,
equipment__pb2.EquipmentDescription.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def FlushSpool(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/FlushSpool',
equipment__pb2.SpoolFlushRequest.SerializeToString,
equipment__pb2.Ack.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def SendTerminalMessage(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/SendTerminalMessage',
equipment__pb2.TerminalMessage.SerializeToString,
equipment__pb2.Ack.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+31
View File
@@ -201,6 +201,37 @@ wafer was at every moment.
--- ---
## Daemon path (Python client)
If your tool uses the daemon (`secs_gemd`) and the Python client, the
E90 and E157 RPCs are wrapped as two methods:
```python
from secsgem_client import Equipment
eq = Equipment("localhost:50051")
# E90 — substrate journey (daemon drives FSMs, fires CEIDs automatically)
eq.report_substrate("WFR-001", "ARRIVED", carrier_id="FOUP-7", slot=3)
eq.report_substrate("WFR-001", "AT_WORK")
eq.report_substrate("WFR-001", "PROCESSING")
eq.report_substrate("WFR-001", "PROCESSED")
eq.report_substrate("WFR-001", "AT_DESTINATION")
# E157 — module state (module is auto-created on first report)
eq.report_module("CHAMBER-A", "GENERAL_EXECUTING")
eq.report_module("CHAMBER-A", "STEP_EXECUTING")
eq.report_module("CHAMBER-A", "STEP_COMPLETED")
eq.report_module("CHAMBER-A", "NOT_EXECUTING")
```
Milestones map to the `SubstrateReport.Milestone` protobuf enum;
module states to `ModuleReport.State`. The daemon's `ReportSubstrate`
handler validates FSM transitions and returns `INVALID_OBJECT` if the
substrate was never `ARRIVED` (which guarantees the daemon owns the
substrate record).
---
## Where to go next ## Where to go next
You now know how every component of in-flight material is You now know how every component of in-flight material is
+55 -9
View File
@@ -31,7 +31,7 @@ drops; the daemon model covers the gap if *your software* drops.
tool software (any language) secs_gemd fab host / MES tool software (any language) secs_gemd fab host / MES
┌──────────────────────────┐ gRPC ┌──────────────────────────┐ HSMS ┌────────┐ ┌──────────────────────────┐ gRPC ┌──────────────────────────┐ HSMS ┌────────┐
│ set / fire / alarm │◄─────►│ EquipmentRuntime │◄────►│ MES │ │ set / fire / alarm │◄─────►│ EquipmentRuntime │◄────►│ MES │
│ @on("START") handlers │ :50051│ + register_default_* │SECS-II└────────┘ │ @command / @on handlers │ :50051│ + register_default_* │SECS-II└────────┘
│ (restartable, crashable) │ │ + spool, timers, FSMs │ │ (restartable, crashable) │ │ + spool, timers, FSMs │
└──────────────────────────┘ └──────────────────────────┘ └──────────────────────────┘ └──────────────────────────┘
``` ```
@@ -63,9 +63,15 @@ stays F4 on the wire no matter what you send).
| `FireEvent` | trigger a collection event; daemon assembles the configured report → S6F11 | | `FireEvent` | trigger a collection event; daemon assembles the configured report → S6F11 |
| `SetAlarm` / `ClearAlarm` | S5F1 set/clear, by alarm `name:` (or stringified ALID) | | `SetAlarm` / `ClearAlarm` | S5F1 set/clear, by alarm `name:` (or stringified ALID) |
| `GetControlState` / `RequestControlState` | read the E30 control state / operator transitions | | `GetControlState` / `RequestControlState` | read the E30 control state / operator transitions |
| `ReportCarrier` | E87 carrier state transitions (WAITING / IN_ACCESS / COMPLETE) |
| `ReportSubstrate` | E90 wafer tracking (ARRIVED / AT_WORK / PROCESSING / PROCESSED / AT_DESTINATION) |
| `ReportModule` | E157 module tracking (NOT_EXECUTING / GENERAL_EXECUTING / STEP_EXECUTING / STEP_COMPLETED) |
| `WatchHealth` | server stream: link state, control state, spool depth | | `WatchHealth` | server stream: link state, control state, spool depth |
| `Subscribe` | server stream: everything the host asks of the tool | | `Subscribe` | server stream: everything the host asks of the tool |
| `CompleteCommand` | close a streamed command's audit entry | | `CompleteCommand` | close a streamed command's audit entry |
| `Describe` | all names this equipment exposes (variables, events, alarms, commands, constants) |
| `FlushSpool` | drain or discard spooled messages |
| `SendTerminalMessage` | S10F1 operator message to the host |
### The HCACK-4 command contract ### The HCACK-4 command contract
@@ -102,28 +108,68 @@ eq.set(ChamberPressure=2.5) # host sees it on its next S1F3
eq["WaferCounter"] = 7 # item syntax, same thing eq["WaferCounter"] = 7 # item syntax, same thing
print(eq.get("ChamberPressure")) # read back through the daemon print(eq.get("ChamberPressure")) # read back through the daemon
eq.fire("ProcessStarted", ChamberPressure=2.75) # values, then S6F11 # eq.names — autocomplete-able, typo-safe name lookup (fetched from Describe)
eq.alarm("chiller_temp_high") # S5F1 set eq.fire(eq.names.event.ProcessStarted) # typo → AttributeError at the line it happened
eq.clear("chiller_temp_high") # S5F1 clear eq.alarm(eq.names.alarm.chiller_temp_high)
eq.clear(eq.names.alarm.chiller_temp_high)
# Plain strings still work; names are a convenience, not a requirement.
eq.fire("ProcessStarted", ChamberPressure=2.75)
@eq.on("START") # host S2F41 -> your function @eq.command # function name IS the command name;
def start(cmd): def START(cmd): # validated against Describe at decoration time
run_recipe(cmd.params.get("PPID")) run_recipe(cmd.params.get("PPID"))
eq.fire("ProcessStarted") # the host's completion signal eq.fire(eq.names.event.ProcessStarted)
# @eq.on("NAME") still works — use it when the name can't be a Python identifier
# or when you prefer explicit strings.
eq.listen(background=True) # consume the Subscribe stream eq.listen(background=True) # consume the Subscribe stream
eq.control_state # "ONLINE_REMOTE" eq.control_state # "ONLINE_REMOTE"
eq.request_control_state("HOST_OFFLINE") # operator panel -> maintenance eq.request_control_state("HOST_OFFLINE") # operator panel -> maintenance
eq.health() # link / control state / spool depth eq.health() # link / control state / spool depth
# E90 / E157 material tracking
eq.report_substrate("WFR-001", "ARRIVED", carrier_id="FOUP-7", slot=3)
eq.report_substrate("WFR-001", "AT_WORK")
eq.report_substrate("WFR-001", "PROCESSING")
eq.report_substrate("WFR-001", "PROCESSED")
eq.report_substrate("WFR-001", "AT_DESTINATION")
eq.report_module("CHAMBER-A", "GENERAL_EXECUTING")
eq.report_module("CHAMBER-A", "STEP_EXECUTING")
eq.report_module("CHAMBER-A", "STEP_COMPLETED")
eq.report_module("CHAMBER-A", "NOT_EXECUTING")
``` ```
Anything the daemon declines raises `SecsGemError` with its explanation Anything the daemon declines raises `SecsGemError` with its explanation
(`no variable named 'ChamberPresure'`). A complete runnable tool is (`no variable named 'ChamberPresure'`). A complete runnable tool is
[clients/python/examples/mini_tool.py](../clients/python/examples/mini_tool.py) [clients/python/examples/mini_tool.py](../clients/python/examples/mini_tool.py)
(~25 lines). The package is validated end-to-end by (~25 lines). The package is validated end-to-end by
`interop/pyclient_interop.py`: 13 checks driving the published API while `interop/pyclient_interop.py` driving the published API while secsgem-py
secsgem-py judges the wire. judges the wire.
### `eq.names` — name autocomplete and typo safety
`eq.names` fetches `Describe` from the daemon once (lazy, cached), then
exposes five sub-namespaces:
```python
eq.names.event.ProcessStarted # → "ProcessStarted"
eq.names.alarm.chiller_temp_high # → "chiller_temp_high"
eq.names.command.START # → "START"
eq.names.var.ChamberPressure # → "ChamberPressure"
eq.names.constant.MaxPressure # → "MaxPressure"
# Typo → AttributeError with suggestions:
# AttributeError: no event named 'ProcessStated'. Did you mean ProcessStarted?
# Membership test — useful in @eq.on guards:
"START" in eq.names.command # → True
```
`dir(eq.names.event)` lists all event names — REPL and IDE autocomplete
work out of the box.
Other languages: generate stubs from the proto (`protoc` supports 11+ Other languages: generate stubs from the proto (`protoc` supports 11+
languages) and wrap them the same way — the Python client is ~200 lines languages) and wrap them the same way — the Python client is ~200 lines
@@ -527,6 +527,70 @@ class EquipmentService final : public pb::Equipment::Service {
return grpc::Status::OK; return grpc::Status::OK;
} }
grpc::Status ReportSubstrate(grpc::ServerContext*, const pb::SubstrateReport* req,
pb::Ack* resp) override {
const std::string sid = req->substrate_id();
const std::string cid = req->carrier_id();
const auto slot = static_cast<uint8_t>(req->slot());
const auto m = req->milestone();
auto outcome = rt_.read_sync([this, sid, cid, slot, m]() -> std::optional<bool> {
auto& subs = rt_.model().substrates;
if (m == pb::SubstrateReport::ARRIVED) {
subs.create(sid, cid, slot); // AtSource / NeedsProcessing
return true;
}
if (!subs.has(sid)) return std::nullopt;
switch (m) {
case pb::SubstrateReport::AT_WORK:
return subs.fire_location_event(sid, gem::SubstrateEvent::Acquire, "");
case pb::SubstrateReport::AT_DESTINATION:
return subs.fire_location_event(sid, gem::SubstrateEvent::Release, "");
case pb::SubstrateReport::PROCESSING:
return subs.fire_processing_event(sid, gem::SubstrateProcessingEvent::StartProcessing);
case pb::SubstrateReport::PROCESSED:
return subs.fire_processing_event(sid, gem::SubstrateProcessingEvent::EndProcessing);
default:
return true;
}
});
ack_from_outcome(outcome, resp, "substrate '" + sid + "'",
"E90 table rejects that transition");
return grpc::Status::OK;
}
grpc::Status ReportModule(grpc::ServerContext*, const pb::ModuleReport* req,
pb::Ack* resp) override {
const std::string mid = req->module_id();
const auto st = req->state();
auto outcome = rt_.read_sync([this, mid, st]() -> bool {
auto& mods = rt_.model().modules;
if (!mods.has(mid)) mods.create(mid); // auto-create; starts NotExecuting
switch (st) {
case pb::ModuleReport::GENERAL_EXECUTING:
return mods.fire(mid, gem::ModuleEvent::StartGeneral);
case pb::ModuleReport::STEP_EXECUTING:
return mods.fire(mid, gem::ModuleEvent::StartStep);
case pb::ModuleReport::STEP_COMPLETED:
return mods.fire(mid, gem::ModuleEvent::CompleteStep);
case pb::ModuleReport::NOT_EXECUTING:
return mods.fire(mid, gem::ModuleEvent::Reset);
default:
return true;
}
});
// Module auto-creates, so "not found" can't happen; only the FSM verdict.
if (!outcome) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("engine io thread did not answer");
} else if (!*outcome) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("E157 table rejects that transition from the current state");
} else {
resp->set_code(pb::Ack::ACCEPT);
}
return grpc::Status::OK;
}
grpc::Status Describe(grpc::ServerContext*, const pb::Empty*, grpc::Status Describe(grpc::ServerContext*, const pb::Empty*,
pb::EquipmentDescription* resp) override { pb::EquipmentDescription* resp) override {
resp->set_model_name(descr_model_); resp->set_model_name(descr_model_);
@@ -653,6 +717,26 @@ class EquipmentService final : public pb::Equipment::Service {
} }
private: private:
// Map a read_sync(std::optional<bool>) result to an Ack: outer nullopt =
// io thread didn't answer; inner nullopt = object not found; false = FSM
// rejected; true = accepted.
void ack_from_outcome(const std::optional<std::optional<bool>>& outcome,
pb::Ack* resp, const std::string& what,
const std::string& reject_msg) {
if (!outcome) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message("engine io thread did not answer (not running?)");
} else if (!*outcome) {
resp->set_code(pb::Ack::INVALID_OBJECT);
resp->set_message("no " + what);
} else if (!**outcome) {
resp->set_code(pb::Ack::CANNOT_DO_NOW);
resp->set_message(reject_msg);
} else {
resp->set_code(pb::Ack::ACCEPT);
}
}
// Fan a HostRequest out to every subscriber (fire-and-forget // Fan a HostRequest out to every subscriber (fire-and-forget
// notifications: jobs, recipes, EC changes). No subscriber = dropped, // notifications: jobs, recipes, EC changes). No subscriber = dropped,
// matching the no-buffering contract. // matching the no-buffering contract.
+22
View File
@@ -144,6 +144,28 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int) -> int:
check("handler's eq.fire reached the host (completion signal)", check("handler's eq.fire reached the host (completion signal)",
ceid300.wait(timeout=10)) ceid300.wait(timeout=10))
# ---- Describe-backed names + @eq.command binding ----
check("eq.names.event has ProcessStarted",
"ProcessStarted" in eq.names.event)
check("eq.names.command has START", "START" in eq.names.command)
try:
_ = eq.names.event.NoSuchEvent
check("typo on eq.names raises", False)
except AttributeError:
check("typo on eq.names raises", True)
eq.fire(eq.names.event.ProcessStarted)
check("eq.fire(eq.names.event.*) accepted", True)
# ---- E90/E157 material tracking ----
eq.report_substrate("WFR-PY-1", "ARRIVED")
eq.report_substrate("WFR-PY-1", "AT_WORK")
eq.report_substrate("WFR-PY-1", "PROCESSING")
eq.report_substrate("WFR-PY-1", "PROCESSED")
check("report_substrate journey accepted", True)
eq.report_module("MOD-PY-1", "GENERAL_EXECUTING")
eq.report_module("MOD-PY-1", "STEP_EXECUTING")
check("report_module accepted", True)
# ---- operator offline via the client ---- # ---- operator offline via the client ----
eq.request_control_state("HOST_OFFLINE") eq.request_control_state("HOST_OFFLINE")
check("request_control_state(HOST_OFFLINE)", check("request_control_state(HOST_OFFLINE)",
+38
View File
@@ -91,6 +91,17 @@ service Equipment {
// as CarrierAction. // as CarrierAction.
rpc ReportCarrier(CarrierState) returns (Ack); rpc ReportCarrier(CarrierState) returns (Ack);
// E90 — substrate (wafer) tracking. Report each milestone of a wafer's
// journey; the daemon drives the E90 FSMs and emits the standard CEIDs to
// the host. ARRIVED creates the substrate. Only for tools that track
// individual substrates.
rpc ReportSubstrate(SubstrateReport) returns (Ack);
// E157 — module process tracking. Report a module's execution state; the
// daemon drives the E157 FSM and emits the standard CEIDs. The module is
// auto-created on first report. Only for tools with tracked modules.
rpc ReportModule(ModuleReport) returns (Ack);
// ---- Diagnostics -------------------------------------------------------- // ---- Diagnostics --------------------------------------------------------
// Live daemon/link status: distinguishes "host went offline" from "cable // Live daemon/link status: distinguishes "host went offline" from "cable
@@ -278,6 +289,33 @@ message Health {
} }
} }
// ---- Material tracking (E90 / E157) ----------------------------------------
message SubstrateReport {
string substrate_id = 1;
Milestone milestone = 2;
string carrier_id = 3; // optional; recorded on ARRIVED
uint32 slot = 4; // optional; 1-based slot within the carrier
enum Milestone {
ARRIVED = 0; // create; AtSource / NeedsProcessing
AT_WORK = 1; // picked up for processing
PROCESSING = 2; // processing started
PROCESSED = 3; // processing finished
AT_DESTINATION = 4; // returned / deposited
}
}
message ModuleReport {
string module_id = 1;
State state = 2;
enum State {
NOT_EXECUTING = 0;
GENERAL_EXECUTING = 1;
STEP_EXECUTING = 2;
STEP_COMPLETED = 3;
}
}
// ---- Diagnostics & operations ----------------------------------------------- // ---- Diagnostics & operations -----------------------------------------------
message EquipmentDescription { message EquipmentDescription {
+68
View File
@@ -735,3 +735,71 @@ TEST_CASE("randomized concurrent RPC stress (seeded)") {
server->Shutdown(); server->Shutdown();
rt.stop(); rt.stop();
} }
// Phase 16 tail: E90 substrate + E157 module reporting (observe-and-report).
TEST_CASE("ReportSubstrate (E90) and ReportModule (E157) drive the FSMs") {
gem::EquipmentRuntime rt(test_config());
gem::register_default_handlers(rt);
dmn::EquipmentService svc(rt);
rt.run_async();
grpc::ServerBuilder builder;
builder.RegisterService(&svc);
std::unique_ptr<grpc::Server> server(builder.BuildAndStart());
REQUIRE(server);
auto stub = pb::Equipment::NewStub(server->InProcessChannel(grpc::ChannelArguments{}));
auto sub = [&](const std::string& sid, pb::SubstrateReport::Milestone m) {
grpc::ClientContext ctx;
pb::SubstrateReport req;
pb::Ack ack;
req.set_substrate_id(sid);
req.set_milestone(m);
REQUIRE(stub->ReportSubstrate(&ctx, req, &ack).ok());
return ack.code();
};
// A wafer's journey: arrive -> picked up -> process -> done -> deposited.
CHECK(sub("WFR-1", pb::SubstrateReport::ARRIVED) == pb::Ack::ACCEPT);
CHECK(sub("WFR-1", pb::SubstrateReport::AT_WORK) == pb::Ack::ACCEPT);
CHECK(sub("WFR-1", pb::SubstrateReport::PROCESSING) == pb::Ack::ACCEPT);
CHECK(sub("WFR-1", pb::SubstrateReport::PROCESSED) == pb::Ack::ACCEPT);
CHECK(sub("WFR-1", pb::SubstrateReport::AT_DESTINATION) == pb::Ack::ACCEPT);
auto loc = rt.read_sync([&rt]() {
const auto* s = rt.model().substrates.get("WFR-1");
return s ? s->fsm->location_state() : gem::SubstrateState::NoState;
});
REQUIRE(loc.has_value());
CHECK(*loc == gem::SubstrateState::AtDestination);
// Reporting on a substrate that never ARRIVED is rejected.
CHECK(sub("WFR-GHOST", pb::SubstrateReport::AT_WORK) == pb::Ack::INVALID_OBJECT);
auto mod = [&](const std::string& mid, pb::ModuleReport::State st) {
grpc::ClientContext ctx;
pb::ModuleReport req;
pb::Ack ack;
req.set_module_id(mid);
req.set_state(st);
REQUIRE(stub->ReportModule(&ctx, req, &ack).ok());
return ack.code();
};
// Module: auto-created, then walked General -> Step -> StepCompleted.
CHECK(mod("MOD-1", pb::ModuleReport::GENERAL_EXECUTING) == pb::Ack::ACCEPT);
CHECK(mod("MOD-1", pb::ModuleReport::STEP_EXECUTING) == pb::Ack::ACCEPT);
CHECK(mod("MOD-1", pb::ModuleReport::STEP_COMPLETED) == pb::Ack::ACCEPT);
auto mstate = rt.read_sync([&rt]() {
const auto* m = rt.model().modules.get("MOD-1");
return m ? m->fsm->state() : gem::ModuleState::NotExecuting;
});
REQUIRE(mstate.has_value());
CHECK(*mstate == gem::ModuleState::StepCompleted);
// An illegal jump (StepExecuting straight from a fresh NotExecuting module)
// is rejected by the E157 table.
CHECK(mod("MOD-2", pb::ModuleReport::STEP_EXECUTING) == pb::Ack::CANNOT_DO_NOW);
server->Shutdown();
rt.stop();
}