diff --git a/README.md b/README.md index 506e259..1b12580 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/clients/python/examples/mini_tool.py b/clients/python/examples/mini_tool.py index 4f1f7aa..a35ef08 100644 --- a/clients/python/examples/mini_tool.py +++ b/clients/python/examples/mini_tool.py @@ -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) + diff --git a/clients/python/secsgem_client/__pycache__/_client.cpython-312.pyc b/clients/python/secsgem_client/__pycache__/_client.cpython-312.pyc index d03b2d7..b06a42c 100644 Binary files a/clients/python/secsgem_client/__pycache__/_client.cpython-312.pyc and b/clients/python/secsgem_client/__pycache__/_client.cpython-312.pyc differ diff --git a/clients/python/secsgem_client/_client.py b/clients/python/secsgem_client/_client.py index 002ce00..f25a7bc 100644 --- a/clients/python/secsgem_client/_client.py +++ b/clients/python/secsgem_client/_client.py @@ -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.""" diff --git a/clients/python/secsgem_client/_proto/__pycache__/equipment_pb2.cpython-312.pyc b/clients/python/secsgem_client/_proto/__pycache__/equipment_pb2.cpython-312.pyc index 752f611..e44ec28 100644 Binary files a/clients/python/secsgem_client/_proto/__pycache__/equipment_pb2.cpython-312.pyc and b/clients/python/secsgem_client/_proto/__pycache__/equipment_pb2.cpython-312.pyc differ diff --git a/clients/python/secsgem_client/_proto/__pycache__/equipment_pb2_grpc.cpython-312.pyc b/clients/python/secsgem_client/_proto/__pycache__/equipment_pb2_grpc.cpython-312.pyc index d578898..73f3ab2 100644 Binary files a/clients/python/secsgem_client/_proto/__pycache__/equipment_pb2_grpc.cpython-312.pyc and b/clients/python/secsgem_client/_proto/__pycache__/equipment_pb2_grpc.cpython-312.pyc differ diff --git a/clients/python/secsgem_client/_proto/equipment_pb2.py b/clients/python/secsgem_client/_proto/equipment_pb2.py index 13bb62a..3c3c675 100644 --- a/clients/python/secsgem_client/_proto/equipment_pb2.py +++ b/clients/python/secsgem_client/_proto/equipment_pb2.py @@ -14,7 +14,7 @@ _sym_db = _symbol_database.Default() -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x65quipment.proto\x12\nsecsgem.v1\"\x89\x01\n\x05Value\x12\x0e\n\x04text\x18\x01 \x01(\tH\x00\x12\x11\n\x07integer\x18\x02 \x01(\x12H\x00\x12\x0e\n\x04real\x18\x03 \x01(\x01H\x00\x12\x11\n\x07\x62oolean\x18\x04 \x01(\x08H\x00\x12\x10\n\x06\x62inary\x18\x05 \x01(\x0cH\x00\x12 \n\x04list\x18\x06 \x01(\x0b\x32\x10.secsgem.v1.ListH\x00\x42\x06\n\x04kind\"(\n\x04List\x12 \n\x05items\x18\x01 \x03(\x0b\x32\x11.secsgem.v1.Value\"\x07\n\x05\x45mpty\"\x8a\x01\n\x0eVariableUpdate\x12\x36\n\x06values\x18\x01 \x03(\x0b\x32&.secsgem.v1.VariableUpdate.ValuesEntry\x1a@\n\x0bValuesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.secsgem.v1.Value:\x02\x38\x01\"\x1e\n\rVariableQuery\x12\r\n\x05names\x18\x01 \x03(\t\"\x8e\x01\n\x10VariableSnapshot\x12\x38\n\x06values\x18\x01 \x03(\x0b\x32(.secsgem.v1.VariableSnapshot.ValuesEntry\x1a@\n\x0bValuesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.secsgem.v1.Value:\x02\x38\x01\"\x80\x01\n\x05\x45vent\x12\x0c\n\x04name\x18\x01 \x01(\t\x12)\n\x04\x64\x61ta\x18\x02 \x03(\x0b\x32\x1b.secsgem.v1.Event.DataEntry\x1a>\n\tDataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.secsgem.v1.Value:\x02\x38\x01\"\x15\n\x05\x41larm\x12\x0c\n\x04name\x18\x01 \x01(\t\"\"\n\x10SubscribeRequest\x12\x0e\n\x06\x63lient\x18\x01 \x01(\t\"\xa8\x01\n\x0c\x43ontrolState\x12-\n\x05state\x18\x01 \x01(\x0e\x32\x1e.secsgem.v1.ControlState.State\"i\n\x05State\x12\x15\n\x11\x45QUIPMENT_OFFLINE\x10\x00\x12\x12\n\x0e\x41TTEMPT_ONLINE\x10\x01\x12\x10\n\x0cHOST_OFFLINE\x10\x02\x12\x10\n\x0cONLINE_LOCAL\x10\x03\x12\x11\n\rONLINE_REMOTE\x10\x04\"F\n\x13\x43ontrolStateRequest\x12/\n\x07\x64\x65sired\x18\x01 \x01(\x0e\x32\x1e.secsgem.v1.ControlState.State\"\xbd\x02\n\x0bHostRequest\x12&\n\x07\x63ommand\x18\x01 \x01(\x0b\x32\x13.secsgem.v1.CommandH\x00\x12\x37\n\rcontrol_state\x18\x02 \x01(\x0b\x32\x1e.secsgem.v1.ControlStateChangeH\x00\x12.\n\x08\x63onstant\x18\x03 \x01(\x0b\x32\x1a.secsgem.v1.ConstantChangeH\x00\x12\x35\n\x0fprocess_program\x18\x04 \x01(\x0b\x32\x1a.secsgem.v1.ProcessProgramH\x00\x12,\n\x07\x63\x61rrier\x18\x05 \x01(\x0b\x32\x19.secsgem.v1.CarrierActionH\x00\x12-\n\x0bprocess_job\x18\x06 \x01(\x0b\x32\x16.secsgem.v1.ProcessJobH\x00\x42\t\n\x07request\"\x96\x01\n\x07\x43ommand\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12/\n\x06params\x18\x03 \x03(\x0b\x32\x1f.secsgem.v1.Command.ParamsEntry\x1a@\n\x0bParamsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.secsgem.v1.Value:\x02\x38\x01\"C\n\x12\x43ontrolStateChange\x12-\n\x05state\x18\x01 \x01(\x0e\x32\x1e.secsgem.v1.ControlState.State\"@\n\x0e\x43onstantChange\x12\x0c\n\x04name\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.secsgem.v1.Value\",\n\x0eProcessProgram\x12\x0c\n\x04ppid\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\x0c\"\x95\x01\n\rCarrierAction\x12\x12\n\ncarrier_id\x18\x01 \x01(\t\x12\x0c\n\x04port\x18\x02 \x01(\r\x12\x30\n\x06\x61\x63tion\x18\x03 \x01(\x0e\x32 .secsgem.v1.CarrierAction.Action\"0\n\x06\x41\x63tion\x12\r\n\tVERIFY_ID\x10\x00\x12\x0b\n\x07PROCEED\x10\x01\x12\n\n\x06\x43\x41NCEL\x10\x02\"\xae\x01\n\nProcessJob\x12\x0e\n\x06job_id\x18\x01 \x01(\t\x12\x0e\n\x06recipe\x18\x02 \x01(\t\x12-\n\x06\x61\x63tion\x18\x03 \x01(\x0e\x32\x1d.secsgem.v1.ProcessJob.Action\x12\x10\n\x08\x63\x61rriers\x18\x04 \x03(\t\"?\n\x06\x41\x63tion\x12\t\n\x05START\x10\x00\x12\x08\n\x04STOP\x10\x01\x12\t\n\x05PAUSE\x10\x02\x12\n\n\x06RESUME\x10\x03\x12\t\n\x05\x41\x42ORT\x10\x04\"9\n\rCommandResult\x12\n\n\x02id\x18\x01 \x01(\t\x12\x1c\n\x03\x61\x63k\x18\x02 \x01(\x0b\x32\x0f.secsgem.v1.Ack\"\x97\x01\n\x0fProcessJobState\x12\x0e\n\x06job_id\x18\x01 \x01(\t\x12\x30\n\x05state\x18\x02 \x01(\x0e\x32!.secsgem.v1.ProcessJobState.State\"B\n\x05State\x12\x0e\n\nSETTING_UP\x10\x00\x12\x0e\n\nPROCESSING\x10\x01\x12\x0c\n\x08\x43OMPLETE\x10\x02\x12\x0b\n\x07\x41\x42ORTED\x10\x03\"\xa1\x01\n\x0c\x43\x61rrierState\x12\x12\n\ncarrier_id\x18\x01 \x01(\t\x12\x0c\n\x04port\x18\x02 \x01(\r\x12-\n\x05state\x18\x03 \x01(\x0e\x32\x1e.secsgem.v1.CarrierState.State\x12\r\n\x05slots\x18\x04 \x03(\x08\"1\n\x05State\x12\x0b\n\x07WAITING\x10\x00\x12\r\n\tIN_ACCESS\x10\x01\x12\x0c\n\x08\x43OMPLETE\x10\x02\"\xbc\x01\n\x06Health\x12*\n\x04link\x18\x01 \x01(\x0e\x32\x1c.secsgem.v1.Health.LinkState\x12\x13\n\x0bspool_depth\x18\x02 \x01(\r\x12\x35\n\rcontrol_state\x18\x03 \x01(\x0e\x32\x1e.secsgem.v1.ControlState.State\":\n\tLinkState\x12\x10\n\x0c\x44ISCONNECTED\x10\x00\x12\r\n\tCONNECTED\x10\x01\x12\x0c\n\x08SELECTED\x10\x02\"\xd0\x01\n\x03\x41\x63k\x12\"\n\x04\x63ode\x18\x01 \x01(\x0e\x32\x14.secsgem.v1.Ack.Code\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x93\x01\n\x04\x43ode\x12\n\n\x06\x41\x43\x43\x45PT\x10\x00\x12\x13\n\x0fINVALID_COMMAND\x10\x01\x12\x11\n\rCANNOT_DO_NOW\x10\x02\x12\x15\n\x11PARAMETER_INVALID\x10\x03\x12\x1e\n\x1a\x41\x43\x43\x45PTED_WILL_FINISH_LATER\x10\x04\x12\x0c\n\x08REJECTED\x10\x05\x12\x12\n\x0eINVALID_OBJECT\x10\x06\x32\xe8\x05\n\tEquipment\x12;\n\x0cSetVariables\x12\x1a.secsgem.v1.VariableUpdate\x1a\x0f.secsgem.v1.Ack\x12G\n\x0cGetVariables\x12\x19.secsgem.v1.VariableQuery\x1a\x1c.secsgem.v1.VariableSnapshot\x12/\n\tFireEvent\x12\x11.secsgem.v1.Event\x1a\x0f.secsgem.v1.Ack\x12.\n\x08SetAlarm\x12\x11.secsgem.v1.Alarm\x1a\x0f.secsgem.v1.Ack\x12\x30\n\nClearAlarm\x12\x11.secsgem.v1.Alarm\x1a\x0f.secsgem.v1.Ack\x12>\n\x0fGetControlState\x12\x11.secsgem.v1.Empty\x1a\x18.secsgem.v1.ControlState\x12G\n\x13RequestControlState\x12\x1f.secsgem.v1.ControlStateRequest\x1a\x0f.secsgem.v1.Ack\x12\x44\n\tSubscribe\x12\x1c.secsgem.v1.SubscribeRequest\x1a\x17.secsgem.v1.HostRequest0\x01\x12=\n\x0f\x43ompleteCommand\x12\x19.secsgem.v1.CommandResult\x1a\x0f.secsgem.v1.Ack\x12@\n\x10ReportProcessJob\x12\x1b.secsgem.v1.ProcessJobState\x1a\x0f.secsgem.v1.Ack\x12:\n\rReportCarrier\x12\x18.secsgem.v1.CarrierState\x1a\x0f.secsgem.v1.Ack\x12\x36\n\x0bWatchHealth\x12\x11.secsgem.v1.Empty\x1a\x12.secsgem.v1.Health0\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0f\x65quipment.proto\x12\nsecsgem.v1\"\x89\x01\n\x05Value\x12\x0e\n\x04text\x18\x01 \x01(\tH\x00\x12\x11\n\x07integer\x18\x02 \x01(\x12H\x00\x12\x0e\n\x04real\x18\x03 \x01(\x01H\x00\x12\x11\n\x07\x62oolean\x18\x04 \x01(\x08H\x00\x12\x10\n\x06\x62inary\x18\x05 \x01(\x0cH\x00\x12 \n\x04list\x18\x06 \x01(\x0b\x32\x10.secsgem.v1.ListH\x00\x42\x06\n\x04kind\"(\n\x04List\x12 \n\x05items\x18\x01 \x03(\x0b\x32\x11.secsgem.v1.Value\"\x07\n\x05\x45mpty\"\x8a\x01\n\x0eVariableUpdate\x12\x36\n\x06values\x18\x01 \x03(\x0b\x32&.secsgem.v1.VariableUpdate.ValuesEntry\x1a@\n\x0bValuesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.secsgem.v1.Value:\x02\x38\x01\"\x1e\n\rVariableQuery\x12\r\n\x05names\x18\x01 \x03(\t\"\x8e\x01\n\x10VariableSnapshot\x12\x38\n\x06values\x18\x01 \x03(\x0b\x32(.secsgem.v1.VariableSnapshot.ValuesEntry\x1a@\n\x0bValuesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.secsgem.v1.Value:\x02\x38\x01\"\x80\x01\n\x05\x45vent\x12\x0c\n\x04name\x18\x01 \x01(\t\x12)\n\x04\x64\x61ta\x18\x02 \x03(\x0b\x32\x1b.secsgem.v1.Event.DataEntry\x1a>\n\tDataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.secsgem.v1.Value:\x02\x38\x01\"\x15\n\x05\x41larm\x12\x0c\n\x04name\x18\x01 \x01(\t\"\"\n\x10SubscribeRequest\x12\x0e\n\x06\x63lient\x18\x01 \x01(\t\"\xa8\x01\n\x0c\x43ontrolState\x12-\n\x05state\x18\x01 \x01(\x0e\x32\x1e.secsgem.v1.ControlState.State\"i\n\x05State\x12\x15\n\x11\x45QUIPMENT_OFFLINE\x10\x00\x12\x12\n\x0e\x41TTEMPT_ONLINE\x10\x01\x12\x10\n\x0cHOST_OFFLINE\x10\x02\x12\x10\n\x0cONLINE_LOCAL\x10\x03\x12\x11\n\rONLINE_REMOTE\x10\x04\"F\n\x13\x43ontrolStateRequest\x12/\n\x07\x64\x65sired\x18\x01 \x01(\x0e\x32\x1e.secsgem.v1.ControlState.State\"\xbd\x02\n\x0bHostRequest\x12&\n\x07\x63ommand\x18\x01 \x01(\x0b\x32\x13.secsgem.v1.CommandH\x00\x12\x37\n\rcontrol_state\x18\x02 \x01(\x0b\x32\x1e.secsgem.v1.ControlStateChangeH\x00\x12.\n\x08\x63onstant\x18\x03 \x01(\x0b\x32\x1a.secsgem.v1.ConstantChangeH\x00\x12\x35\n\x0fprocess_program\x18\x04 \x01(\x0b\x32\x1a.secsgem.v1.ProcessProgramH\x00\x12,\n\x07\x63\x61rrier\x18\x05 \x01(\x0b\x32\x19.secsgem.v1.CarrierActionH\x00\x12-\n\x0bprocess_job\x18\x06 \x01(\x0b\x32\x16.secsgem.v1.ProcessJobH\x00\x42\t\n\x07request\"\x96\x01\n\x07\x43ommand\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12/\n\x06params\x18\x03 \x03(\x0b\x32\x1f.secsgem.v1.Command.ParamsEntry\x1a@\n\x0bParamsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.secsgem.v1.Value:\x02\x38\x01\"C\n\x12\x43ontrolStateChange\x12-\n\x05state\x18\x01 \x01(\x0e\x32\x1e.secsgem.v1.ControlState.State\"@\n\x0e\x43onstantChange\x12\x0c\n\x04name\x18\x01 \x01(\t\x12 \n\x05value\x18\x02 \x01(\x0b\x32\x11.secsgem.v1.Value\",\n\x0eProcessProgram\x12\x0c\n\x04ppid\x18\x01 \x01(\t\x12\x0c\n\x04\x62ody\x18\x02 \x01(\x0c\"\x95\x01\n\rCarrierAction\x12\x12\n\ncarrier_id\x18\x01 \x01(\t\x12\x0c\n\x04port\x18\x02 \x01(\r\x12\x30\n\x06\x61\x63tion\x18\x03 \x01(\x0e\x32 .secsgem.v1.CarrierAction.Action\"0\n\x06\x41\x63tion\x12\r\n\tVERIFY_ID\x10\x00\x12\x0b\n\x07PROCEED\x10\x01\x12\n\n\x06\x43\x41NCEL\x10\x02\"\xae\x01\n\nProcessJob\x12\x0e\n\x06job_id\x18\x01 \x01(\t\x12\x0e\n\x06recipe\x18\x02 \x01(\t\x12-\n\x06\x61\x63tion\x18\x03 \x01(\x0e\x32\x1d.secsgem.v1.ProcessJob.Action\x12\x10\n\x08\x63\x61rriers\x18\x04 \x03(\t\"?\n\x06\x41\x63tion\x12\t\n\x05START\x10\x00\x12\x08\n\x04STOP\x10\x01\x12\t\n\x05PAUSE\x10\x02\x12\n\n\x06RESUME\x10\x03\x12\t\n\x05\x41\x42ORT\x10\x04\"9\n\rCommandResult\x12\n\n\x02id\x18\x01 \x01(\t\x12\x1c\n\x03\x61\x63k\x18\x02 \x01(\x0b\x32\x0f.secsgem.v1.Ack\"\x97\x01\n\x0fProcessJobState\x12\x0e\n\x06job_id\x18\x01 \x01(\t\x12\x30\n\x05state\x18\x02 \x01(\x0e\x32!.secsgem.v1.ProcessJobState.State\"B\n\x05State\x12\x0e\n\nSETTING_UP\x10\x00\x12\x0e\n\nPROCESSING\x10\x01\x12\x0c\n\x08\x43OMPLETE\x10\x02\x12\x0b\n\x07\x41\x42ORTED\x10\x03\"\xa1\x01\n\x0c\x43\x61rrierState\x12\x12\n\ncarrier_id\x18\x01 \x01(\t\x12\x0c\n\x04port\x18\x02 \x01(\r\x12-\n\x05state\x18\x03 \x01(\x0e\x32\x1e.secsgem.v1.CarrierState.State\x12\r\n\x05slots\x18\x04 \x03(\x08\"1\n\x05State\x12\x0b\n\x07WAITING\x10\x00\x12\r\n\tIN_ACCESS\x10\x01\x12\x0c\n\x08\x43OMPLETE\x10\x02\"\xbc\x01\n\x06Health\x12*\n\x04link\x18\x01 \x01(\x0e\x32\x1c.secsgem.v1.Health.LinkState\x12\x13\n\x0bspool_depth\x18\x02 \x01(\r\x12\x35\n\rcontrol_state\x18\x03 \x01(\x0e\x32\x1e.secsgem.v1.ControlState.State\":\n\tLinkState\x12\x10\n\x0c\x44ISCONNECTED\x10\x00\x12\r\n\tCONNECTED\x10\x01\x12\x0c\n\x08SELECTED\x10\x02\"\xdd\x01\n\x0fSubstrateReport\x12\x14\n\x0csubstrate_id\x18\x01 \x01(\t\x12\x38\n\tmilestone\x18\x02 \x01(\x0e\x32%.secsgem.v1.SubstrateReport.Milestone\x12\x12\n\ncarrier_id\x18\x03 \x01(\t\x12\x0c\n\x04slot\x18\x04 \x01(\r\"X\n\tMilestone\x12\x0b\n\x07\x41RRIVED\x10\x00\x12\x0b\n\x07\x41T_WORK\x10\x01\x12\x0e\n\nPROCESSING\x10\x02\x12\r\n\tPROCESSED\x10\x03\x12\x12\n\x0e\x41T_DESTINATION\x10\x04\"\xab\x01\n\x0cModuleReport\x12\x11\n\tmodule_id\x18\x01 \x01(\t\x12-\n\x05state\x18\x02 \x01(\x0e\x32\x1e.secsgem.v1.ModuleReport.State\"Y\n\x05State\x12\x11\n\rNOT_EXECUTING\x10\x00\x12\x15\n\x11GENERAL_EXECUTING\x10\x01\x12\x12\n\x0eSTEP_EXECUTING\x10\x02\x12\x12\n\x0eSTEP_COMPLETED\x10\x03\"\x98\x01\n\x14\x45quipmentDescription\x12\x12\n\nmodel_name\x18\x01 \x01(\t\x12\x14\n\x0csoftware_rev\x18\x02 \x01(\t\x12\x11\n\tvariables\x18\x03 \x03(\t\x12\x0e\n\x06\x65vents\x18\x04 \x03(\t\x12\x0e\n\x06\x61larms\x18\x05 \x03(\t\x12\x10\n\x08\x63ommands\x18\x06 \x03(\t\x12\x11\n\tconstants\x18\x07 \x03(\t\"\"\n\x11SpoolFlushRequest\x12\r\n\x05purge\x18\x01 \x01(\x08\",\n\x0fTerminalMessage\x12\x0b\n\x03tid\x18\x01 \x01(\r\x12\x0c\n\x04text\x18\x02 \x01(\t\"\xd0\x01\n\x03\x41\x63k\x12\"\n\x04\x63ode\x18\x01 \x01(\x0e\x32\x14.secsgem.v1.Ack.Code\x12\x0f\n\x07message\x18\x02 \x01(\t\"\x93\x01\n\x04\x43ode\x12\n\n\x06\x41\x43\x43\x45PT\x10\x00\x12\x13\n\x0fINVALID_COMMAND\x10\x01\x12\x11\n\rCANNOT_DO_NOW\x10\x02\x12\x15\n\x11PARAMETER_INVALID\x10\x03\x12\x1e\n\x1a\x41\x43\x43\x45PTED_WILL_FINISH_LATER\x10\x04\x12\x0c\n\x08REJECTED\x10\x05\x12\x12\n\x0eINVALID_OBJECT\x10\x06\x32\xa8\x08\n\tEquipment\x12;\n\x0cSetVariables\x12\x1a.secsgem.v1.VariableUpdate\x1a\x0f.secsgem.v1.Ack\x12G\n\x0cGetVariables\x12\x19.secsgem.v1.VariableQuery\x1a\x1c.secsgem.v1.VariableSnapshot\x12/\n\tFireEvent\x12\x11.secsgem.v1.Event\x1a\x0f.secsgem.v1.Ack\x12.\n\x08SetAlarm\x12\x11.secsgem.v1.Alarm\x1a\x0f.secsgem.v1.Ack\x12\x30\n\nClearAlarm\x12\x11.secsgem.v1.Alarm\x1a\x0f.secsgem.v1.Ack\x12>\n\x0fGetControlState\x12\x11.secsgem.v1.Empty\x1a\x18.secsgem.v1.ControlState\x12G\n\x13RequestControlState\x12\x1f.secsgem.v1.ControlStateRequest\x1a\x0f.secsgem.v1.Ack\x12\x44\n\tSubscribe\x12\x1c.secsgem.v1.SubscribeRequest\x1a\x17.secsgem.v1.HostRequest0\x01\x12=\n\x0f\x43ompleteCommand\x12\x19.secsgem.v1.CommandResult\x1a\x0f.secsgem.v1.Ack\x12@\n\x10ReportProcessJob\x12\x1b.secsgem.v1.ProcessJobState\x1a\x0f.secsgem.v1.Ack\x12:\n\rReportCarrier\x12\x18.secsgem.v1.CarrierState\x1a\x0f.secsgem.v1.Ack\x12?\n\x0fReportSubstrate\x12\x1b.secsgem.v1.SubstrateReport\x1a\x0f.secsgem.v1.Ack\x12\x39\n\x0cReportModule\x12\x18.secsgem.v1.ModuleReport\x1a\x0f.secsgem.v1.Ack\x12\x36\n\x0bWatchHealth\x12\x11.secsgem.v1.Empty\x1a\x12.secsgem.v1.Health0\x01\x12?\n\x08\x44\x65scribe\x12\x11.secsgem.v1.Empty\x1a .secsgem.v1.EquipmentDescription\x12<\n\nFlushSpool\x12\x1d.secsgem.v1.SpoolFlushRequest\x1a\x0f.secsgem.v1.Ack\x12\x43\n\x13SendTerminalMessage\x12\x1b.secsgem.v1.TerminalMessage\x1a\x0f.secsgem.v1.Ackb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -93,10 +93,24 @@ if _descriptor._USE_C_DESCRIPTORS == False: _globals['_HEALTH']._serialized_end=2522 _globals['_HEALTH_LINKSTATE']._serialized_start=2464 _globals['_HEALTH_LINKSTATE']._serialized_end=2522 - _globals['_ACK']._serialized_start=2525 - _globals['_ACK']._serialized_end=2733 - _globals['_ACK_CODE']._serialized_start=2586 - _globals['_ACK_CODE']._serialized_end=2733 - _globals['_EQUIPMENT']._serialized_start=2736 - _globals['_EQUIPMENT']._serialized_end=3480 + _globals['_SUBSTRATEREPORT']._serialized_start=2525 + _globals['_SUBSTRATEREPORT']._serialized_end=2746 + _globals['_SUBSTRATEREPORT_MILESTONE']._serialized_start=2658 + _globals['_SUBSTRATEREPORT_MILESTONE']._serialized_end=2746 + _globals['_MODULEREPORT']._serialized_start=2749 + _globals['_MODULEREPORT']._serialized_end=2920 + _globals['_MODULEREPORT_STATE']._serialized_start=2831 + _globals['_MODULEREPORT_STATE']._serialized_end=2920 + _globals['_EQUIPMENTDESCRIPTION']._serialized_start=2923 + _globals['_EQUIPMENTDESCRIPTION']._serialized_end=3075 + _globals['_SPOOLFLUSHREQUEST']._serialized_start=3077 + _globals['_SPOOLFLUSHREQUEST']._serialized_end=3111 + _globals['_TERMINALMESSAGE']._serialized_start=3113 + _globals['_TERMINALMESSAGE']._serialized_end=3157 + _globals['_ACK']._serialized_start=3160 + _globals['_ACK']._serialized_end=3368 + _globals['_ACK_CODE']._serialized_start=3221 + _globals['_ACK_CODE']._serialized_end=3368 + _globals['_EQUIPMENT']._serialized_start=3371 + _globals['_EQUIPMENT']._serialized_end=4435 # @@protoc_insertion_point(module_scope) diff --git a/clients/python/secsgem_client/_proto/equipment_pb2_grpc.py b/clients/python/secsgem_client/_proto/equipment_pb2_grpc.py index 2291227..ef08fac 100644 --- a/clients/python/secsgem_client/_proto/equipment_pb2_grpc.py +++ b/clients/python/secsgem_client/_proto/equipment_pb2_grpc.py @@ -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) diff --git a/docs/16_e90_e157_substrates_modules.md b/docs/16_e90_e157_substrates_modules.md index 1d193a0..f541c3a 100644 --- a/docs/16_e90_e157_substrates_modules.md +++ b/docs/16_e90_e157_substrates_modules.md @@ -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 diff --git a/docs/42_vendor_daemon_and_clients.md b/docs/42_vendor_daemon_and_clients.md index 3e77b88..4a9a0e8 100644 --- a/docs/42_vendor_daemon_and_clients.md +++ b/docs/42_vendor_daemon_and_clients.md @@ -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 diff --git a/include/secsgem/daemon/equipment_service.hpp b/include/secsgem/daemon/equipment_service.hpp index b847ed1..654f20c 100644 --- a/include/secsgem/daemon/equipment_service.hpp +++ b/include/secsgem/daemon/equipment_service.hpp @@ -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(req->slot()); + const auto m = req->milestone(); + auto outcome = rt_.read_sync([this, sid, cid, slot, m]() -> std::optional { + 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) 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>& 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. diff --git a/interop/pyclient_interop.py b/interop/pyclient_interop.py index f5c4294..a0ee5c2 100644 --- a/interop/pyclient_interop.py +++ b/interop/pyclient_interop.py @@ -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)", diff --git a/proto/secsgem/v1/equipment.proto b/proto/secsgem/v1/equipment.proto index dc36529..8b3e3b0 100644 --- a/proto/secsgem/v1/equipment.proto +++ b/proto/secsgem/v1/equipment.proto @@ -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 { diff --git a/tests/test_daemon_service.cpp b/tests/test_daemon_service.cpp index 572c3d6..8d715de 100644 --- a/tests/test_daemon_service.cpp +++ b/tests/test_daemon_service.cpp @@ -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 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(); +}