register_default_handlers was a relocated app main(): one 1086-line function, all-or-nothing. It is now 15 per-capability registration functions along the lines GEM itself defines (S1F19): identification, equipment constants, clock, event reports, remote commands, trace/limits, spooling, alarms, exceptions, material tracking (E90/E116/E157), carriers (E87), recipes, object services (E39), jobs (E40/E94), terminal services. A sensor-class tool registers three functions instead of carrying carrier/job handlers it doesn't have; register_default_handlers composes all 15. Each function derives exactly the runtime aliases its handlers use (generated programmatically from the moved bodies with boundary/substitution guards — zero hand-retyping). Magic constants are gone: the control-state/clock SVIDs (were hardcoded 1/2) and the CJ Executing/Completed CEIDs (were 400/401) now come from a "roles:" block in equipment.yaml via EquipmentDescriptor, with historical defaults when absent, loader parsing, and validation (CEID roles must name declared events). The coupling is now visible in ONE file instead of silently split between YAML and C++ — the exact drift class this repo's spec-as-data philosophy exists to kill. Tests: capability subsetting, role-driven SVID refresh via S1F3, roles loader (shipped/custom/absent). Battery: core 473/3087 incl. the 53-handler conformance sweep, daemon 125/125, live GEM300 demo (client exit 0), daemon interop 20/20 vs secsgem-py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 KiB
Vendor Daemon & gRPC API — Status, Known Issues, and Plan to Fab-Readiness
This is a forward-looking roadmap, not a description of shipped behaviour. Every item carries a status marker. Do not read an item as "done" unless it says ✅. (Last full audit: 2026-06-10.)
Status legend: ✅ done · 🚧 in progress · ⬜ planned · ⚠️ risk/unknown
What this is
A vendor-facing daemon (secs_gemd) that runs the SECS/GEM engine as its
own process and exposes a small, name-based, language-agnostic API over gRPC,
so a tool's control software (in any language) can drive the equipment without
linking C++ or knowing SEMI. See proto/secsgem/v1/equipment.proto.
The point of the daemon model: it owns the durable HSMS relationship with the host and stays conformant while the tool software restarts/upgrades/crashes.
Current status (2026-06-10, end of day)
| Piece | Status | Notes |
|---|---|---|
proto/secsgem/v1/equipment.proto |
✅ | v1 surface designed: universal + carrier/recipe/job tiers, Subscribe stream, health |
HostCommandRegistry::set_handler behaviour hook |
✅ | the engine seam for command behaviour; tested |
EquipmentRuntime (engine owner) |
✅ | tested (test_runtime.cpp); secs_server runs entirely on it (live GEM300 demo passes) |
register_default_handlers (the 56 GEM handlers as a library fn) |
✅ | src/gem/default_handlers.cpp; tested (test_default_handlers.cpp) |
| gRPC/protobuf toolchain (Dockerfile + CMake codegen) | ✅ | grpc++ 1.51 / protoc 3.21; opt-in SECSGEM_DAEMON, graceful skip without grpc |
secs_gemd: SetVariables / FireEvent / GetControlState / GetVariables |
✅ | format-aware both directions (declared SECS-II formats on write, from_item on read) and thread-safe (snapshot maps + posted writes + read_sync reads). In-process gRPC tests incl. run_async production mode (test_daemon_service.cpp, 61 assertions) |
| Daemon interop vs secsgem-py reference host | ✅ | interop/daemon_interop.py (via gemd compose service): gRPC SetVariables(ChamberPressure=2.5) + FireEvent → host receives S6F11 CEID 300 carrying <F4 2.5> — value and declared format flow gRPC→engine→HSMS→host |
| Daemon interop vs secs4j (Java) | ⬜ | mirror the secsgem-py harness against interop/secs4j |
Subscribe host→tool command stream + CompleteCommand |
✅ | HCACK-4 contract implemented + tested in-process AND live vs secsgem-py (full loop: S2F41 → stream → complete → S6F11) |
| Universal RPC surface complete (vars/events/alarms/control-state/health) | ✅ | Phase A done; daemon tests 101 assertions, interop 15 checks |
| Python client package (the "beautiful API") | ⬜ | thin wrapper over generated stubs |
Known issues (found in the 2026-06-10 audit; honest list)
- ✅
Fixed 2026-06-10: the runtime keeps an atomic control-state mirror updated via anGetControlStatecross-thread read.add_state_change_handlerobserver (HandlerSlotprimary+observers pattern), so the mirror survivesregister_default_handlersclaiming the primary slot.control_state()is now safe from any thread. - ✅
Alarms have no name key.Optionalname:added to the alarm config (loader + validator + shipped equipment.yaml); daemon RPCs accept the name or the stringified ALID. - ⬜
pvd_toolpredates the behaviour hook AND the runtime. It still hard-codes START behaviour in a router handler and hand-wires its own main(). Migrate it to EquipmentRuntime + per-capability registration +commands.set_handlerso the flagship example showcases the intended integration shape. (Phase C item 9.) - ✅
Interop harnesses are manual.tools/run_interop.shruns all nine validation steps with one command (verified green); CI lanes added, pending first-push verification (Phase 0 item 2). - ✅
TSan lane doesn't cover the daemon.Covered locally + in CI withtools/tsan.supp(third-party-only suppressions). Caught + fixed a real test-side contract violation on its first run. - ⚠️ macOS bind-mount staleness can break Docker builds mid-edit (a build reading a half-synced source file). Not a product bug; re-run the build.
The Subscribe design (settled — implement to this)
S2F42 is an acknowledgement, not a completion: SEMI separates "I accept
your command" from "the work finished". The conformant, non-blocking flow:
- Host sends
S2F41 START. The engine'son_commandhandler (registered by the daemon) runs on the io thread. - If no tool client is subscribed → fall back to the YAML declarative ack.
If a tool is subscribed → push the command onto its
Subscribestream and returnHCACK=4(AcceptedWillFinishLater) immediately — never block the io thread or the T3 window on the tool. - The tool does the work and reports the outcome via
FireEvent(success event) /SetAlarm(failure) — exactly how secsgem-py applications and commercial gateways do it. CompleteCommandtherefore only correlates/audits the command lifecycle in v1. A synchronous gating mode (tool decides HCACK 0/2 before the S2F42 goes out) requires a deferred-reply mechanism in the engine — explicitly a v2 refinement, not needed for conformance.
Sub-decisions (settled 2026-06-10, implemented + tested):
- v1 is a firehose: every subscriber receives every host request.
- NO buffering: with no subscriber a command takes its declarative YAML ack and is not replayed on reconnect — never "will finish later" for work no tool will do. Documented in the proto's Subscribe contract.
Plan — ordered next steps
Phase 0 — structural debts (from the 2026-06-10 design review; pay before sprinting)
The review's verdict: architecture and API bets are sound, but two structural debts tax every later phase, and the most valuable tests aren't automated.
- 🚧 Multi-observer callbacks (THE structural blocker — hit twice already).
HandlerSlot(primary slot keeps legacy set_ semantics; append-only add_ observers survive it) — done forControlStateMachine+ PJ/CJ stores, plus runtime atomic control-state mirror (race retired) andadd_link_observer(WatchHealth foundation). ⬜ Remaining: roll the same 3-line pattern onto the other single-slot classes (comm-state, EPT, exceptions, substrates, modules, carriers, E84) as each phase needs them — mechanical now that the type exists. - 🚧 CI the interop + conformance harnesses.
tools/run_interop.sh✅ — one command runs ALL nine validation steps (build, unit, daemon-unit, py-host 31 checks, conformance 47, daemon bridge, spool restart, tshark, secs4j 55) with a PASS/FAIL summary; verified green end-to-end 2026-06-10. CI ✅ added but UNVERIFIED until pushed: grpc deps +secs_gemd_testsin the build job (fails loudly if the daemon silently drops out), and a newpython-interoplane (py-host + conformance + daemon harness against localhost, no docker-in-docker). ⬜ Verify the lanes on the first push. - ✅ Fix
CompleteCommandproto comment — it described the rejected blocking model; now states the HCACK-4 contract. - ✅ Table-driven handler conformance test — one ordered scenario
drives 53 of the 56 handlers through
router.dispatch(236 assertions). Golden frames: S1F13, S5F1, and a composed S6F11, all hand-computed from E5 rules (external pins, not codec-derived). - ✅ Decomposed
register_default_handlersinto 15 per-capability functions (identification, ECs, clock, event reports, remote commands, trace/limits, spooling, alarms, exceptions, material tracking, carriers, recipes, object services, jobs, terminal) — vendors register only what their equipment is;register_default_handlers= all 15. Magic constants replaced by YAML role bindings (roles:block — control_state_svid, clock_svid, cj_executing_ceid, cj_completed_ceid) parsed into the descriptor with historical defaults, validated (CEID roles must be declared). Tested: subset registration, role-driven SVID refresh, roles loader (present/custom/absent); full battery green (473/3087 core incl. the 53-handler sweep, live GEM300 demo, 20-check daemon interop). - ✅ Standardize the mutable-read pattern —
EquipmentRuntime::read_sync(post-to-io + future with deadline; nullopt => UNAVAILABLE at the RPC edge). Precedent set byGetVariables; every future mutable read copies it. - ✅
equipment_service.hppmoved toinclude/secsgem/daemon/(apps/ include-path hack removed). TSan daemon lane added locally + in CI (tools/tsan.suppsuppresses UNinstrumented system libgrpc/libabsl internals only — our frames stay checked). The lane caught a real contract violation on its first run (a test reading the model from the test thread under run_async — fixed to read_sync); now TSan-clean with halt_on_error=1. - ✅ Identifier-safe name validation:
ConfigValidatorwarns (not errors) on non-identifier variable/event/alarm/command names — bindings expose names as kwargs/attributes. Format-compliance property test ✅; unset-Valueguard ✅.
Phase A — finish the universal daemon surface (small, unblock vendors)
- ✅
GetVariables—from_itemreverse conversion (scalar for 1-element arrays, List otherwise; C2-as-text and U8>2^63 noted as TODOs) + reads viaread_sync. Tested under run_async (production threading) — write through the API, read back through the API — plus empty-query-returns-all, INVALID_ARGUMENT on unknown names, and a live round-trip check indaemon_interop.py. - ✅ Alarm
name:config field (optional local key;nameappended LAST on the Alarm struct so existing brace-inits compile unchanged) +SetAlarm/ClearAlarmRPCs (addressable by config name AND stringified ALID). Validated end-to-end: gRPCSetAlarm(chiller_temp_high)-> secsgem-py host receivesS5F1 ALCD=0x84 ALID=1. - ✅
RequestControlState— fires operator events on the io thread and reports what the E30 table actually did (ACCEPT iff landed in the requested state; the shipped table has NO operator path to EquipmentOffline and the test pins that honesty). ✅WatchHealth— initial snapshot + push on link/control-state change (+ spool depth sampled at 500ms); unit-tested incl. the change push; link state still SELECTED/DISCONNECTED only (CONNECTED reserved, TODO in code). Interop covers RequestControlState; WatchHealth external check rides with Phase B. - ✅ Done per-item above (daemon suite at 101 assertions; interop at 15 checks).
Phase B — the command stream (the big one)
- ✅
Subscribe/CompleteCommandimplemented per the HCACK-4 design. Reconnect decision settled and documented in the proto: no buffering — a command with no subscriber takes its declarative YAML ack (the honest pre-daemon behaviour) and is not replayed. Firehose fan-out; per-command forwarding handlers registered from the registry (newnames()/spec()accessors); pending-id audit map. In-process tests drive a REAL S2F41 through the default-handler router on the io thread: HCACK 4 with a subscriber (params arrive on the stream), declarative Accept without, CompleteCommand known/unknown ids, fallback restored after unsubscribe. - ✅ The full conformant loop runs against secsgem-py live: host
S2F41 START→S2F42 HCACK=4→ tool receives Command(name=START, id) on the stream →CompleteCommand→ tool fires the event → host receivesS6F11. (interop now 20 checks.) - ⬜ Java interop:
secs4jhost variant of the same scenario.
Phase C — the beautiful Python client
- ⬜
clients/python/package (pip install secsgem-client): wraps generated stubs in the agreed API —eq.set(chamber_pressure=2.5),eq.fire("wafer_complete", thickness=1.2),eq.alarm("pressure_high"),@eq.on("START")consuming the stream,eq.health(). Pure Python (no compiled ext). Ship stubs pre-generated. - ⬜ Example: rewrite a minimal
pvd_tool-equivalent in ~40 lines of Python against the daemon; also migrate the C++pvd_tooltoset_handler.
Phase D — GEM300 in-the-loop (process/carrier tools)
- ⬜ Settle job/carrier semantics (who acks S16F5/S3F17, gate vs observe —
see proto comments), then wire
ProcessJob/CarrierActiononto the stream +ReportProcessJob/ReportCarrierinto the PJ/CJ/carrier stores. - ⬜ Recipe download (
ProcessProgramon the stream when S7F3 lands) and EC-change notification (ConstantChangewhen S2F15 lands). - ⬜ Interop scenarios for jobs/carriers vs secsgem-py + secs4j.
Phase E — hardening & operations
- ⬜ gRPC exposure: default to localhost + document UDS; optional TLS creds.
- ⬜
tools/run_interop.sh+ CI lanes: all interop harnesses + TSan daemon lane. - ⬜ Daemon Prometheus metrics + supervised deployment recipe (systemd unit).
- ⬜ Remaining Layer-1 API: traces, limits, substrates/modules, terminal
services, spool depth/flush,
DescribeRPC.
Phase F — fab acceptance (parallel track; the hard gate)
- ⚠️ Standards correctness remains unverified against SEMI texts (behaviour
reconstructed without the standards; interop with secsgem-py/secs4j/Wireshark
mitigates but does not prove). The #1 fab-readiness risk; needs real
standards access and/or a fab's MES qualification run (
docs/MES_INTEROP.md). - ⬜ GEM compliance statement + manual matching the tool's data dictionary.
- ⬜ SECS-I serial driver (asio
serial_portadapter; FSM done) — only if a target tool uses RS-232.