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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user