feat(client): typo-safe protocol enums + context manager; add wafer_tool example

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:09:48 +02:00
parent 2218b854ce
commit 8a55137e57
12 changed files with 290 additions and 82 deletions
+56
View File
@@ -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()