Files
secs-gem/INTEGRATION.md
T
raphael a4599b3b9d config: multi-error YAML validator + --validate-config CLI flag
The existing loader throws ConfigError on the first problem it hits.
A customer with a tool-specific equipment.yaml that has six issues
sees one, fixes, restarts, sees the next, fixes, restarts — six
edit-restart cycles before the server even binds.  Day-1 friction
is the top support ticket source in fab integrations.

This commit adds a parallel validator that does a separate read-only
pass and surfaces *every* issue at once:

  $ secs_server --validate-config \
      --config equipment.yaml \
      --state-table control_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

What it catches:
- Missing required fields (device.model_name, .software_rev, …)
- Range violations (alarm category must be 0–127, spool streams 1–127,
  device.id fits u16, etc.)
- Unknown enum values (SECS-II types, HCACK values, control/PJ/CJ
  state and event names — using the right case + snake convention
  the runtime parsers enforce)
- Duplicate IDs within svids / dvids / ecids / ceids / alarms,
  duplicate PPIDs in recipes, duplicate command names in host_commands
- Referential integrity: host_commands[*].emit_ceid must exist in
  ceids; host_commands[*].set_alarm must exist in alarms;
  emit_on_control_change must exist in ceids
- PJ-table-specific: `NoState` sentinel rejected as `initial`,
  `from`, or `to` (matches loader's existing runtime check)
- yaml-cpp Mark → 1-based line numbers when available

What it doesn't catch (out of scope this round):
- JSON Schema for editor red-squigglies (future)
- Deep semantic checks across state-table reachability
- ECID min/max value parsing (would need numeric type coupling)

Tests cover: clean file passes; multi-error YAML surfaces every issue
on a single pass; line numbers populate; control_state /
process_job_state / control_job_state casing conventions;
format_issues_to renders both severities; the shipped
data/equipment.yaml etc. validate cleanly (regression tripwire if
anyone breaks the demo configs).

INTEGRATION.md §2.3 calls out the flag and suggests CI use.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 14:32:09 +02:00

557 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
---
## 7. 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.
---
## 8. 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.
---
## 9. 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.
---
## 10. 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.