Files
secs-gem/docs/INTEGRATION.md
raphael 60fa164626 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>
2026-06-09 19:35:43 +02:00

26 KiB
Raw Permalink Blame History

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:

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:

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:

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

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:

// 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::posts 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)

// 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:

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:

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:

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:

// 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)

// 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:

#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:

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:

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

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:

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.

#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:

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:

// 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:

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.


/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.