From a2ebbf7c653020d376cd332ff8fbe8db7639acba Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Fri, 26 Jun 2026 21:43:07 +0200 Subject: [PATCH] feat(client)+feat(daemon): eq.names, @eq.command, E90/E157 RPCs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 16 +- clients/python/examples/mini_tool.py | 11 +- .../__pycache__/_client.cpython-312.pyc | Bin 17719 -> 23196 bytes clients/python/secsgem_client/_client.py | 83 ++++++++ .../__pycache__/equipment_pb2.cpython-312.pyc | Bin 8041 -> 9673 bytes .../equipment_pb2_grpc.cpython-312.pyc | Bin 21993 -> 29840 bytes .../secsgem_client/_proto/equipment_pb2.py | 28 ++- .../_proto/equipment_pb2_grpc.py | 184 +++++++++++++++++- docs/16_e90_e157_substrates_modules.md | 31 +++ docs/42_vendor_daemon_and_clients.md | 64 +++++- include/secsgem/daemon/equipment_service.hpp | 84 ++++++++ interop/pyclient_interop.py | 22 +++ proto/secsgem/v1/equipment.proto | 38 ++++ tests/test_daemon_service.cpp | 68 +++++++ 14 files changed, 602 insertions(+), 27 deletions(-) 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 d03b2d7a2aafeee1e37a7a4d6735bc83f24685b1..b06a42cdc98208c46589a16afd74ff1dd25d2af1 100644 GIT binary patch delta 8720 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 MGD7|(i9uNAzo}%#FaQ7m 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 752f611794d654d878fffcd6610a1436ac55253b..e44ec287a5691c022c9645b738434688ee8659d5 100644 GIT binary patch delta 1703 zcmZvc-*4Mg6vtyHY2%yU-U3;=b|n%l)gWRUu#O+CLh6>NNgQlv6!BoW$zAK#j#Jx7 z*A>Y~@DHE}iARulK-$AJbS&EhOIp1^cx#!&L zKi7WQj{O>qA|E{eK3qFEyZv=6P2USW$)zajyZ##W+MLte$BCwT1r+j2L`RRSXA*WMY^v)5JAQ%AWmDY) z(4CBR%kKV~4<~RUJYi0lmDAqA{C)bfWdHa1GsZciP+aWnTDGa!Sio(=wAsrjVvUik zHq+EHm1Edd#OSJqE!$|}Gz}(#u48`d7y^WB^InJoFrSj@5rH5AiSu}QM|huI=1 zU}=W!qyu0^vpa~H>ceQhAPA*<+(yF35<`+d;f3u4#l{e-34DQ zVWAjkH#ku$RSQyyuOckDU{ses+>?LdoV>x6D6+ZVKiaR5El7Lt;|Ve57}=i zbeTKqsBIm$>c*PfQgl3;37M2}-#%7MEStDHET4g@n66@~$}UU` z+UfFGMG@EK&Wufa1>QMc41EWMd#kCp z9CjUr+^+9pHtCJ$^tOGHSw4qeG^h6w8un+{DR$ATztFr3b#QDET@rDtIorc?jhJy> z$kNwRWxtb<#rlV$Bori0;A*@esXxJYQ{9EvAT#TeE2T2r8@|e^KNCSzuJ9Z6@;Guo z43Tko4KAH?$f5-4)kES*$=t{FQmw*OCAfk`M<~_E9aQ}hhz-JPz{+Bc=gXVry7-X* zVpveUPP{9`n~J5McG+kR2 zlRn={deHwX{fG+J;A=?1&-yah`#!VDoE}sE_sF+EaS@8GY;d&9|r4n>}r5v6sA7MW?hcaKo&^ GV*dm7-n~Bn delta 174 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\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(); +}