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:
@@ -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"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user