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 ._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" __version__ = "0.1.0"
+45 -17
View File
@@ -23,13 +23,15 @@ equipment.yaml; values are plain Python.
from __future__ import annotations from __future__ import annotations
import difflib import difflib
import enum
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
from types import SimpleNamespace from types import SimpleNamespace
from typing import Callable, Dict, Iterator, Optional from typing import Callable, Dict, Iterator, Optional, Union
import grpc import grpc
from ._enums import JobState, Milestone, ModuleState
from ._proto import equipment_pb2 as pb from ._proto import equipment_pb2 as pb
from ._proto import equipment_pb2_grpc as rpc from ._proto import equipment_pb2_grpc as rpc
from ._values import from_value, to_value from ._values import from_value, to_value
@@ -48,6 +50,21 @@ def _check(ack: pb.Ack) -> None:
raise SecsGemError(ack.code, ack.message) 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 @dataclass
class Command: class Command:
"""A remote command from the host (e.g. START). The host has already been """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") """Operator-panel transition, e.g. eq.request_control_state("HOST_OFFLINE")
before maintenance. Raises if the E30 table says no from here.""" before maintenance. Raises if the E30 table says no from here."""
req = pb.ControlStateRequest( req = pb.ControlStateRequest(
desired=pb.ControlState.State.Value(desired)) desired=_enum_value(pb.ControlState.State, desired, "control state"))
_check(self._stub.RequestControlState(req)) _check(self._stub.RequestControlState(req))
def health(self) -> Health: def health(self) -> Health:
@@ -259,32 +276,36 @@ class Equipment:
self._constant_handler = fn self._constant_handler = fn
return fn return fn
def report_job(self, job_id: str, state: str) -> None: def report_job(self, job_id: str, state: Union[JobState, str]) -> None:
"""Report physical job progress: "SETTING_UP" | "PROCESSING" | """Report physical job progress (E40). state: a JobState or its name —
"COMPLETE" | "ABORTED". The daemon drives the E40 state machine and SETTING_UP | PROCESSING | COMPLETE | ABORTED. The daemon drives the E40
notifies the host (S16F9).""" state machine and notifies the host (S16F9)."""
req = pb.ProcessJobState( 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)) _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: carrier_id: str = "", slot: int = 0) -> None:
"""E90 wafer tracking. milestone: ARRIVED | AT_WORK | PROCESSING | """E90 wafer tracking. milestone: a Milestone or its name — ARRIVED |
PROCESSED | AT_DESTINATION. The daemon drives the E90 FSMs and emits AT_WORK | PROCESSING | PROCESSED | AT_DESTINATION. ARRIVED records the
the standard CEIDs to the host.""" 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( req = pb.SubstrateReport(
substrate_id=substrate_id, substrate_id=substrate_id,
milestone=pb.SubstrateReport.Milestone.Value(milestone), milestone=_enum_value(pb.SubstrateReport.Milestone, milestone, "milestone"),
carrier_id=carrier_id, slot=slot) carrier_id=carrier_id, slot=slot)
_check(self._stub.ReportSubstrate(req)) _check(self._stub.ReportSubstrate(req))
def report_module(self, module_id: str, state: str) -> None: def report_module(self, module_id: str,
"""E157 module tracking. state: NOT_EXECUTING | GENERAL_EXECUTING | state: Union[ModuleState, str]) -> None:
STEP_EXECUTING | STEP_COMPLETED. Auto-creates the module on first """E157 module tracking. state: a ModuleState or its name —
report.""" NOT_EXECUTING | GENERAL_EXECUTING | STEP_EXECUTING | STEP_COMPLETED.
Auto-creates the module on first report; NOT_EXECUTING resets it."""
req = pb.ModuleReport( req = pb.ModuleReport(
module_id=module_id, module_id=module_id,
state=pb.ModuleReport.State.Value(state)) state=_enum_value(pb.ModuleReport.State, state, "module state"))
_check(self._stub.ReportModule(req)) _check(self._stub.ReportModule(req))
def listen(self, background: bool = False) -> None: def listen(self, background: bool = False) -> None:
@@ -301,6 +322,13 @@ class Equipment:
self._stop.set() self._stop.set()
self._channel.close() self._channel.close()
def __enter__(self) -> "Equipment":
return self
def __exit__(self, *exc) -> bool:
self.close()
return False
# ---- internals ------------------------------------------------------------- # ---- internals -------------------------------------------------------------
def _listen_loop(self) -> None: 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())
+17 -15
View File
@@ -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: E90 and E157 RPCs are wrapped as two methods:
```python ```python
from secsgem_client import Equipment from secsgem_client import Equipment, Milestone, ModuleState
eq = Equipment("localhost:50051") eq = Equipment("localhost:50051")
# E90 — substrate journey (daemon drives FSMs, fires CEIDs automatically) # 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", Milestone.ARRIVED, carrier_id="FOUP-7", slot=3)
eq.report_substrate("WFR-001", "AT_WORK") eq.report_substrate("WFR-001", Milestone.AT_WORK)
eq.report_substrate("WFR-001", "PROCESSING") eq.report_substrate("WFR-001", Milestone.PROCESSING)
eq.report_substrate("WFR-001", "PROCESSED") eq.report_substrate("WFR-001", Milestone.PROCESSED)
eq.report_substrate("WFR-001", "AT_DESTINATION") eq.report_substrate("WFR-001", Milestone.AT_DESTINATION)
# E157 — module state (module is auto-created on first report) # E157 — module state (module is auto-created on first report)
eq.report_module("CHAMBER-A", "GENERAL_EXECUTING") eq.report_module("CHAMBER-A", ModuleState.GENERAL_EXECUTING)
eq.report_module("CHAMBER-A", "STEP_EXECUTING") eq.report_module("CHAMBER-A", ModuleState.STEP_EXECUTING)
eq.report_module("CHAMBER-A", "STEP_COMPLETED") eq.report_module("CHAMBER-A", ModuleState.STEP_COMPLETED)
eq.report_module("CHAMBER-A", "NOT_EXECUTING") eq.report_module("CHAMBER-A", ModuleState.NOT_EXECUTING)
``` ```
Milestones map to the `SubstrateReport.Milestone` protobuf enum; `Milestone` / `ModuleState` are importable enums (each member equals its
module states to `ModuleReport.State`. The daemon's `ReportSubstrate` plain-string name, so `"ARRIVED"` works just as well). The daemon's
handler validates FSM transitions and returns `INVALID_OBJECT` if the `ReportSubstrate` handler validates FSM transitions: a substrate that never
substrate was never `ARRIVED` (which guarantees the daemon owns the `ARRIVED` is rejected with `INVALID_OBJECT`, and a **duplicate** `ARRIVED`
substrate record). 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).
--- ---
+57 -35
View File
@@ -100,52 +100,57 @@ protocol was designed for it.
extension) and the entire integration is: extension) and the entire integration is:
```python ```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.set(ChamberPressure=2.5) # host sees it on its next S1F3
eq["WaferCounter"] = 7 # item syntax, same thing eq["WaferCounter"] = 7 # item syntax, same thing
print(eq.get("ChamberPressure")) # read back through the daemon print(eq.get("ChamberPressure")) # read back through the daemon
# eq.names — autocomplete-able, typo-safe name lookup (fetched from Describe) # 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.fire(eq.names.event.ProcessStarted) # typo → AttributeError at the line it happened
eq.alarm(eq.names.alarm.chiller_temp_high) eq.alarm(eq.names.alarm.chiller_temp_high)
eq.clear(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. # Plain strings still work; names are a convenience, not a requirement.
eq.fire("ProcessStarted", ChamberPressure=2.75) eq.fire("ProcessStarted", ChamberPressure=2.75)
@eq.command # function name IS the command name; @eq.command # function name IS the command name;
def START(cmd): # validated against Describe at decoration time def START(cmd): # validated against Describe at decoration time
run_recipe(cmd.params.get("PPID")) run_recipe(cmd.params.get("PPID"))
eq.fire(eq.names.event.ProcessStarted) eq.fire(eq.names.event.ProcessStarted)
# @eq.on("NAME") still works — use it when the name can't be a Python identifier # @eq.on("NAME") still works — use it when the name can't be a Python identifier
# or when you prefer explicit strings. # 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.control_state # "ONLINE_REMOTE"
eq.request_control_state("HOST_OFFLINE") # operator panel -> maintenance eq.request_control_state("HOST_OFFLINE") # operator panel -> maintenance
eq.health() # link / control state / spool depth eq.health() # link / control state / spool depth
# E90 / E157 material tracking # E90 / E157 material tracking. Milestone / ModuleState are importable
eq.report_substrate("WFR-001", "ARRIVED", carrier_id="FOUP-7", slot=3) # enums (autocomplete + typo-checked); the equivalent plain strings work too.
eq.report_substrate("WFR-001", "AT_WORK") eq.report_substrate("WFR-001", Milestone.ARRIVED, carrier_id="FOUP-7", slot=3)
eq.report_substrate("WFR-001", "PROCESSING") eq.report_substrate("WFR-001", Milestone.AT_WORK)
eq.report_substrate("WFR-001", "PROCESSED") eq.report_substrate("WFR-001", Milestone.PROCESSING)
eq.report_substrate("WFR-001", "AT_DESTINATION") 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", ModuleState.GENERAL_EXECUTING)
eq.report_module("CHAMBER-A", "STEP_EXECUTING") eq.report_module("CHAMBER-A", ModuleState.STEP_EXECUTING)
eq.report_module("CHAMBER-A", "STEP_COMPLETED") eq.report_module("CHAMBER-A", ModuleState.STEP_COMPLETED)
eq.report_module("CHAMBER-A", "NOT_EXECUTING") eq.report_module("CHAMBER-A", ModuleState.NOT_EXECUTING)
``` ```
Anything the daemon declines raises `SecsGemError` with its explanation Two error channels, by design: a **bad value you control** (a misspelled
(`no variable named 'ChamberPresure'`). A complete runnable tool is milestone, an unknown control state) raises a plain `ValueError`/`NameError`
[clients/python/examples/mini_tool.py](../clients/python/examples/mini_tool.py) *before* any round-trip, with a close-match hint; anything the **daemon**
(~25 lines). The package is validated end-to-end by 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 `interop/pyclient_interop.py` driving the published API while secsgem-py
judges the wire. judges the wire.
@@ -171,6 +176,23 @@ eq.names.constant.MaxPressure # → "MaxPressure"
`dir(eq.names.event)` lists all event names — REPL and IDE autocomplete `dir(eq.names.event)` lists all event names — REPL and IDE autocomplete
work out of the box. 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+ Other languages: generate stubs from the proto (`protoc` supports 11+
languages) and wrap them the same way — the Python client is ~200 lines 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. and is the reference for what a thin wrapper should feel like.
+20 -14
View File
@@ -33,7 +33,7 @@ import secsgem.gem
import secsgem.hsms import secsgem.hsms
import secsgem.secs import secsgem.secs
from secsgem_client import Equipment, SecsGemError from secsgem_client import Equipment, Milestone, ModuleState, SecsGemError
LOG = logging.getLogger("pyclient-interop") LOG = logging.getLogger("pyclient-interop")
F = secsgem.secs.functions F = secsgem.secs.functions
@@ -175,27 +175,33 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int) -> int:
except NameError: except NameError:
check("@eq.command on unknown name raises NameError", True) check("@eq.command on unknown name raises NameError", True)
# ---- E90 substrate tracking: full journey, then the error path ---- # ---- E90 substrate tracking: enum + string forms, then error paths ----
eq.report_substrate("WFR-PY-1", "ARRIVED", carrier_id="FOUP-PY", slot=4) eq.report_substrate("WFR-PY-1", Milestone.ARRIVED,
eq.report_substrate("WFR-PY-1", "AT_WORK") carrier_id="FOUP-PY", slot=4) # enum form
eq.report_substrate("WFR-PY-1", "PROCESSING") eq.report_substrate("WFR-PY-1", "AT_WORK") # plain string, same thing
eq.report_substrate("WFR-PY-1", "PROCESSED") eq.report_substrate("WFR-PY-1", Milestone.PROCESSING)
eq.report_substrate("WFR-PY-1", "AT_DESTINATION") eq.report_substrate("WFR-PY-1", Milestone.PROCESSED)
check("report_substrate full journey accepted (no raise)", True) eq.report_substrate("WFR-PY-1", Milestone.AT_DESTINATION)
check("report_substrate full journey (enum + string) accepted", True)
try: 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) check("report_substrate on unknown wafer raises", False)
except SecsGemError as e: except SecsGemError as e:
check("report_substrate on unknown wafer raises", "WFR-GHOST" in str(e)) check("report_substrate on unknown wafer raises", "WFR-GHOST" in str(e))
# ---- E157 module tracking: walk and reset ---- # ---- E157 module tracking: walk and reset ----
eq.report_module("MOD-PY-1", "GENERAL_EXECUTING") eq.report_module("MOD-PY-1", ModuleState.GENERAL_EXECUTING)
eq.report_module("MOD-PY-1", "STEP_EXECUTING") eq.report_module("MOD-PY-1", ModuleState.STEP_EXECUTING)
eq.report_module("MOD-PY-1", "STEP_COMPLETED") eq.report_module("MOD-PY-1", ModuleState.STEP_COMPLETED)
eq.report_module("MOD-PY-1", "NOT_EXECUTING") eq.report_module("MOD-PY-1", ModuleState.NOT_EXECUTING)
check("report_module walk + reset accepted (no raise)", True) check("report_module walk + reset accepted (no raise)", True)
try: 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) check("report_module illegal jump raises", False)
except SecsGemError: except SecsGemError:
check("report_module illegal jump raises", True) check("report_module illegal jump raises", True)
+2
View File
@@ -98,6 +98,8 @@ note "pyclient: secsgem_client package + secsgem-py host vs secs_gemd"
set -e set -e
compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \ compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \
python3 /app/clients/python/tests/test_values.py 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 compose up -d --no-deps gemd
sleep 2 sleep 2
compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \ compose run --rm --no-deps -e PYTHONPATH=/app/clients/python interop \