docs: chapters 02 + 03 of the guided tour (Part 1 complete)

02 — The cast of characters: equipment, EAP, MES, fab planner, AMHS,
operator.  Who initiates which conversation, why the equipment is
the passive side of HSMS by convention, how the AMHS handshake is
out-of-band relative to SECS.  Cross-references the relevant
namespace and test files for each actor.

03 — Vocabulary + a wafer's journey: follows one 300 mm wafer
end-to-end through a fab and labels every SECS message and acronym
that fires.  Introduces SVID / DVID / ECID / CEID / RPTID / ALID /
PPID / MDLN / SOFTREV / HCACK / ALCD / OFLACK / CAACK / SMACK / etc.
in context rather than as a list.  Includes one-screen reference
tables for the remaining acknowledge codes, T-timers in all four
contexts (HSMS / SECS-I / E84 / E30 communication state), and a
stream-by-stream summary.

Part 1 (Foundations) of the guided tour is now complete — a reader
who reads chapters 01–03 can describe the protocol stack, identify
the actors, and recognise every acronym they'll meet in Part 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 19:35:43 +02:00
parent bc54de7711
commit 60fa164626
12 changed files with 1009 additions and 0 deletions
+715
View File
@@ -0,0 +1,715 @@
# Integration tutorial
How a semiconductor equipment vendor takes this library and turns it
into a SECS/GEM-compliant interface on a real tool.
The library gives you **the runtime stack** — wire codecs, the HSMS
connection state machine, every GEM 300 sub-state-machine, persistent
stores, the message catalog, and a dispatcher. What you bring is
**the application**: knowledge of your tool's real sensors, recipes,
alarms, processing states, and chamber I/O. This guide walks through
how those two halves meet.
> **Audience.** Firmware / controls engineers integrating
> SECS/GEM on a tool for the first time. Familiarity with SEMI
> E5/E30/E37 helps but isn't required — every spec reference is
> pinned in `COMPLIANCE.md`.
---
## 1. What you get vs. what you build
```
┌───────────────────────────────────────────────────────────┐
│ your equipment application (you write) │
│ recipe runner • sensor polling • alarm sources • UI hooks│
├───────────────────────────────────────────────────────────┤
│ secs-gem runtime stack (this library) │
│ data model • FSMs • SECS-II codec • HSMS connection │
│ message catalog • routers • persistence • spool │
├───────────────────────────────────────────────────────────┤
│ OS + Asio (provided) + your serial/Ethernet driver │
└───────────────────────────────────────────────────────────┘
```
The boundary lives at three classes:
- `gem::EquipmentDataModel` — the data dictionary (SVIDs, ECIDs,
CEIDs, alarms, recipes, jobs, carriers, substrates …). Your
application reads/writes it; the dispatcher serves it on the wire.
- `gem::Router` — maps `(stream, function) → handler`. Wire it once
at startup; messages flow through it.
- `hsms::Connection` (or `secsi::TcpTransport` for SECS-I) — the
byte-level transport. You feed it a TCP socket and a router; it
runs.
You don't subclass the FSMs. You don't write parsers. You don't
patch the dispatcher. Your code lives in two places: **YAML**
(static configuration) and **callbacks** (dynamic glue).
---
## 2. The 30-minute first connection
The shortest path from `git clone` to "a host can talk to my tool":
### 2.1. Describe your tool in YAML
Copy `data/equipment.yaml`, rename to your tool, and edit:
```yaml
device:
id: 1 # E37 SESSION-ID
model_name: "ACME-PVD-3000"
software_rev: "1.4.2"
equipment_type: "PVD" # S1F20 EQPTYP
svids: # status variables (read-only)
- {id: 1, name: ControlState, units: "", type: ASCII, value: ""}
- {id: 100, name: ChamberPressureTorr, units: "Torr", type: F4, value: 0.0}
- {id: 101, name: WaferCounter, units: "wafer", type: U4, value: 0}
ecids: # equipment constants (host can set)
- {id: 10, name: ChamberSetpointTorr, units: "Torr", type: F4,
value: 1.0e-6, min: "1.0e-9", max: "1.0"}
ceids: # collection events
- {id: 300, name: ProcessStarted}
- {id: 301, name: ProcessCompleted}
alarms:
- {id: 1, text: "Chamber pressure out of range", category: 4}
recipes:
- {id: "RECIPE-A", body: "STEP HEAT 350C 60s\nEND"}
host_commands:
- {name: START, ack: Accept, emit_ceid: 300}
- {name: STOP, ack: Accept}
```
That's the GEM data dictionary. The library will serve every
S1F3/F11, S2F13/F29, S2F33-F38, S5F5, S7F19, S2F41, etc. against
this YAML without any C++ changes.
### 2.2. Stand it up
A minimal `main()` looks like `apps/secs_server.cpp`. In your code:
```cpp
auto model = std::make_shared<gem::EquipmentDataModel>();
auto desc = config::load_equipment("/etc/acme/equipment.yaml", *model);
auto sm = std::make_shared<gem::ControlStateMachine>(
gem::ControlStateMachine::default_table(),
gem::ControlState::HostOffline);
asio::io_context io;
Server server(io, {/*port=*/5000, desc.device_id, {}});
server.on_accept([&](std::shared_ptr<hsms::Connection> conn) {
auto router = std::make_shared<gem::Router>();
register_default_handlers(*router, model, sm, conn); // your function
conn->set_message_handler([router, conn](const secs2::Message& m) {
return router->dispatch_with_s9(
[&](uint8_t f, const std::array<uint8_t, 10>& mhead) {
conn->emit_s9(f, mhead);
},
[&]() -> std::optional<std::array<uint8_t, 10>> {
auto* h = conn->current_header();
return h ? std::optional{h->encode()} : std::nullopt;
}, m);
});
});
server.start();
io.run();
```
`register_default_handlers` is the only piece of glue you write at
the start. The repo's `apps/secs_server.cpp` is a complete worked
example — copy it verbatim, then customize the YAML to your tool.
### 2.3. Validate before you run
YAML edits are easy to get wrong: an unknown SECS-II type, a
duplicate ID, a `host_command` referencing a CEID you forgot to
declare. The server has a `--validate-config` mode that reads every
YAML, accumulates *every* problem it can find, prints them with file
and line number, and exits 0 / 1 without binding the port:
```sh
secs_server --validate-config \
--config /etc/acme/equipment.yaml \
--state-table /etc/acme/control_state.yaml \
--pj-state-table /etc/acme/process_job_state.yaml \
--cj-state-table /etc/acme/control_job_state.yaml
# [error] equipment.yaml:5 svids[0].type — unknown SECS-II type `WTF`
# [error] equipment.yaml:7 alarms[0].category — value 200 out of range [0, 127]
# [error] equipment.yaml:9 host_commands[0].emit_ceid — CEID 999 not declared in `ceids` section
# 3 error(s), 0 warning(s) across 4 files
```
Run this in CI on every config change and you skip the slow
load-fail-edit-restart loop the first deployment otherwise becomes.
### 2.4. Run it
```sh
docker compose up server # or your own deployment
# host fires up secsgem-py / wonderware / equipment manager:
# selects, S1F13, S1F1, S1F3 → you're talking GEM.
```
That's the floor. From here, every section below adds capability.
---
## 3. Wiring real sensors to SVIDs
The YAML's `value:` field is the *initial* value. Your application
updates the live value as the tool runs.
> **Thread-safety contract.** Every store in `EquipmentDataModel` is
> single-threaded by design: there are no locks. All access — reads
> from the dispatcher, writes from your application — must run on the
> io_context that drives the HSMS connection. If your sensor polls
> live on a different thread (typical), marshal the update via
> `asio::post`:
>
> ```cpp
> // Sensor-poll thread (separate from the io_context thread):
> double torr = read_baratron();
> asio::post(io.get_executor(), [model, torr] {
> model->svids.set_value(/*ChamberPressure=*/100,
> secs2::Item::f4(float(torr)));
> });
> ```
>
> Calling `set_value(...)` directly from the sensor thread is a data
> race against the dispatcher reading the same SVID for an inbound
> S1F3 — the library has no mutex to defend you. This is also true
> for every `set_*_change_handler` callback you register: those fire
> on the io_context thread, and any state observers (metrics
> exporters, log shippers) must be thread-safe themselves or must
> hand the work off.
Two patterns scale well:
1. **One updater per sensor, fixed cadence.** Each sensor's driver
owns the (vid, set_value) pair and `asio::post`s into the io_context.
2. **A single refresh tick.** A periodic timer dumps all polled
values at once (`refresh()` in `apps/secs_server.cpp` does this
for two virtual SVIDs). Because the periodic timer runs *on* the
io_context, no posting is needed.
The SECS-II Item shape must match the YAML's `type:`. If the YAML
says `F4` and you call `set_value(100, secs2::Item::ascii("..."))`,
the host will get the string back — the library doesn't enforce a
runtime check. Treat the YAML type as a contract you maintain.
---
## 4. Plugging the FSMs into your tool
Every GEM 300 sub-state-machine in the library is a behavior model.
You decide *when* state transitions happen by firing events:
### 4.1. Equipment processing (E116 EPT)
```cpp
// At startup or whenever the operator clicks "Run":
model->ept.on_event(gem::EptEvent::EnterStandby);
model->ept.on_event(gem::EptEvent::EnterProductive);
// Auto-emit S6F11(ControlEvent_*) on every transition:
model->ept.set_state_change_handler(
[&](gem::EptState, gem::EptState to, gem::EptEvent,
std::chrono::milliseconds /*dwell*/) {
uint32_t ceid = ept_state_to_ceid(to); // your switch/case
if (!ceid || !model->is_event_enabled(ceid)) return;
conn->send_data(gem::s6f11_event_report(
next_dataid++, ceid, model->compose_reports_for(ceid)));
});
```
Helpers:
- `model->ept.accumulated(state)` — total milliseconds spent in
`state` since startup. Use it to populate E116 SVIDs.
- `model->ept.reset_history()` — call at shift boundary.
### 4.2. Carriers + load ports (E87)
When AMHS delivers a carrier:
```cpp
model->carriers.create("CAR-A1B2", /*port=*/1, /*capacity=*/25);
model->carriers.fire_id_event("CAR-A1B2", gem::CarrierIDEvent::Read);
// ... host sends S3F17(ProceedWithCarrier), the registered handler
// in the Router calls fire_id_event(..., ProceedWithCarrier) and
// CIDS moves NotConfirmed → Confirmed.
```
When your slot-map scanner finishes:
```cpp
auto* c = model->carriers.get("CAR-A1B2");
for (std::size_t i = 0; i < scan_result.size(); ++i)
c->slots[i].state = scan_result[i]; // 0 empty, 1 occupied
model->carriers.fire_slot_map_event("CAR-A1B2", gem::SlotMapEvent::Read);
```
The S3F19/F20 verify handler will compare against this map.
### 4.3. Substrates (E90)
For each wafer you start tracking:
```cpp
model->substrates.create("W-2024-001", "CAR-A1B2", /*slot=*/1);
model->substrates.fire_location_event(
"W-2024-001", gem::SubstrateEvent::Acquire, /*location=*/"ChamberA");
model->substrates.fire_processing_event(
"W-2024-001", gem::SubstrateProcessingEvent::StartProcessing);
// ... when done:
model->substrates.fire_processing_event(
"W-2024-001", gem::SubstrateProcessingEvent::EndProcessing);
model->substrates.fire_location_event(
"W-2024-001", gem::SubstrateEvent::Release, /*location=*/"OutCarrier");
```
History is tracked per-substrate (`model->substrates.history(id)`)
and can power your downtime / yield reports.
### 4.4. Process jobs + control jobs (E40 / E94)
The host creates these via S16F11 / S14F9. Your application drives
their internal transitions as the recipe engine progresses:
```cpp
// Recipe runner reports setup done:
model->process_jobs.fire_internal("PJ-1", gem::ProcessJobEvent::SetupComplete);
// Operator hits Start (or autorun is on):
model->process_jobs.on_host_command("PJ-1", gem::ProcessJobEvent::Start);
// Recipe completed normally:
model->process_jobs.fire_internal("PJ-1", gem::ProcessJobEvent::ProcessComplete);
```
CJ state cascades the same way (E94).
### 4.5. Alarms (E5 §13)
```cpp
// Sensor crosses threshold:
model->alarms.set(/*alid=*/1, /*set=*/true); // emits S5F1(ALCD=0x84)
// Later it clears:
model->alarms.set(1, false); // emits S5F1(ALCD=0x04)
```
The dispatcher takes care of the wire frame — you just toggle.
### 4.6. E84 parallel I/O handoff (AMHS)
For each load port that talks to the AMHS robot:
```cpp
#include "secsgem/gem/e84_asio_timers.hpp"
auto* fsm = model->e84_ports.get(/*port_id=*/1);
if (!fsm) { model->e84_ports.create(1); fsm = model->e84_ports.get(1); }
// SEMI E84 §6 handshake timers. Defaults below are spec-typical; tune
// per port. TA1=AMHS waits for L_REQ/U_REQ after VALID; TA2=equipment
// waits for BUSY after port is ready; TA3=BUSY phase budget.
fsm->set_timeouts({std::chrono::seconds(2),
std::chrono::seconds(2),
std::chrono::seconds(60)});
// Wire arm/cancel into asio so the FSM polices the real wall clock.
auto driver = std::make_shared<gem::E84AsioTimers>(io.get_executor(), *fsm);
driver->attach();
// Keep `driver` alive for the lifetime of the FSM (e.g. as a member
// of your per-port object).
// Optional: log handoff faults.
fsm->set_fault_handler([port_id = 1](gem::E84Fault reason) {
log("E84 port " + std::to_string(port_id) + " fault: " +
gem::e84_fault_name(reason));
});
// Now feed signal changes from your I/O bridge. On a real AMHS the
// bridge polls or interrupts on the parallel-I/O lines:
model->e84_ports.on_signal_change(1, gem::E84Signal::CS_0, true);
model->e84_ports.on_signal_change(1, gem::E84Signal::VALID, true);
// equipment side asserts when port is physically ready:
model->e84_ports.on_signal_change(1, gem::E84Signal::L_REQ, true);
// ... AMHS continues with BUSY / COMPT.
```
If TA1, TA2, or TA3 expires the FSM transitions to `HandoffFault` and
the fault handler fires with the precise `E84Fault` reason. Your
application is then responsible for whatever the tool's fault policy is
(typically: assert your local ES line and raise an alarm).
### 4.7. Recoverable exceptions (E5 §9, S5F9F18)
For faults where you want a host/equipment recovery dialogue:
```cpp
model->exceptions.post(/*exid=*/42, "VACUUM",
"lost vacuum in chamber A",
{"PURGE", "RECOVER", "ABORT"}); // emits S5F9
// Host picks PURGE via S5F13; the registered handler calls
// model->exceptions.on_recover(42, "PURGE"), state moves to Recovering.
// Your purge routine completes:
model->exceptions.fire_internal(42, gem::ExceptionEvent::RecoveryComplete);
// state → Cleared; S5F11 fires; entry removed.
```
---
## 5. Persistence
GEM equipment that loses power mid-job can recover gracefully
because every store the library ships supports an opt-in file-backed
journal. Enable per store, at startup, BEFORE the connection comes up:
```cpp
auto base = std::filesystem::path("/var/lib/acme/secsgem");
model->spool.enable_persistence(base / "spool");
model->carriers.enable_persistence(base / "carriers");
model->load_ports.enable_persistence(base / "loadports");
model->substrates.enable_persistence(base / "substrates");
model->process_jobs.enable_persistence(base / "pjobs");
model->control_jobs.enable_persistence(base / "cjobs");
model->exceptions.enable_persistence(base / "exceptions");
```
On enable, the store scans the directory, replays records into
in-memory state, and from there keeps the directory in sync on
every create / state-change / remove. Writes use a
`.tmp + rename` pattern so a power loss mid-write can lose at most
the in-flight record (older records remain coherent).
Storage budget per store, roughly:
- spool: one file per spooled S6F11 (typically tens of bytes each)
- carriers: one file per carrier (~50 bytes + slot count)
- load_ports: one file per LP (~30 bytes)
- substrates: one file per wafer (~80 bytes)
- pjobs: one file per active PJ (~100 bytes), plus `order.idx`
- cjobs: one file per active CJ (~80 bytes)
- exceptions: one file per Posted/Recovering exception
Even a busy fab tool tops out at a few hundred files in each
directory — well within filesystem caps. Sweep terminal-state
entries (completed PJs, cleared exceptions) periodically if you
care about directory size.
Caveats currently captured in the persistence tests:
- Substrate **history** is intentionally NOT journaled — only the
*current* state of each axis. Replay starts with an empty
history vector.
- PJ `rcpvars` / `prprocessparams` (the optional E40 `secs2::Item`
trailers) are not journaled in v1; call `set_e40_extras` again on
the application side after restart if you need them.
---
## 6. Monitoring + observability
### 6.1. Connection lifecycle
```cpp
conn->set_log_handler([](const std::string& m) {
syslog(LOG_INFO, "hsms: %s", m.c_str());
});
conn->set_selected_handler([] { metrics.inc("hsms.selected"); });
conn->set_closed_handler([](const std::string& r) {
metrics.inc("hsms.closed", {{"reason", r}});
});
```
### 6.2. State change observers
Every store / FSM exposes a `set_*_change_handler`. Hook them up
to your metrics / log pipeline:
```cpp
model->control_jobs.set_state_change_handler(
[](const std::string& cj, gem::ControlJobState f, gem::ControlJobState t,
gem::ControlJobEvent) {
log("CJ " + cj + " " + gem::control_job_state_name(f) +
"" + gem::control_job_state_name(t));
});
```
### 6.3. Self-emitted protocol errors
Look for `S9F*` traffic in your logs. S9F3 / F5 mean the host
asked for something your router doesn't handle; S9F7 means a bad
body arrived; S9F9 means a reply didn't arrive in T3; S9F11 means
a frame exceeded the 16 MiB cap. None of these are normal — they're
real diagnostic events.
### 6.4. Prometheus exporter (worked example)
`include/secsgem/metrics/prometheus.hpp` ships a minimal Registry +
asio-backed HTTP server. Drop it next to your equipment and scrape
from your fab's Prometheus.
```cpp
#include "secsgem/metrics/prometheus.hpp"
namespace metrics = secsgem::metrics;
auto reg = std::make_shared<metrics::Registry>();
reg->describe("secsgem_messages_total", "messages dispatched",
metrics::MetricType::Counter);
reg->describe("secsgem_alarms_active", "currently-active alarms",
metrics::MetricType::Gauge);
reg->describe("secsgem_spool_depth", "queued spool messages",
metrics::MetricType::Gauge);
reg->describe("secsgem_t_timer_total", "T-timer expiry by id",
metrics::MetricType::Counter);
// HTTP /metrics on :9090. Same io_context as the HSMS connection —
// scraping runs on the strand, so updates and reads serialize for free.
auto exporter = std::make_shared<metrics::PrometheusServer>(io, 9090, reg);
exporter->start();
// Wire counters into the connection + model hooks you already set up
// in §6.1 / §6.2. These all fire on the io_context thread.
conn->set_selected_handler([reg, conn] {
reg->set_gauge("secsgem_hsms_selected", 1);
});
conn->set_closed_handler([reg](const std::string& reason) {
reg->set_gauge("secsgem_hsms_selected", 0);
// T-timer expirations surface here as `reason` starting with "T".
if (!reason.empty() && reason[0] == 'T')
reg->inc("secsgem_t_timer_total", {{"timer", reason.substr(0, 2)}});
});
// Per-message dispatch — wrap your existing router.dispatch() call.
auto orig_handler = conn->message_handler(); // (or whatever you set)
conn->set_message_handler([reg, orig_handler](const secs2::Message& m) {
reg->inc("secsgem_messages_total",
{{"dir", "rx"},
{"stream", std::to_string(m.stream)},
{"function", std::to_string(m.function)}});
return orig_handler(m);
});
// Push gauge snapshots from a periodic timer on the same io.
auto poll = std::make_shared<asio::steady_timer>(io);
std::function<void(std::error_code)> tick = [reg, model, poll, &tick](std::error_code ec) {
if (ec) return;
reg->set_gauge("secsgem_spool_depth",
static_cast<double>(model->spool.size()));
std::size_t active = 0;
for (auto& a : model->alarms.all())
if (model->alarms.active(a.id)) ++active;
reg->set_gauge("secsgem_alarms_active", static_cast<double>(active));
poll->expires_after(std::chrono::seconds(5));
poll->async_wait(tick);
};
poll->expires_after(std::chrono::seconds(5));
poll->async_wait(tick);
```
What lands at `/metrics`:
```
# HELP secsgem_messages_total messages dispatched
# TYPE secsgem_messages_total counter
secsgem_messages_total{dir="rx",function="13",stream="1"} 42
# TYPE secsgem_spool_depth gauge
secsgem_spool_depth 7
# TYPE secsgem_hsms_selected gauge
secsgem_hsms_selected 1
```
Wire this into your fab's Prometheus + Grafana and you've got the
starter dashboard the README §3 table describes. The exporter has
**no auth and no TLS** — drop nginx or Caddy in front with mTLS for
production.
---
## 7. HSMS-GS: one tool, multiple MES
Most fab tools talk to one MES. Some — particularly tools shared by
multiple production lines or sites — need to serve two or more MES
schedulers simultaneously over a single HSMS-GS connection. E37 §11
calls these "general sessions": one TCP socket, multiple session
identifiers, independent SELECTED state per session.
The library models this as additional sessions on the same
`hsms::Connection`:
```cpp
server.on_connection([](std::shared_ptr<Connection> conn) {
// Primary session (device_id=1) was registered by Server::Config;
// add a second session for the second MES.
conn->add_session(/*device_id=*/2);
// Per-session message routing — each MES gets a distinct dispatcher,
// distinct SVID views, distinct alarm enable state, distinct
// recipe namespace if you want. Or share state via a common
// EquipmentDataModel and just route messages here.
conn->set_session_message_handler(1, [model_1](const secs2::Message& m) {
return router_1.dispatch(m);
});
conn->set_session_message_handler(2, [model_2](const secs2::Message& m) {
return router_2.dispatch(m);
});
// Per-session SELECT state observers. These fire when each MES
// completes its Select.req handshake; independent of each other.
conn->set_session_selected_handler(1, [] {
log("MES-1 selected");
});
conn->set_session_selected_handler(2, [] {
log("MES-2 selected");
});
});
```
When the equipment emits an unsolicited primary (S5F1, S6F11,
S10F1), choose the session explicitly:
```cpp
// Alarm goes to MES-1 only.
conn->send_data(/*session_id=*/1, gem::s5f1_alarm_report(0x84, 1, "high"));
// Event report goes to both.
auto event = gem::s6f11_event_report(0, 300, reports);
conn->send_data(1, event);
conn->send_data(2, event);
```
### Active-mode (host side) GS
The host (active) connection initiates Select.req for each registered
session serially — session 1 first, then once 1 reaches SELECTED,
session 2. Customers building a multi-tool fleet controller use the
same `add_session` API on the `Client`-derived `Connection`:
```cpp
client.on_connection([](std::shared_ptr<Connection> conn) {
conn->add_session(2); // a second tool's session
conn->set_session_selected_handler(1, [] { /* tool A ready */ });
conn->set_session_selected_handler(2, [] { /* tool B ready */ });
});
```
### Rejection semantics
A data frame whose `session_id` field doesn't match any registered
session gets a Reject(EntityNotSelected) response, per E37 §7.7 — the
peer's MES will see this and know to back off. See
`tests/test_hsms_gs.cpp` for the wire-level coverage and
`tests/test_hsms_gs_integration.cpp` for the end-to-end Server/Client
pattern.
---
## 8. Recommended layout for a vendor application
```
/opt/acme-secsgem/
├─ bin/
│ └─ secsgem-equipment # your built binary
├─ etc/
│ ├─ equipment.yaml # your tool's dictionary
│ └─ control_state.yaml # your tool-specific state model
├─ var/
│ ├─ spool/ # populated at runtime
│ ├─ carriers/
│ ├─ substrates/
│ ├─ pjobs/
│ ├─ cjobs/
│ └─ exceptions/
└─ share/
└─ doc/ # COMPLIANCE.md, INTEGRATION.md
```
Your application reads `etc/`, writes to `var/`, and never touches
`share/`. YAML edits don't require a rebuild — restart the
process.
The control-state YAML is your tool's *processing* state machine —
E30 deliberately leaves the concrete states (IDLE / SETUP / READY /
EXECUTING / PAUSE / …) up to the tool builder. Copy
`data/control_state.yaml` as a starting point.
---
## 9. Test the integration
Don't ship without:
1. **Round-trip every host-facing message you serve.** The library's
own test suite covers the codecs; you should also drive your
YAML's specific SVIDs / CEIDs / alarms / recipes against the
built-in passive server using the `interop/host_vs_cpp_server.py`
harness as a template.
2. **Power-loss simulation.** Kill -9 the process mid-job, restart,
confirm the stores replay the correct state. The persistence
tests give you a template; copy and parameterize for your store
directories.
3. **Multi-hour soak.** Spool fills up if persistence is enabled and
the host link is down — make sure your fab's MES side ack-rate
keeps up. Run a 24h test with the host periodically disconnecting
and watch the journal directory.
4. **The two-container demo** in this repo gives you a starting
harness — the host emulator (`apps/secs_client.cpp`) drives
~20 transactions through your server. Adapt it to your message
set.
---
## 10. When to extend the runtime
The library is open to extension. Common reasons to add code:
- **A new SECS-II message** the catalog doesn't cover. Edit
`data/messages.yaml`, run the codegen (built into the CMake
pipeline), add a Router handler. No core code change.
- **A new state machine** specific to your tool (e.g. an in-chamber
cooling cycle FSM). Lift the pattern from `ept_state.hpp`:
define your states + events + transition table; let your
application drive it.
- **An additional persistence backend** (DB instead of files).
Mirror the spool `.enable_persistence` pattern — it's about 100
lines per store.
If your change is broadly useful, it's worth landing in the library
itself. See `COMPLIANCE.md` for the standards still on the
"explicitly out of scope" list — anything there is a possible
contribution.
---
## 11. Going from "stack" to "certified GEM tool"
This codebase passes its own conformance harness and cross-validates
against `secsgem-py`, but a real *certified* GEM tool needs more:
- **Independent third-party validator**. Run a GEM RTS (Reference
Test System) or equivalent against your integration. The library
serves the messages; the validator decides whether your data is
consistent.
- **Vendor application code**. The runtime cannot, by design, know
what your SVID values *should* be at any given moment. That's
your domain knowledge plugged into the data model and FSMs.
- **Documentation**. E30 §6.10 (Documentation capability) requires
you to publish what you implement. `COMPLIANCE.md` in this repo
is the template — fork it, prune to your actual coverage, ship
it with your tool.
- **Operations**: monitoring dashboards, alarm escalation, log
retention — the standard SRE concerns, no different from any
other piece of fab software.