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:
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+20
-14
@@ -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)
|
||||
|
||||
@@ -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 \
|
||||
|
||||
Reference in New Issue
Block a user