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:
@@ -39,16 +39,22 @@ Three ways in, same engine underneath:
|
||||
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, report auto-assembled
|
||||
eq.set(ChamberPressure=2.5) # variables: kwargs, not strings
|
||||
|
||||
@eq.on("START") # host remote commands -> your function
|
||||
def start(cmd):
|
||||
run_recipe(cmd.params.get("PPID"))
|
||||
@eq.command # the function name IS the command,
|
||||
def START(cmd): # validated against the real equipment
|
||||
run_recipe(cmd.params.get("PPID")) # — so a typo fails at startup
|
||||
eq.fire(eq.names.event.ProcessStarted) # autocomplete + typo-safe
|
||||
|
||||
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).
|
||||
|
||||
2. **Any language over gRPC** — `secs_gemd` exposes the name-based API in
|
||||
|
||||
@@ -13,10 +13,10 @@ from secsgem_client import Equipment
|
||||
eq = Equipment("localhost:50051")
|
||||
|
||||
|
||||
@eq.on("START")
|
||||
def start(cmd):
|
||||
@eq.command
|
||||
def START(cmd):
|
||||
print("host says START", cmd.params)
|
||||
eq.fire("ProcessStarted")
|
||||
eq.fire(eq.names.event.ProcessStarted)
|
||||
|
||||
|
||||
eq.listen(background=True)
|
||||
@@ -25,7 +25,8 @@ while True:
|
||||
pressure = round(random.uniform(1.0, 3.0), 3)
|
||||
eq.set(ChamberPressure=pressure)
|
||||
if pressure > 2.9:
|
||||
eq.alarm("chiller_temp_high")
|
||||
eq.alarm(eq.names.alarm.chiller_temp_high)
|
||||
else:
|
||||
eq.clear("chiller_temp_high")
|
||||
eq.clear(eq.names.alarm.chiller_temp_high)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -21,8 +21,10 @@ equipment.yaml; values are plain Python.
|
||||
|
||||
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
|
||||
@@ -81,6 +83,31 @@ class Health:
|
||||
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."""
|
||||
|
||||
@@ -95,6 +122,7 @@ class Equipment:
|
||||
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 -------------------------------------------
|
||||
|
||||
@@ -116,6 +144,22 @@ class Equipment:
|
||||
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})
|
||||
|
||||
@@ -181,6 +225,25 @@ class Equipment:
|
||||
|
||||
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]):
|
||||
"""Decorator: the host wants a process job run/stopped (E40).
|
||||
Report progress with report_job()."""
|
||||
@@ -205,6 +268,26 @@ class Equipment:
|
||||
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."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -94,11 +94,36 @@ class EquipmentStub(object):
|
||||
request_serializer=equipment__pb2.CarrierState.SerializeToString,
|
||||
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(
|
||||
'/secsgem.v1.Equipment/WatchHealth',
|
||||
request_serializer=equipment__pb2.Empty.SerializeToString,
|
||||
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):
|
||||
@@ -227,7 +252,30 @@ class EquipmentServicer(object):
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
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_details('Method not implemented!')
|
||||
@@ -243,6 +291,30 @@ class EquipmentServicer(object):
|
||||
context.set_details('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):
|
||||
rpc_method_handlers = {
|
||||
@@ -301,11 +373,36 @@ def add_EquipmentServicer_to_server(servicer, server):
|
||||
request_deserializer=equipment__pb2.CarrierState.FromString,
|
||||
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(
|
||||
servicer.WatchHealth,
|
||||
request_deserializer=equipment__pb2.Empty.FromString,
|
||||
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(
|
||||
'secsgem.v1.Equipment', rpc_method_handlers)
|
||||
@@ -528,6 +625,40 @@ class Equipment(object):
|
||||
options, channel_credentials,
|
||||
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
|
||||
def WatchHealth(request,
|
||||
target,
|
||||
@@ -544,3 +675,54 @@ class Equipment(object):
|
||||
equipment__pb2.Health.FromString,
|
||||
options, channel_credentials,
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
You now know how every component of in-flight material is
|
||||
|
||||
@@ -31,7 +31,7 @@ drops; the daemon model covers the gap if *your software* drops.
|
||||
tool software (any language) secs_gemd fab host / MES
|
||||
┌──────────────────────────┐ gRPC ┌──────────────────────────┐ HSMS ┌────────┐
|
||||
│ 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 │
|
||||
└──────────────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
@@ -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 |
|
||||
| `SetAlarm` / `ClearAlarm` | S5F1 set/clear, by alarm `name:` (or stringified ALID) |
|
||||
| `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 |
|
||||
| `Subscribe` | server stream: everything the host asks of the tool |
|
||||
| `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
|
||||
|
||||
@@ -102,28 +108,68 @@ eq.set(ChamberPressure=2.5) # host sees it on its next S1F3
|
||||
eq["WaferCounter"] = 7 # item syntax, same thing
|
||||
print(eq.get("ChamberPressure")) # read back through the daemon
|
||||
|
||||
eq.fire("ProcessStarted", ChamberPressure=2.75) # values, then S6F11
|
||||
eq.alarm("chiller_temp_high") # S5F1 set
|
||||
eq.clear("chiller_temp_high") # S5F1 clear
|
||||
# eq.names — autocomplete-able, typo-safe name lookup (fetched from Describe)
|
||||
eq.fire(eq.names.event.ProcessStarted) # typo → AttributeError at the line it happened
|
||||
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
|
||||
def start(cmd):
|
||||
@eq.command # function name IS the command name;
|
||||
def START(cmd): # validated against Describe at decoration time
|
||||
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.control_state # "ONLINE_REMOTE"
|
||||
eq.request_control_state("HOST_OFFLINE") # operator panel -> maintenance
|
||||
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
|
||||
(`no variable named 'ChamberPresure'`). A complete runnable tool is
|
||||
[clients/python/examples/mini_tool.py](../clients/python/examples/mini_tool.py)
|
||||
(~25 lines). The package is validated end-to-end by
|
||||
`interop/pyclient_interop.py`: 13 checks driving the published API while
|
||||
secsgem-py judges the wire.
|
||||
`interop/pyclient_interop.py` driving the published API while secsgem-py
|
||||
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+
|
||||
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;
|
||||
}
|
||||
|
||||
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*,
|
||||
pb::EquipmentDescription* resp) override {
|
||||
resp->set_model_name(descr_model_);
|
||||
@@ -653,6 +717,26 @@ class EquipmentService final : public pb::Equipment::Service {
|
||||
}
|
||||
|
||||
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
|
||||
// notifications: jobs, recipes, EC changes). No subscriber = dropped,
|
||||
// matching the no-buffering contract.
|
||||
|
||||
@@ -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)",
|
||||
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 ----
|
||||
eq.request_control_state("HOST_OFFLINE")
|
||||
check("request_control_state(HOST_OFFLINE)",
|
||||
|
||||
@@ -91,6 +91,17 @@ service Equipment {
|
||||
// as CarrierAction.
|
||||
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 --------------------------------------------------------
|
||||
|
||||
// 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 -----------------------------------------------
|
||||
|
||||
message EquipmentDescription {
|
||||
|
||||
@@ -735,3 +735,71 @@ TEST_CASE("randomized concurrent RPC stress (seeded)") {
|
||||
server->Shutdown();
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user