feat(daemon): Phase D — GEM300 in-the-loop (jobs, recipes, EC changes)

Semantics settled and documented: v1 is observe-and-report. The engine keeps
acking S16/S3/S7/S2F15 from its FSM tables — exactly the behaviour both
reference implementations validated — while the tool observes lifecycle
events on the Subscribe stream and reports physical progress back. Gating
stays the documented v2 deferred-reply item.

Engine: two new store observers (HandlerSlot pattern) — RecipeStore fires
(ppid, body) after an add (S7F3 downloads), EquipmentConstantStore fires
(id, value) on ACCEPTED S2F15 writes only. Unit-tested.

Daemon: the service registers PJ/recipe/EC observers (io thread; add_
observers coexist with register_default_handlers' primaries) and fans the
new HostRequest variants out via push_request (fire-and-forget, no-
buffering contract). ProcessJob carries action (Start->START, Resume->
RESUME, Paused->PAUSE, Stopping->STOP, Aborting->ABORT) + recipe + material
bindings read store-side on the io thread. ReportProcessJob maps SETTING_UP
->SetupComplete, COMPLETE->ProcessComplete, ABORTED->AbortComplete via
read_sync; PROCESSING is informational; unknown job => INVALID_OBJECT,
table-rejected transition => CANNOT_DO_NOW. Carriers deferred (CarrierStore
has no observer machinery; ReportCarrier stays UNIMPLEMENTED) — roadmap.

Python client: on_process_job / on_recipe / on_constant_change decorators +
report_job(job_id, state); ProcessJob dataclass exported.

Tests: daemon suite 141 -> 175 assertions — the full in-process loop
(S16F11 create -> tool setup -> S16F5 PJSTART -> stream ProcessJob with
recipe+carriers -> ReportProcessJob(COMPLETE) -> FSM at ProcessComplete),
rejection paths, S7F3 -> ProcessProgram, S2F15 -> ConstantChange with the
configured name. Core 475/3097 (observer units). Live regression: daemon
interop 20 checks + pyclient 13 checks still green against the running
daemon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 23:53:45 +02:00
parent b30443089f
commit b1772cfefd
10 changed files with 333 additions and 10 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ stream/function, a format code, or a numeric id — just the names from your
equipment.yaml.
"""
from ._client import Command, Equipment, Health, SecsGemError
from ._client import Command, Equipment, Health, ProcessJob, SecsGemError
__all__ = ["Equipment", "Command", "Health", "SecsGemError"]
__all__ = ["Equipment", "Command", "Health", "ProcessJob", "SecsGemError"]
__version__ = "0.1.0"
+53 -1
View File
@@ -63,6 +63,17 @@ class Command:
self._done = True
@dataclass
class ProcessJob:
"""An E40 process job the host wants run (or stopped/aborted). Do the
physical work, then report progress with Equipment.report_job()."""
id: str
action: str # "START" | "STOP" | "PAUSE" | "RESUME" | "ABORT"
recipe: str
carriers: list
@dataclass
class Health:
link: str # "DISCONNECTED" | "CONNECTED" | "SELECTED"
@@ -79,6 +90,9 @@ class Equipment:
grpc.channel_ready_future(self._channel).result(timeout=connect_timeout)
self._stub = rpc.EquipmentStub(self._channel)
self._handlers: Dict[str, Callable[[Command], object]] = {}
self._job_handler: Optional[Callable[[ProcessJob], object]] = None
self._recipe_handler: Optional[Callable[[str, bytes], object]] = None
self._constant_handler: Optional[Callable[[str, object], object]] = None
self._listen_thread: Optional[threading.Thread] = None
self._stop = threading.Event()
@@ -167,6 +181,30 @@ class Equipment:
return register
def on_process_job(self, fn: Callable[[ProcessJob], object]):
"""Decorator: the host wants a process job run/stopped (E40).
Report progress with report_job()."""
self._job_handler = fn
return fn
def on_recipe(self, fn: Callable[[str, bytes], object]):
"""Decorator: the host downloaded a recipe (ppid, body)."""
self._recipe_handler = fn
return fn
def on_constant_change(self, fn: Callable[[str, object], object]):
"""Decorator: the host changed an equipment constant (name, value)."""
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)."""
req = pb.ProcessJobState(
job_id=job_id, state=pb.ProcessJobState.State.Value(state))
_check(self._stub.ReportProcessJob(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."""
@@ -188,8 +226,22 @@ class Equipment:
for hr in self._stub.Subscribe(pb.SubscribeRequest(client="secsgem_client")):
if self._stop.is_set():
return
if hr.HasField("process_job") and self._job_handler:
j = hr.process_job
self._job_handler(ProcessJob(
j.job_id, pb.ProcessJob.Action.Name(j.action),
j.recipe, list(j.carriers)))
continue
if hr.HasField("process_program") and self._recipe_handler:
self._recipe_handler(hr.process_program.ppid,
hr.process_program.body)
continue
if hr.HasField("constant") and self._constant_handler:
self._constant_handler(hr.constant.name,
from_value(hr.constant.value))
continue
if not hr.HasField("command"):
continue # future HostRequest variants (jobs, carriers, ...)
continue # carriers etc.: future variants
cmd = hr.command
handler = self._handlers.get(cmd.name) or self._handlers.get("*")
command = Command(cmd.id, cmd.name,