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