From 8a55137e57b79ce69bd1b8c1dea7cd6df3361fdd Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Fri, 26 Jun 2026 22:09:48 +0200 Subject: [PATCH] feat(client): typo-safe protocol enums + context manager; add wafer_tool example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interface cleanup so the report_* family matches the typo-safe ethos of eq.names instead of leaking raw protobuf errors on a misspelled value. - Milestone / ModuleState / JobState: importable str-enums (member == its wire name, so plain strings still work) — autocomplete + a typo-checked happy path. The clean rule: equipment-specific *names* live on eq.names; fixed protocol *value-sets* are enums. - _enum_value(): resolves an enum-or-string arg client-side and, on a bad value, raises ValueError with a close-match hint *before* the wire. Wired into report_job / report_substrate / report_module / request_control_state (all previously raised a raw protobuf ValueError). - Equipment is now a context manager (with Equipment(...) as eq: ...). - examples/wafer_tool.py: a cluster tool tracking one wafer through one module end-to-end (E90 + E157), showing the enums + context manager. - tests/test_enums.py: asserts the enums stay in lockstep with the proto and that the typo path is helpful. Wired into run_interop.sh (pyclient step). - Interop drives both the enum and string forms on the wire + the ValueError typo path. Docs (ch16/ch42) updated; names-vs-enums rule documented. All Python unit tests + 25 pyclient interop checks pass. Co-Authored-By: Claude Opus 4.8 --- clients/python/examples/wafer_tool.py | 56 +++++++++++ clients/python/secsgem_client/__init__.py | 6 +- .../__pycache__/__init__.cpython-312.pyc | Bin 690 -> 794 bytes .../__pycache__/_client.cpython-312.pyc | Bin 23196 -> 25213 bytes .../__pycache__/_enums.cpython-312.pyc | Bin 0 -> 2162 bytes clients/python/secsgem_client/_client.py | 62 ++++++++---- clients/python/secsgem_client/_enums.py | 48 +++++++++ clients/python/tests/test_enums.py | 40 ++++++++ docs/16_e90_e157_substrates_modules.md | 32 +++--- docs/42_vendor_daemon_and_clients.md | 92 +++++++++++------- interop/pyclient_interop.py | 34 ++++--- tools/run_interop.sh | 2 + 12 files changed, 290 insertions(+), 82 deletions(-) create mode 100644 clients/python/examples/wafer_tool.py create mode 100644 clients/python/secsgem_client/__pycache__/_enums.cpython-312.pyc create mode 100644 clients/python/secsgem_client/_enums.py create mode 100644 clients/python/tests/test_enums.py diff --git a/clients/python/examples/wafer_tool.py b/clients/python/examples/wafer_tool.py new file mode 100644 index 0000000..8949b40 --- /dev/null +++ b/clients/python/examples/wafer_tool.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""A cluster tool that tracks material — E90 wafers + E157 modules. + +Where mini_tool.py is the bare quickstart, this shows the *material-tracking* +side: when the host issues START, we run one wafer through one process module +and report every milestone. The daemon turns each report into the standard +GEM 300 collection events the host has subscribed to — we never touch a CEID. + + build/secs_gemd --port 5000 --config-dir data # then run this + +The host sees, per wafer: SubstrateArrived, module GeneralExecuting, the wafer +Acquired, the step running, processing start/stop, step complete, the wafer +delivered, and the module back to idle — the complete in-flight trace. +""" +import time + +from secsgem_client import Equipment, Milestone, ModuleState + +MODULE = "CHAMBER-A" + + +def run_wafer(eq: Equipment, wafer: str, carrier: str, slot: int) -> None: + """One wafer's journey through one module. Milestone / ModuleState are + importable enums — autocomplete-friendly and typo-checked — but plain + strings ("ARRIVED", "STEP_EXECUTING") work identically if you prefer.""" + eq.report_substrate(wafer, Milestone.ARRIVED, carrier_id=carrier, slot=slot) + eq.report_module(MODULE, ModuleState.GENERAL_EXECUTING) # module spins up + + eq.report_substrate(wafer, Milestone.AT_WORK) # robot loads wafer + eq.report_module(MODULE, ModuleState.STEP_EXECUTING) # recipe step begins + eq.report_substrate(wafer, Milestone.PROCESSING) + eq.fire(eq.names.event.ProcessStarted) + + time.sleep(1) # ...the actual process... + + eq.report_substrate(wafer, Milestone.PROCESSED) + eq.report_module(MODULE, ModuleState.STEP_COMPLETED) + eq.report_substrate(wafer, Milestone.AT_DESTINATION) # robot unloads wafer + eq.report_module(MODULE, ModuleState.NOT_EXECUTING) # module idle again + + +def main() -> None: + # Context manager: the gRPC channel is closed for you on exit. + with Equipment("localhost:50051") as eq: + + @eq.command # bound by name, validated against the daemon + def START(cmd): # the host's S2F41 START lands here + carrier = cmd.params.get("CARRIER", "FOUP-1") + for slot in range(1, 4): # three wafers out of the FOUP + run_wafer(eq, f"{carrier}-W{slot}", carrier, slot) + + eq.listen() # blocks, dispatching host commands + + +if __name__ == "__main__": + main() diff --git a/clients/python/secsgem_client/__init__.py b/clients/python/secsgem_client/__init__.py index 276914a..b28eeb4 100644 --- a/clients/python/secsgem_client/__init__.py +++ b/clients/python/secsgem_client/__init__.py @@ -7,6 +7,10 @@ equipment.yaml. """ from ._client import Command, Equipment, Health, ProcessJob, SecsGemError +from ._enums import JobState, Milestone, ModuleState -__all__ = ["Equipment", "Command", "Health", "ProcessJob", "SecsGemError"] +__all__ = [ + "Equipment", "Command", "Health", "ProcessJob", "SecsGemError", + "Milestone", "ModuleState", "JobState", +] __version__ = "0.1.0" diff --git a/clients/python/secsgem_client/__pycache__/__init__.cpython-312.pyc b/clients/python/secsgem_client/__pycache__/__init__.cpython-312.pyc index fc850a211de058a54450ba02267ef5ae941f72af..2520fe3de0ed4ab78d83c1e01802dd3138177d8b 100644 GIT binary patch delta 221 zcmdnQI*X0(G%qg~0}yPyZI>0vG?7n&(Pg5#s!9rD3Udy7E=LqcE@u>HE>{#6BSShv z3dZ4=lY&bUOHyxf`ex>&7MJAbrQYK9%}*)KNd=2* za!kIrc&@v?=zTOWH9-}!pzOo!2W@QfnThVyGRhI768T; BGtU43 delta 141 zcmbQmwuzPRG%qg~0}xc*(aieDIFV0+(O{ywsz5qJ3iBeyDE1VVD2_^2O}32%4U8(R zMJzy-Ma&?A6-2Os2zDS*#cH5usAu4($vN4bNoR5wQ@B1qP?!;ji)Det2WCb_#*a*F ej4a;;7#O`6uQGT(;__->zriQk$X&z>Q~?0|9vjU7 diff --git a/clients/python/secsgem_client/__pycache__/_client.cpython-312.pyc b/clients/python/secsgem_client/__pycache__/_client.cpython-312.pyc index b06a42cdc98208c46589a16afd74ff1dd25d2af1..ca91d1650f10210db69e450878ab7642a536470f 100644 GIT binary patch delta 8343 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 DMs6UD diff --git a/clients/python/secsgem_client/__pycache__/_enums.cpython-312.pyc b/clients/python/secsgem_client/__pycache__/_enums.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..45ce85fd85e07c6e9b87c5db4ed701218acee45a GIT binary patch literal 2162 zcmbtV&u4v7P5tE#jhrFa_*#EEPNiZAQQ?!;c$-C1UK z6IQ(#i9?S;s+4Q5RsWP;dQ7*MsGNFhMY#0T_hxMrD1t**^0#kx-g`6ed*6(InVd8< zc)t7n@|}w%O?!-k(NoL}{mGAD>}rnI(Hz~$dAgtLQUjt+vdJc14oiS20uU)myB#tk&uuHBjS)6CW+^6F~S4V?Xr7Sm8sjU z%;O@;-HREKERuwZQ3)G~+Cs}*sT(qa98@^4np71eME2c~P)S@LOQ9Pc%WA!77zCb8 z!F|YR%O3MB+jC*dLsw#sikc@YuE%7=0|q8{Vvn^V8nG+`>~lzyj5lN?U}ARq);GZ@&!pZncCDVQ%(;vO=qc8K9w@`s2dR2 zfgALIbRb}xi@VY=OltR`Z~g`oWX^@wxIo~x3k)K`WVYLVR^MowTQWGVb-DS1xF1Rx$<= z!?so})VQ^U@la9K_!G}tMNcPa5OC-xgvf@S9l{Lt;p@o*#R4bB^B0JGoB5 z$#=$_LZ|4AbxKaLQ%*{iaytHu%lJA(nO~fTEe&-+X1Afmq5>-4WOibM7X6X3vSR_% zX3~FAQr&JfEe$&lM%%iz+Pt0`Yt7XwW~)_iET`i`+pMKiFkdrU?RsOWU0-c9D&uL{ zvas2fl^T}iD-DBv#Io+i)Emx}EX(1xWeL2~1YC5QC?kv`;EnZ@_5@J-XMPbHwV0h2 zxfmvJn1jXV57DAxTUNtFjA1y!Dfr3v03K?;71sBsP7Rj#6dZqdaBc6{*+G5p-<|iAg zZOi=Dys{3QE~m$r&4$@rx^d{!RI6>S{bzh-b!F{_**0s9N?A-`Sw|2i5vCAMAe=;a z3jrAtM-dbU$I*Io1n+|(-v&VXYWpW<1}l4~&;43B39Jnohp5Ad|3zK-8sAViT>J^7 zGCw_!{}VQoa^Vi&K)WYk@KVme0SPl&W*e!p*4LDV((;H35bxKk%~w!@6=u3Si`F{` zFHmv@zah-QPoja8T-sMUQgHOl;3{bA!E#0Uv$<*5J7IEn?F1p)tsT3Wz+A$?7T?x None: raise SecsGemError(ack.code, ack.message) +def _enum_value(proto_enum, value, kind: str) -> int: + """Resolve a protocol-enum argument (an enum member like Milestone.ARRIVED + or a plain string like "ARRIVED") to its protobuf int, typo-safely — the + same close-match courtesy as eq.names, instead of a raw protobuf error.""" + name = value.value if isinstance(value, enum.Enum) else str(value) + try: + return proto_enum.Value(name) + except ValueError: + valid = list(proto_enum.keys()) + close = difflib.get_close_matches(name, valid, n=3) + hint = (f" Did you mean {', '.join(close)}?" if close + else f" Valid: {', '.join(valid)}.") + raise ValueError(f"unknown {kind} '{name}'.{hint}") from None + + @dataclass class Command: """A remote command from the host (e.g. START). The host has already been @@ -196,7 +213,7 @@ class Equipment: """Operator-panel transition, e.g. eq.request_control_state("HOST_OFFLINE") before maintenance. Raises if the E30 table says no from here.""" req = pb.ControlStateRequest( - desired=pb.ControlState.State.Value(desired)) + desired=_enum_value(pb.ControlState.State, desired, "control state")) _check(self._stub.RequestControlState(req)) def health(self) -> Health: @@ -259,32 +276,36 @@ class Equipment: self._constant_handler = fn return fn - def report_job(self, job_id: str, state: str) -> None: - """Report physical job progress: "SETTING_UP" | "PROCESSING" | - "COMPLETE" | "ABORTED". The daemon drives the E40 state machine and - notifies the host (S16F9).""" + def report_job(self, job_id: str, state: Union[JobState, str]) -> None: + """Report physical job progress (E40). state: a JobState or its name — + SETTING_UP | PROCESSING | COMPLETE | ABORTED. The daemon drives the E40 + state machine and notifies the host (S16F9).""" req = pb.ProcessJobState( - job_id=job_id, state=pb.ProcessJobState.State.Value(state)) + job_id=job_id, + state=_enum_value(pb.ProcessJobState.State, state, "job state")) _check(self._stub.ReportProcessJob(req)) - def report_substrate(self, substrate_id: str, milestone: str, + def report_substrate(self, substrate_id: str, + milestone: Union[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.""" + """E90 wafer tracking. milestone: a Milestone or its name — ARRIVED | + AT_WORK | PROCESSING | PROCESSED | AT_DESTINATION. ARRIVED records the + origin (carrier_id, slot) and creates the wafer; the rest drive its + E90 FSMs, and the daemon emits the standard CEIDs to the host.""" req = pb.SubstrateReport( substrate_id=substrate_id, - milestone=pb.SubstrateReport.Milestone.Value(milestone), + milestone=_enum_value(pb.SubstrateReport.Milestone, milestone, "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.""" + def report_module(self, module_id: str, + state: Union[ModuleState, str]) -> None: + """E157 module tracking. state: a ModuleState or its name — + NOT_EXECUTING | GENERAL_EXECUTING | STEP_EXECUTING | STEP_COMPLETED. + Auto-creates the module on first report; NOT_EXECUTING resets it.""" req = pb.ModuleReport( module_id=module_id, - state=pb.ModuleReport.State.Value(state)) + state=_enum_value(pb.ModuleReport.State, state, "module state")) _check(self._stub.ReportModule(req)) def listen(self, background: bool = False) -> None: @@ -301,6 +322,13 @@ class Equipment: self._stop.set() self._channel.close() + def __enter__(self) -> "Equipment": + return self + + def __exit__(self, *exc) -> bool: + self.close() + return False + # ---- internals ------------------------------------------------------------- def _listen_loop(self) -> None: diff --git a/clients/python/secsgem_client/_enums.py b/clients/python/secsgem_client/_enums.py new file mode 100644 index 0000000..073e7f9 --- /dev/null +++ b/clients/python/secsgem_client/_enums.py @@ -0,0 +1,48 @@ +"""The fixed protocol enums you pass to the report_* / control methods. + +Unlike the equipment's *names* (events, alarms, commands — those vary per +tool and live on ``eq.names``), these value sets are fixed by the SEMI +standards, so they ship as importable enums: + + from secsgem_client import Equipment, Milestone, ModuleState + + eq.report_substrate("WFR-1", Milestone.ARRIVED) # autocomplete + typo-safe + eq.report_substrate("WFR-1", "ARRIVED") # plain string also works + +Each member *is* its wire name (``Milestone.ARRIVED == "ARRIVED"``), so the +two forms are interchangeable; the enums simply give you IDE autocomplete and +a typo-checked happy path. A wrong string raises ``ValueError`` with a +close-match suggestion (see ``_client._enum_value``). +""" + +from __future__ import annotations + +import enum + + +class Milestone(str, enum.Enum): + """E90 substrate (wafer) lifecycle milestones — for ``eq.report_substrate``.""" + + ARRIVED = "ARRIVED" # created at its source carrier slot + AT_WORK = "AT_WORK" # picked up for processing + PROCESSING = "PROCESSING" # recipe step started + PROCESSED = "PROCESSED" # recipe step finished + AT_DESTINATION = "AT_DESTINATION" # returned / deposited + + +class ModuleState(str, enum.Enum): + """E157 module execution states — for ``eq.report_module``.""" + + NOT_EXECUTING = "NOT_EXECUTING" # idle (also resets the module) + GENERAL_EXECUTING = "GENERAL_EXECUTING" # setup / pre- / post-process + STEP_EXECUTING = "STEP_EXECUTING" # actively running a recipe step + STEP_COMPLETED = "STEP_COMPLETED" + + +class JobState(str, enum.Enum): + """E40 process-job progress — for ``eq.report_job``.""" + + SETTING_UP = "SETTING_UP" + PROCESSING = "PROCESSING" + COMPLETE = "COMPLETE" + ABORTED = "ABORTED" diff --git a/clients/python/tests/test_enums.py b/clients/python/tests/test_enums.py new file mode 100644 index 0000000..978ddb1 --- /dev/null +++ b/clients/python/tests/test_enums.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""The exported protocol enums must stay in lockstep with the proto, and the +typo-safe resolver must reject bad values with a helpful message. Plain +asserts — run directly.""" +import sys + +from secsgem_client import JobState, Milestone, ModuleState +from secsgem_client._client import _enum_value +from secsgem_client._proto import equipment_pb2 as pb + + +def members(e): + return {m.value for m in e} + + +def main() -> int: + # Each enum mirrors its proto source exactly — no drift, no missing member. + assert members(Milestone) == set(pb.SubstrateReport.Milestone.keys()) + assert members(ModuleState) == set(pb.ModuleReport.State.keys()) + assert members(JobState) == set(pb.ProcessJobState.State.keys()) + + # A member IS its wire name, so enum and plain string are interchangeable. + assert Milestone.ARRIVED == "ARRIVED" + assert _enum_value(pb.SubstrateReport.Milestone, Milestone.ARRIVED, "milestone") \ + == _enum_value(pb.SubstrateReport.Milestone, "ARRIVED", "milestone") + + # A typo raises ValueError (client-side, not a daemon round-trip) and the + # message offers the close match instead of leaking a raw protobuf error. + try: + _enum_value(pb.SubstrateReport.Milestone, "AT_WROK", "milestone") + raise SystemExit("expected ValueError for a misspelled milestone") + except ValueError as e: + assert "AT_WORK" in str(e), str(e) + + print("enums: proto-sync + typo-safety checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/16_e90_e157_substrates_modules.md b/docs/16_e90_e157_substrates_modules.md index f541c3a..d51efbb 100644 --- a/docs/16_e90_e157_substrates_modules.md +++ b/docs/16_e90_e157_substrates_modules.md @@ -207,28 +207,30 @@ 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 +from secsgem_client import Equipment, Milestone, ModuleState 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") +eq.report_substrate("WFR-001", Milestone.ARRIVED, carrier_id="FOUP-7", slot=3) +eq.report_substrate("WFR-001", Milestone.AT_WORK) +eq.report_substrate("WFR-001", Milestone.PROCESSING) +eq.report_substrate("WFR-001", Milestone.PROCESSED) +eq.report_substrate("WFR-001", Milestone.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") +eq.report_module("CHAMBER-A", ModuleState.GENERAL_EXECUTING) +eq.report_module("CHAMBER-A", ModuleState.STEP_EXECUTING) +eq.report_module("CHAMBER-A", ModuleState.STEP_COMPLETED) +eq.report_module("CHAMBER-A", ModuleState.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). +`Milestone` / `ModuleState` are importable enums (each member equals its +plain-string name, so `"ARRIVED"` works just as well). The daemon's +`ReportSubstrate` handler validates FSM transitions: a substrate that never +`ARRIVED` is rejected with `INVALID_OBJECT`, and a **duplicate** `ARRIVED` +with `CANNOT_DO_NOW` (`substrate '...' already exists`) — it never silently +re-creates over a wafer's live state. A complete worked example is +[clients/python/examples/wafer_tool.py](../clients/python/examples/wafer_tool.py). --- diff --git a/docs/42_vendor_daemon_and_clients.md b/docs/42_vendor_daemon_and_clients.md index 4a9a0e8..7a4cc62 100644 --- a/docs/42_vendor_daemon_and_clients.md +++ b/docs/42_vendor_daemon_and_clients.md @@ -100,52 +100,57 @@ protocol was designed for it. extension) and the entire integration is: ```python -from secsgem_client import Equipment +from secsgem_client import Equipment, Milestone, ModuleState -eq = Equipment("localhost:50051") +with Equipment("localhost:50051") as eq: # context manager closes the channel -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.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.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.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.command # function name IS the command name; -def START(cmd): # validated against Describe at decoration time - run_recipe(cmd.params.get("PPID")) - eq.fire(eq.names.event.ProcessStarted) + @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(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.on("NAME") still works — use it when the name can't be a Python identifier + # or when you prefer explicit strings. -eq.listen(background=True) # consume the Subscribe stream + eq.listen(background=True) # consume the Subscribe stream -eq.control_state # "ONLINE_REMOTE" -eq.request_control_state("HOST_OFFLINE") # operator panel -> maintenance -eq.health() # link / control state / spool depth + 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") + # E90 / E157 material tracking. Milestone / ModuleState are importable + # enums (autocomplete + typo-checked); the equivalent plain strings work too. + eq.report_substrate("WFR-001", Milestone.ARRIVED, carrier_id="FOUP-7", slot=3) + eq.report_substrate("WFR-001", Milestone.AT_WORK) + eq.report_substrate("WFR-001", Milestone.PROCESSING) + eq.report_substrate("WFR-001", Milestone.PROCESSED) + eq.report_substrate("WFR-001", Milestone.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") + eq.report_module("CHAMBER-A", ModuleState.GENERAL_EXECUTING) + eq.report_module("CHAMBER-A", ModuleState.STEP_EXECUTING) + eq.report_module("CHAMBER-A", ModuleState.STEP_COMPLETED) + eq.report_module("CHAMBER-A", ModuleState.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 +Two error channels, by design: a **bad value you control** (a misspelled +milestone, an unknown control state) raises a plain `ValueError`/`NameError` +*before* any round-trip, with a close-match hint; anything the **daemon** +declines (unknown variable name, illegal FSM transition) raises `SecsGemError` +with its explanation (`no variable named 'ChamberPresure'`). Runnable tools: +[mini_tool.py](../clients/python/examples/mini_tool.py) (~25-line quickstart) +and [wafer_tool.py](../clients/python/examples/wafer_tool.py) (E90/E157 +material tracking). The package is validated end-to-end by `interop/pyclient_interop.py` driving the published API while secsgem-py judges the wire. @@ -171,6 +176,23 @@ eq.names.constant.MaxPressure # → "MaxPressure" `dir(eq.names.event)` lists all event names — REPL and IDE autocomplete work out of the box. +### Names vs. enums — one rule + +There are two kinds of identifier in the API, split on whether *your tool* or +*the SEMI standard* owns the value: + +- **Equipment-specific names** — events, alarms, commands, variables, + constants — come from *your* `equipment.yaml`, so they live on the instance + as `eq.names.*` (fetched from the live daemon). +- **Fixed protocol value-sets** — `Milestone`, `ModuleState`, `JobState` — + are defined by the standards, so they're importable enums + (`from secsgem_client import Milestone`). `Milestone.ARRIVED == "ARRIVED"`, + so an enum member and its plain string are interchangeable; the enum just + buys you autocomplete and a typo-checked happy path. + +Either way a wrong value fails fast and helpfully — a `ValueError`/`NameError` +with a close-match suggestion, raised client-side before the wire. + Other languages: generate stubs from the proto (`protoc` supports 11+ languages) and wrap them the same way — the Python client is ~200 lines and is the reference for what a thin wrapper should feel like. diff --git a/interop/pyclient_interop.py b/interop/pyclient_interop.py index 8bf8922..9b541ab 100644 --- a/interop/pyclient_interop.py +++ b/interop/pyclient_interop.py @@ -33,7 +33,7 @@ import secsgem.gem import secsgem.hsms import secsgem.secs -from secsgem_client import Equipment, SecsGemError +from secsgem_client import Equipment, Milestone, ModuleState, SecsGemError LOG = logging.getLogger("pyclient-interop") F = secsgem.secs.functions @@ -175,27 +175,33 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int) -> int: except NameError: check("@eq.command on unknown name raises NameError", True) - # ---- E90 substrate tracking: full journey, then the error path ---- - eq.report_substrate("WFR-PY-1", "ARRIVED", carrier_id="FOUP-PY", slot=4) - eq.report_substrate("WFR-PY-1", "AT_WORK") - eq.report_substrate("WFR-PY-1", "PROCESSING") - eq.report_substrate("WFR-PY-1", "PROCESSED") - eq.report_substrate("WFR-PY-1", "AT_DESTINATION") - check("report_substrate full journey accepted (no raise)", True) + # ---- E90 substrate tracking: enum + string forms, then error paths ---- + eq.report_substrate("WFR-PY-1", Milestone.ARRIVED, + carrier_id="FOUP-PY", slot=4) # enum form + eq.report_substrate("WFR-PY-1", "AT_WORK") # plain string, same thing + eq.report_substrate("WFR-PY-1", Milestone.PROCESSING) + eq.report_substrate("WFR-PY-1", Milestone.PROCESSED) + eq.report_substrate("WFR-PY-1", Milestone.AT_DESTINATION) + check("report_substrate full journey (enum + string) accepted", True) try: - eq.report_substrate("WFR-GHOST", "AT_WORK") # never ARRIVED + eq.report_substrate("WFR-PY-1", "AT_WROK") # typo: client-side reject + check("misspelled milestone raises ValueError", False) + except ValueError as e: + check("misspelled milestone raises ValueError", "AT_WORK" in str(e)) + try: + eq.report_substrate("WFR-GHOST", Milestone.AT_WORK) # never ARRIVED check("report_substrate on unknown wafer raises", False) except SecsGemError as e: check("report_substrate on unknown wafer raises", "WFR-GHOST" in str(e)) # ---- E157 module tracking: walk and reset ---- - eq.report_module("MOD-PY-1", "GENERAL_EXECUTING") - eq.report_module("MOD-PY-1", "STEP_EXECUTING") - eq.report_module("MOD-PY-1", "STEP_COMPLETED") - eq.report_module("MOD-PY-1", "NOT_EXECUTING") + eq.report_module("MOD-PY-1", ModuleState.GENERAL_EXECUTING) + eq.report_module("MOD-PY-1", ModuleState.STEP_EXECUTING) + eq.report_module("MOD-PY-1", ModuleState.STEP_COMPLETED) + eq.report_module("MOD-PY-1", ModuleState.NOT_EXECUTING) check("report_module walk + reset accepted (no raise)", True) try: - eq.report_module("MOD-PY-2", "STEP_EXECUTING") # illegal from idle + eq.report_module("MOD-PY-2", ModuleState.STEP_EXECUTING) # illegal from idle check("report_module illegal jump raises", False) except SecsGemError: check("report_module illegal jump raises", True) diff --git a/tools/run_interop.sh b/tools/run_interop.sh index d27c744..347c23f 100755 --- a/tools/run_interop.sh +++ b/tools/run_interop.sh @@ -98,6 +98,8 @@ note "pyclient: secsgem_client package + secsgem-py host vs secs_gemd" set -e compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \ python3 /app/clients/python/tests/test_values.py + compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \ + python3 /app/clients/python/tests/test_enums.py compose up -d --no-deps gemd sleep 2 compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \