B7 — the daemon's HSMS face under the Java reference: Dockerfile.server now
bakes secs_gemd alongside secs_server (grpc deps in both stages), and
secs4j_validate.sh gains TARGET=gemd to point the 55-check secs4java8 suite
at the daemon instead. Result: 55/55 green. With secsgem-py already
validating both faces, byte-identical GEM between secs_server and secs_gemd
is now proven by both reference implementations, not inferred from shared
code. CI runs the daemon target as an extra step (image layers shared).
Second client — clients/cpp: a header-only C++ twin of the Python client
over the same proto. eq.set("ChamberPressure", 2.5) with bare literals
(integral/floating dispatch avoids variant ambiguity), get/fire/alarm/
clear, control_state/request_control_state/health, on("START", fn) +
listen()/listen_async()/stop() with auto-CompleteCommand, SecsGemError
carrying the daemon's message. cpp_mini_tool (~30 lines) mirrors the
Python mini_tool. Tested end-to-end over real loopback TCP against the
service inside secs_gemd_tests — now 4 cases / 141 assertions — including
set/get round-trips, error text, alarm-by-name into the model, health,
and the full HCACK-4 command loop with parameters.
(Build note: two grpc-heavy TUs at -O3 OOM even at -j2 on Docker Desktop;
built -j1. Known environment limitation, roadmap-documented.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 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) | ✅ | TARGET=gemd interop/secs4j_validate.sh — 55/55 against the daemon's HSMS face; in CI |
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") | ✅ | clients/python (secsgem-client); 13-check interop green via the published API |
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:
TARGET=gemd interop/secs4j_validate.shruns the full 55-check secs4java8 suite against the DAEMON's HSMS face — 55/55 green (secs_gemd and secs_server sit on the same register_default_handlers, so byte-identical GEM is now proven, not assumed). CI step added. The command loop with a live subscriber is covered by the python harnesses (a Java tool-side gRPC client remains possible future work).
Phase C — the beautiful Python client
- ✅
clients/python/— pip-installablesecsgem-client, pure Python, stubs pre-generated (relative-import fixed). The full agreed API:eq.set(ChamberPressure=2.5)kwargs +eq["..."]item syntax,eq.get,eq.fire(event, **data),eq.alarm/eq.clear,eq.control_state,eq.request_control_state,eq.health()/watch_health(), and@eq.on("START")+eq.listen(background=...)with auto-CompleteCommand. Errors raiseSecsGemErrorcarrying the daemon's explanation. PROOF:interop/pyclient_interop.pydrives the PUBLISHED package against a live daemon with secsgem-py as the host — 13 checks all green (S6F11/ S5F1 set+clear on the wire, HCACK-4 command loop through the decorator, operator offline). Conversion layer unit-tested (bool-before-int etc). Wired into tools/run_interop.sh as thepyclientstep. - ✅
clients/python/examples/mini_tool.py(~25 lines) and the C++pvd_toolmigrated to EquipmentRuntime + register_default_handlers +set_handler(1093 -> 570 lines; now serves all 56 handlers; boots verified). Chapter 42 teaches the daemon path. - ✅ C++ client (
clients/cpp): header-only twin of the Python client —eq.set("ChamberPressure", 2.5),eq.on("START", fn)+listen_async(), alarms/health/control-state, SecsGemError. Tested end-to-end over loopback TCP against the real service insidesecs_gemd_tests(141 assertions total), incl. the HCACK-4 loop.cpp_mini_toolis the worked example.
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.