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()
+5 -1
View File
@@ -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"
+45 -17
View File
@@ -23,13 +23,15 @@ equipment.yaml; values are plain Python.
from __future__ import annotations
import difflib
import enum
import threading
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Callable, Dict, Iterator, Optional
from typing import Callable, Dict, Iterator, Optional, Union
import grpc
from ._enums import JobState, Milestone, ModuleState
from ._proto import equipment_pb2 as pb
from ._proto import equipment_pb2_grpc as rpc
from ._values import from_value, to_value
@@ -48,6 +50,21 @@ def _check(ack: pb.Ack) -> 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:
+48
View File
@@ -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"
+40
View File
@@ -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())