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:
2026-06-26 21:43:07 +02:00
parent 9876dd9b5a
commit a2ebbf7c65
14 changed files with 602 additions and 27 deletions
+83
View File
@@ -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."""
File diff suppressed because one or more lines are too long
@@ -94,11 +94,36 @@ class EquipmentStub(object):
request_serializer=equipment__pb2.CarrierState.SerializeToString,
response_deserializer=equipment__pb2.Ack.FromString,
)
self.ReportSubstrate = channel.unary_unary(
'/secsgem.v1.Equipment/ReportSubstrate',
request_serializer=equipment__pb2.SubstrateReport.SerializeToString,
response_deserializer=equipment__pb2.Ack.FromString,
)
self.ReportModule = channel.unary_unary(
'/secsgem.v1.Equipment/ReportModule',
request_serializer=equipment__pb2.ModuleReport.SerializeToString,
response_deserializer=equipment__pb2.Ack.FromString,
)
self.WatchHealth = channel.unary_stream(
'/secsgem.v1.Equipment/WatchHealth',
request_serializer=equipment__pb2.Empty.SerializeToString,
response_deserializer=equipment__pb2.Health.FromString,
)
self.Describe = channel.unary_unary(
'/secsgem.v1.Equipment/Describe',
request_serializer=equipment__pb2.Empty.SerializeToString,
response_deserializer=equipment__pb2.EquipmentDescription.FromString,
)
self.FlushSpool = channel.unary_unary(
'/secsgem.v1.Equipment/FlushSpool',
request_serializer=equipment__pb2.SpoolFlushRequest.SerializeToString,
response_deserializer=equipment__pb2.Ack.FromString,
)
self.SendTerminalMessage = channel.unary_unary(
'/secsgem.v1.Equipment/SendTerminalMessage',
request_serializer=equipment__pb2.TerminalMessage.SerializeToString,
response_deserializer=equipment__pb2.Ack.FromString,
)
class EquipmentServicer(object):
@@ -227,7 +252,30 @@ class EquipmentServicer(object):
raise NotImplementedError('Method not implemented!')
def ReportCarrier(self, request, context):
"""E87 — carrier-based tools
"""E87 — carrier-based tools. WAITING announces a physically-arrived
carrier (creates it; idempotent — re-announce updates the slot map);
IN_ACCESS / COMPLETE drive the access FSM. The host's S3F17 decisions
(ProceedWithCarrier / CancelCarrier) come back on the Subscribe stream
as CarrierAction.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ReportSubstrate(self, request, context):
"""E90 — substrate (wafer) tracking. Report each milestone of a wafer's
journey; the daemon drives the E90 FSMs and emits the standard CEIDs to
the host. ARRIVED creates the substrate. Only for tools that track
individual substrates.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def ReportModule(self, request, context):
"""E157 — module process tracking. Report a module's execution state; the
daemon drives the E157 FSM and emits the standard CEIDs. The module is
auto-created on first report. Only for tools with tracked modules.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
@@ -243,6 +291,30 @@ class EquipmentServicer(object):
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def Describe(self, request, context):
"""Everything this equipment is configured with, by name — for tooling,
diagnostics, and client-side validation/autocomplete.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def FlushSpool(self, request, context):
"""Flush the spool: purge=true discards queued messages, purge=false drains
them toward the host (requires a SELECTED session).
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def SendTerminalMessage(self, request, context):
"""Equipment-initiated operator message to the host (S10F1). Fails with
CANNOT_DO_NOW when no host is connected and stream 10 isn't spoolable.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_EquipmentServicer_to_server(servicer, server):
rpc_method_handlers = {
@@ -301,11 +373,36 @@ def add_EquipmentServicer_to_server(servicer, server):
request_deserializer=equipment__pb2.CarrierState.FromString,
response_serializer=equipment__pb2.Ack.SerializeToString,
),
'ReportSubstrate': grpc.unary_unary_rpc_method_handler(
servicer.ReportSubstrate,
request_deserializer=equipment__pb2.SubstrateReport.FromString,
response_serializer=equipment__pb2.Ack.SerializeToString,
),
'ReportModule': grpc.unary_unary_rpc_method_handler(
servicer.ReportModule,
request_deserializer=equipment__pb2.ModuleReport.FromString,
response_serializer=equipment__pb2.Ack.SerializeToString,
),
'WatchHealth': grpc.unary_stream_rpc_method_handler(
servicer.WatchHealth,
request_deserializer=equipment__pb2.Empty.FromString,
response_serializer=equipment__pb2.Health.SerializeToString,
),
'Describe': grpc.unary_unary_rpc_method_handler(
servicer.Describe,
request_deserializer=equipment__pb2.Empty.FromString,
response_serializer=equipment__pb2.EquipmentDescription.SerializeToString,
),
'FlushSpool': grpc.unary_unary_rpc_method_handler(
servicer.FlushSpool,
request_deserializer=equipment__pb2.SpoolFlushRequest.FromString,
response_serializer=equipment__pb2.Ack.SerializeToString,
),
'SendTerminalMessage': grpc.unary_unary_rpc_method_handler(
servicer.SendTerminalMessage,
request_deserializer=equipment__pb2.TerminalMessage.FromString,
response_serializer=equipment__pb2.Ack.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'secsgem.v1.Equipment', rpc_method_handlers)
@@ -528,6 +625,40 @@ class Equipment(object):
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def ReportSubstrate(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/ReportSubstrate',
equipment__pb2.SubstrateReport.SerializeToString,
equipment__pb2.Ack.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def ReportModule(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/ReportModule',
equipment__pb2.ModuleReport.SerializeToString,
equipment__pb2.Ack.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def WatchHealth(request,
target,
@@ -544,3 +675,54 @@ class Equipment(object):
equipment__pb2.Health.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def Describe(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/Describe',
equipment__pb2.Empty.SerializeToString,
equipment__pb2.EquipmentDescription.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def FlushSpool(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/FlushSpool',
equipment__pb2.SpoolFlushRequest.SerializeToString,
equipment__pb2.Ack.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
@staticmethod
def SendTerminalMessage(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(request, target, '/secsgem.v1.Equipment/SendTerminalMessage',
equipment__pb2.TerminalMessage.SerializeToString,
equipment__pb2.Ack.FromString,
options, channel_credentials,
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)