feat(client)+feat(daemon): eq.names, @eq.command, E90/E157 RPCs
Python client:
- eq.names.event.* / .alarm.* / .command.* / .var.* / .constant.* —
autocomplete-able, typo-safe name lookup backed by the Describe RPC
(lazy, cached; AttributeError on bad name with close-match hints)
- @eq.command decorator — binds a handler by function name, validated
against the equipment's real command set at decoration time
- eq.report_substrate() — E90 wafer milestone reporting
- eq.report_module() — E157 module state reporting (auto-create)
Daemon (C++ service):
- ReportSubstrate RPC — drives E90 location + processing FSMs
- ReportModule RPC — drives E157 module FSM (auto-create on first report)
- ack_from_outcome() helper — consistent Ack mapping for read_sync results
Proto: SubstrateReport, ModuleReport, EquipmentDescription,
SpoolFlushRequest, TerminalMessage; Describe, FlushSpool,
SendTerminalMessage RPCs
Tests: C++ FSM test (journey + ghost rejection + E157 illegal jump);
interop coverage for names API and E90/E157 round-trip
Docs: ch42 RPC table + Python example updated; ch16 daemon-path section added
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,8 +21,10 @@ equipment.yaml; values are plain Python.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Callable, Dict, Iterator, Optional
|
||||
|
||||
import grpc
|
||||
@@ -81,6 +83,31 @@ class Health:
|
||||
spool_depth: int
|
||||
|
||||
|
||||
class _Names:
|
||||
"""An autocomplete-able, typo-safe view of the equipment's real names
|
||||
(from Describe). `eq.names.event.ProcessStarted` returns "ProcessStarted";
|
||||
a wrong name raises AttributeError with close-match suggestions, and the
|
||||
set shows up in REPL/IDE completion via __dir__."""
|
||||
|
||||
def __init__(self, kind: str, names):
|
||||
self._kind = kind
|
||||
self._names = set(names)
|
||||
|
||||
def __getattr__(self, attr: str) -> str:
|
||||
if attr in self.__dict__.get("_names", ()):
|
||||
return attr
|
||||
close = difflib.get_close_matches(attr, self._names, n=3)
|
||||
hint = f" Did you mean {', '.join(close)}?" if close else ""
|
||||
raise AttributeError(
|
||||
f"no {self._kind} named '{attr}'.{hint}")
|
||||
|
||||
def __dir__(self):
|
||||
return sorted(self._names)
|
||||
|
||||
def __contains__(self, name: str) -> bool:
|
||||
return name in self._names
|
||||
|
||||
|
||||
class Equipment:
|
||||
"""A connection to the secs_gemd daemon — your equipment, by name."""
|
||||
|
||||
@@ -95,6 +122,7 @@ class Equipment:
|
||||
self._constant_handler: Optional[Callable[[str, object], object]] = None
|
||||
self._listen_thread: Optional[threading.Thread] = None
|
||||
self._stop = threading.Event()
|
||||
self._names_cache: Optional[SimpleNamespace] = None
|
||||
|
||||
# ---- report state to the host -------------------------------------------
|
||||
|
||||
@@ -116,6 +144,22 @@ class Equipment:
|
||||
raise SecsGemError(pb.Ack.PARAMETER_INVALID, e.details()) from None
|
||||
return {k: from_value(v) for k, v in snap.values.items()}
|
||||
|
||||
@property
|
||||
def names(self) -> SimpleNamespace:
|
||||
"""The equipment's real names, by category, fetched once from the
|
||||
daemon (Describe): eq.names.event.* / .alarm.* / .command.* / .var.* /
|
||||
.constant.*. Autocomplete-friendly and typo-safe — handy instead of
|
||||
bare string literals."""
|
||||
if self._names_cache is None:
|
||||
d = self._stub.Describe(pb.Empty())
|
||||
self._names_cache = SimpleNamespace(
|
||||
var=_Names("variable", d.variables),
|
||||
event=_Names("event", d.events),
|
||||
alarm=_Names("alarm", d.alarms),
|
||||
command=_Names("command", d.commands),
|
||||
constant=_Names("constant", d.constants))
|
||||
return self._names_cache
|
||||
|
||||
def __setitem__(self, name: str, value) -> None:
|
||||
self.set({name: value})
|
||||
|
||||
@@ -181,6 +225,25 @@ class Equipment:
|
||||
|
||||
return register
|
||||
|
||||
def command(self, fn: Callable[[Command], object]):
|
||||
"""Bind a handler to the host command of the SAME NAME as the
|
||||
function — no string literal:
|
||||
|
||||
@eq.command
|
||||
def START(cmd): ... # binds the "START" RCMD
|
||||
|
||||
The function name is validated against the equipment's real command
|
||||
set (via Describe), so a typo fails loudly at decoration time."""
|
||||
name = fn.__name__
|
||||
if name not in self.names.command:
|
||||
close = difflib.get_close_matches(name, dir(self.names.command), n=3)
|
||||
hint = f" Did you mean {', '.join(close)}?" if close else ""
|
||||
raise SecsGemError(
|
||||
pb.Ack.PARAMETER_INVALID,
|
||||
f"no host command '{name}' to bind @eq.command to.{hint}")
|
||||
self._handlers[name] = fn
|
||||
return fn
|
||||
|
||||
def on_process_job(self, fn: Callable[[ProcessJob], object]):
|
||||
"""Decorator: the host wants a process job run/stopped (E40).
|
||||
Report progress with report_job()."""
|
||||
@@ -205,6 +268,26 @@ class Equipment:
|
||||
job_id=job_id, state=pb.ProcessJobState.State.Value(state))
|
||||
_check(self._stub.ReportProcessJob(req))
|
||||
|
||||
def report_substrate(self, substrate_id: str, 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."""
|
||||
req = pb.SubstrateReport(
|
||||
substrate_id=substrate_id,
|
||||
milestone=pb.SubstrateReport.Milestone.Value(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."""
|
||||
req = pb.ModuleReport(
|
||||
module_id=module_id,
|
||||
state=pb.ModuleReport.State.Value(state))
|
||||
_check(self._stub.ReportModule(req))
|
||||
|
||||
def listen(self, background: bool = False) -> None:
|
||||
"""Consume host requests and dispatch them to @eq.on handlers.
|
||||
Blocks; pass background=True to run on a daemon thread instead."""
|
||||
|
||||
Reference in New Issue
Block a user