From 6aa4427186fe5788dd3e1b0cad2c257e60739f45 Mon Sep 17 00:00:00 2001 From: Raphael Maenle Date: Tue, 9 Jun 2026 16:57:10 +0200 Subject: [PATCH] docs: worked PVD-tool vendor example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fictional Physical Vapor Deposition tool wired end-to-end. examples/pvd_tool/ is the template a real customer should fork. Files: - equipment.yaml: 32 SVIDs (chamber pressure, temperature, source power, gas flows, cooling water, wafer counters, recipe step state, EPT name, 4 load ports), 5 DVIDs, 7 ECIDs (setpoints + T_CRA/T_DELAY + cleaning interval + retry count), 17 CEIDs (control state, alarms, process lifecycle, material movement, EPT), 12 alarms with realistic categories (safety, error, warning, attention), 3 multi-step recipes (Al / Ti / Cu), 9 host commands. - main.cpp (~860 lines): the vendor-side application: §1 helpers + constants §2 sensor simulator — 4 sensors at 10 Hz + 1 Hz cadences, random-walk around step-targeted setpoints, asio::post-on-strand thread-safety pattern §3 recipe runner — parses recipe body (STEP NAME duration=120s power=2500W gas=Argon flow=50sccm), walks each step at 1s per declared-second, fires step-started/completed CEIDs, drives PJ FSM through ProcessComplete §4 alarm threshold monitor — chamber-pressure-over-setpoint and cleaning-interval logic, continuous evaluation, set/clear emission gated on alarm-enable §5 EPT cycler — Standby ↔ Productive ↔ UnscheduledDowntime based on PJ activity + safety alarms §6 Prometheus exporter on :9090 (pvd_messages_total, pvd_chamber_pressure_torr, pvd_spool_depth, pvd_events_total, pvd_alarm_set_total) §7 Router handlers — full E30 set (~40 handlers) so a host can do real work §8 main() — YAML validation, model construction, server wiring, periodic gauge updates - README.md: section-by-section walkthrough, what's the same as apps/secs_server.cpp, what this adds (simulator + recipe runner + alarm monitor + EPT cycler + metrics), what's not here (persistence + E84 + real I/O), and what to change for your tool. Verification: 47/47 conformance harness checks PASS against the PVD tool — same as the demo server. CMakeLists.txt adds the pvd_tool target. README's documentation map points at examples/pvd_tool/. Co-Authored-By: Claude Opus 4.7 --- CMakeLists.txt | 6 + examples/pvd_tool/README.md | 154 +++++ examples/pvd_tool/equipment.yaml | 196 ++++++ examples/pvd_tool/main.cpp | 1093 ++++++++++++++++++++++++++++++ 4 files changed, 1449 insertions(+) create mode 100644 examples/pvd_tool/README.md create mode 100644 examples/pvd_tool/equipment.yaml create mode 100644 examples/pvd_tool/main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index af4798c..809ee48 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -125,6 +125,12 @@ target_link_libraries(secs_conformance PRIVATE secsgem) add_executable(secs_bench apps/secs_bench.cpp) target_link_libraries(secs_bench PRIVATE secsgem) +# Worked vendor example: ACME-PVD-3000. Reads its own YAML, runs +# sensor simulators + a recipe runner + alarm monitors + EPT cycling +# + a Prometheus metrics exporter. See examples/pvd_tool/README.md. +add_executable(pvd_tool examples/pvd_tool/main.cpp) +target_link_libraries(pvd_tool PRIVATE secsgem) + if(SECSGEM_FUZZ) # libFuzzer entry-point targets. Each owns its own `main` via the # `-fsanitize=fuzzer` link flag; do NOT also link them into the diff --git a/examples/pvd_tool/README.md b/examples/pvd_tool/README.md new file mode 100644 index 0000000..492b19c --- /dev/null +++ b/examples/pvd_tool/README.md @@ -0,0 +1,154 @@ +# ACME-PVD-3000 — worked vendor example + +A fictional Physical Vapor Deposition tool, end-to-end. This is what +a real tool integrator's deployment looks like: + +- `equipment.yaml` — the tool's data dictionary (32 SVIDs, 5 DVIDs, + 7 ECIDs, 17 CEIDs, 12 alarms, 3 recipes, 9 host commands) +- `main.cpp` — the vendor application: sensor simulator, recipe + runner, alarm threshold monitor, EPT cycling, metrics exporter, + Router handlers wiring it all to the wire. + +If you're starting a real integration, **fork these two files** and +customize. They're written to be a template, not an abstract demo. + +## What it demonstrates + +| Section in main.cpp | What it shows you how to do | +|-------------------------------|----------------------------------------------------------------------| +| §1 Helpers + constants | The few `kSvidX / kCeidX` constants worth pinning at file scope | +| §2 Sensor simulator | Multi-cadence sensor poll loops (10 Hz pressure, 1 Hz temps), with the `asio::post`-onto-strand thread-safety pattern | +| §3 Recipe runner | Driving a PJ through SettingUp → Processing → ProcessComplete by walking the recipe body, with per-step CEID emission | +| §4 Alarm threshold monitor | Continuous threshold-based alarm logic (chamber pressure, cleaning interval) with set/clear emission | +| §5 EPT cycling | E116 state transitions driven by PJ state + safety alarms | +| §6 Metrics | Prometheus exporter on `:9090` with per-CEID counters and gauge updates | +| §7 Router handlers | Every SECS/GEM message a host might send to a PVD tool, ~40 handlers in ~200 lines | +| §8 main() | Loading YAML → validating → composing → running | + +## Running it + +From repo root: + +```bash +# Validate the configs (this is what your CI should do). +docker compose run --rm builder /app/build/secs_server --validate-config \ + --config /app/examples/pvd_tool/equipment.yaml \ + --state-table /app/data/control_state.yaml \ + --pj-state-table /app/data/process_job_state.yaml \ + --cj-state-table /app/data/control_job_state.yaml + +# Start the tool. +docker compose run --rm builder /app/build/pvd_tool \ + /app/examples/pvd_tool/equipment.yaml \ + /app/data/control_state.yaml \ + 5000 \ + 9090 + +# In another shell, drive it with the conformance harness or a real host. +docker compose run --rm builder /app/build/secs_conformance \ + --host 127.0.0.1 --port 5000 --device 0 +# 47 / 47 checks passed +``` + +Or via Docker Compose if you'd rather wire it as a service. + +## What the host sees + +Once a host connects and SELECTs: + +1. **S1F1 → S1F2** returns `MDLN="ACME-PVD-3000"`, `SOFTREV="1.4.2"`. +2. **S1F3** on the 32 SVIDs returns live sensor readings — chamber + pressure tracks the simulator's target (default 1e-7 Torr in + idle), wafer counter increments per processed PJ, EPT state + gauge says `Standby`. +3. **S2F33/F35/F37** binds dynamic event reports; CEIDs 300 / 301 / + 310 / 311 fire on real PJ activity. +4. **S2F41 RCMD=START** kicks the recipe runner: any PJ in + WaitingForStart transitions to Processing and the simulator + starts tracking the recipe's step targets. Sensor values change + in real time. CEID 300 (ProcessStarted) emits, then per-step + CEID 310/311, then CEID 301 (ProcessCompleted) on completion. +5. **S2F41 RCMD=FAULT** sets alarm 4 → S5F1 emitted (if enabled + via S5F3 first). +6. **S7F19** lists the 3 recipes; **S7F5** returns the body + (multi-line STEP definitions). +7. **S16F11** (PJ create) + **S14F9** (CJ create) + **S16F27** + (CJSTART) drives the full E40/E94 lifecycle. + +## What's the same as the secs-gem demo server + +`apps/secs_server.cpp` (used by `docker compose up server`) is the +canonical fully-loaded server. This example is structurally a +slimmer fork: + +- Same Router pattern (`gem::Router` + `router.on(s, f, [...])`) +- Same event/alarm emission helpers (`deliver_or_spool`, + `emit_event`, `emit_alarm_set`) +- Same control-state-change handler wiring + +What this example adds that the demo doesn't: + +- **Sensor simulator** with multi-cadence poll loops. The demo's + SVID values stay at their YAML defaults; PVD's drift toward + recipe-step targets. +- **Recipe runner** that parses the recipe body and drives the PJ + FSM step-by-step. The demo's RCMD=START just emits the linked + CEID; PVD actually walks the recipe. +- **Alarm threshold monitor** — continuous evaluation of sensor + values against ECID setpoints. The demo only fires alarms when + RCMD=FAULT is sent. +- **EPT cycling** — automatic Standby↔Productive↔UnscheduledDowntime + based on PJ + alarm state. The demo doesn't cycle EPT. +- **Prometheus metrics exporter** on a second port. The demo logs + but doesn't export. + +If you want one of these patterns in your own tool, lift the code +from `main.cpp` directly — each section is independently usable. + +## What's not here + +- **Persistence.** The demo server's `--spool-dir` flag is the + pattern to copy. Add `model->spool.enable_persistence(...)` etc. + at startup before binding the port. See INTEGRATION.md §5. +- **E84 handshake timers.** No load-port AMHS wiring; see + INTEGRATION.md §4.6 for the `E84AsioTimers` adapter. +- **Real I/O bridges.** Sensor values come from a random-walk + simulator. A real PVD tool would have a PLC/serial driver + module-bridge feeding `model->svids.set_value(...)` from real + hardware. +- **Production deployment hardening** — SECURITY.md (nftables, + stunnel, minisign signing) and INTEGRATION.md §7 (HA pattern). + +## What you'd change for *your* tool + +1. **Replace `equipment.yaml`** with your tool's actual SVIDs, + ECIDs, alarms, recipes. Run + `secs_server --validate-config` after every edit. +2. **Replace the sensor simulator** (`pvd::Simulator`) with calls + into your real hardware driver. Keep the `asio::post` pattern + for cross-thread updates. +3. **Replace the recipe runner** with your real recipe engine + integration. The shape — fire `Start`, walk steps, fire + `ProcessComplete` — is the contract; the implementation is + yours. +4. **Replace the alarm threshold monitor** with your real + alarm sources (sensor interrupts, watchdog timers, hardware + fault lines). Same `emit_alarm_set / emit_alarm_clear` API. +5. **Keep most of the Router handler section** — those are spec- + defined and you'll need them all in production. + +That's it. No framework, no DI container, no abstract base +classes. ~700 lines of vendor code on top of the library. + +## Cross-references + +- [INTEGRATION.md](../../INTEGRATION.md) — the conceptual tutorial + this example concretizes +- [ARCHITECTURE.md](../../ARCHITECTURE.md) — how stores compose, how + to extend +- [BENCHMARKS.md](../../BENCHMARKS.md) — what the throughput + envelope looks like +- [SECURITY.md](../../SECURITY.md) — production hardening configs +- [apps/secs_server.cpp](../../apps/secs_server.cpp) — the demo + server's fully-loaded Router (every handler PVD inherits + a + few more) diff --git a/examples/pvd_tool/equipment.yaml b/examples/pvd_tool/equipment.yaml new file mode 100644 index 0000000..f4706e6 --- /dev/null +++ b/examples/pvd_tool/equipment.yaml @@ -0,0 +1,196 @@ +# ACME-PVD-3000 — example data dictionary for a fictional Physical +# Vapor Deposition tool. Designed to look like a real fab tool, not a +# unit-test fixture. Read this alongside main.cpp to see how each +# entry is wired to behaviour. +# +# Topology: +# - One process chamber, single-wafer +# - Four load ports, each accepting a 25-slot FOUP +# - Three deposition recipes (Al / Ti / Cu) +# +# Real PVD tools have hundreds of SVIDs. This one ships 30 — enough +# to demonstrate every wiring pattern (status, derived, setpoint, +# event, alarm, recipe, host command) without burying the example. + +device: + id: 0 + model_name: "ACME-PVD-3000" + software_rev: "1.4.2" + equipment_type: "PVD" + +# E30 §6.10 — what the equipment self-reports to the host on S1F19. +# CCODE values follow E30 Appendix A. +capabilities: + - {code: 1, name: "Establish Communications"} + - {code: 2, name: "Dynamic Event Report Configuration"} + - {code: 3, name: "Variable Data Collection"} + - {code: 5, name: "Status Data Collection"} + - {code: 6, name: "Alarm Management"} + - {code: 7, name: "Remote Control"} + - {code: 8, name: "Equipment Constants"} + - {code: 9, name: "Process Program Management"} + - {code: 11, name: "Equipment Terminal Services"} + - {code: 12, name: "Clock"} + - {code: 14, name: "Spooling"} + - {code: 15, name: "Control (operator initiated)"} + - {code: 40, name: "E40 Process Job Management"} + - {code: 87, name: "E87 Carrier Management"} + - {code: 90, name: "E90 Substrate Tracking"} + - {code: 94, name: "E94 Control Job Management"} + - {code: 116, name: "E116 Equipment Performance Tracking"} + - {code: 148, name: "E148 Time Synchronization"} + - {code: 157, name: "E157 Module Process Tracking"} + +# Status variables — read-only, host queries via S1F3. +# main.cpp updates the sensor values on a poll loop. +svids: + # Mandatory framework SVIDs + - {id: 1, name: ControlState, units: "", type: ASCII, value: ""} + - {id: 2, name: Clock, units: "", type: ASCII, value: ""} + - {id: 3, name: EventsEnabled, units: "", type: BOOLEAN, value: true} + # Equipment-defined + - {id: 10, name: ProcessChamberPressureTorr, units: "Torr", type: F4, value: 1.0e-7} + - {id: 11, name: ProcessChamberTemperatureC, units: "C", type: F4, value: 23.5} + - {id: 12, name: LoadlockPressureTorr, units: "Torr", type: F4, value: 760.0} + - {id: 13, name: VacuumPumpRpm, units: "rpm", type: U4, value: 0} + - {id: 20, name: SourcePowerWatts, units: "W", type: F4, value: 0.0} + - {id: 21, name: TargetVoltageVolts, units: "V", type: F4, value: 0.0} + - {id: 22, name: SubstrateBiasVolts, units: "V", type: F4, value: 0.0} + - {id: 23, name: SourceTargetMaterial, units: "", type: ASCII, value: "None"} + - {id: 30, name: ArgonFlowSccm, units: "sccm", type: F4, value: 0.0} + - {id: 31, name: NitrogenFlowSccm, units: "sccm", type: F4, value: 0.0} + - {id: 32, name: CoolingWaterFlowLpm, units: "L/min", type: F4, value: 12.5} + - {id: 40, name: ChuckTemperatureC, units: "C", type: F4, value: 24.0} + - {id: 41, name: ShieldTemperatureC, units: "C", type: F4, value: 24.0} + - {id: 50, name: WafersProcessedTotal, units: "wafer", type: U4, value: 0} + - {id: 51, name: WafersProcessedSinceCleanup, units: "wafer", type: U4, value: 0} + - {id: 52, name: ChamberHoursSinceCleanup, units: "h", type: F4, value: 0.0} + - {id: 53, name: ActiveRecipePpid, units: "", type: ASCII, value: ""} + - {id: 54, name: RecipeStepName, units: "", type: ASCII, value: ""} + - {id: 55, name: RecipeStepElapsedSec, units: "s", type: U4, value: 0} + - {id: 60, name: LoadPort1Active, units: "", type: BOOLEAN, value: false} + - {id: 61, name: LoadPort2Active, units: "", type: BOOLEAN, value: false} + - {id: 62, name: LoadPort3Active, units: "", type: BOOLEAN, value: false} + - {id: 63, name: LoadPort4Active, units: "", type: BOOLEAN, value: false} + - {id: 70, name: EptStateName, units: "", type: ASCII, value: "Standby"} + - {id: 71, name: ProductiveHoursTotal, units: "h", type: F4, value: 0.0} + - {id: 72, name: UnscheduledDowntimeHours, units: "h", type: F4, value: 0.0} + +# Data variables — same shape as SVIDs but conceptually "intermediate +# data" rather than equipment status. Reported via S1F21/F22. +dvids: + - {id: 200, name: LastCycleTimeSec, units: "s", type: F4, value: 0.0} + - {id: 201, name: AverageCycleTimeSec, units: "s", type: F4, value: 0.0} + - {id: 202, name: BatchYieldPercent, units: "%", type: F4, value: 0.0} + - {id: 203, name: LastWaferProcessedId, units: "", type: ASCII, value: ""} + - {id: 204, name: ProcessChamberLeakRate, units: "Torr/s", type: F4, value: 1.0e-9} + +# Equipment constants — host-settable parameters. S2F15 set, S2F13 read. +ecids: + - {id: 100, name: ChamberBasePressureSetpoint, units: "Torr", type: F4, value: 1.0e-7, min: "1.0e-9", max: "1.0e-5"} + - {id: 101, name: ChamberTempSetpointC, units: "C", type: F4, value: 25.0, min: "15.0", max: "40.0"} + - {id: 102, name: SourcePowerSetpointW, units: "W", type: F4, value: 1500.0, min: "0.0", max: "5000.0"} + - {id: 103, name: T_CRA_Seconds, units: "s", type: U4, value: 30, min: "5", max: "120"} + - {id: 104, name: T_DELAY_Seconds, units: "s", type: U4, value: 10, min: "5", max: "60"} + - {id: 105, name: CleaningIntervalWafers, units: "wafer", type: U4, value: 500, min: "100", max: "2000"} + - {id: 106, name: MaxRecipeRetries, units: "", type: U4, value: 3, min: "0", max: "10"} + +# Collection events. CEID → linked report binding happens at runtime +# via S2F33/F35/F37; the equipment fires S6F11 on transitions through +# main.cpp's event-emit hooks. +ceids: + # Control / lifecycle + - {id: 100, name: ControlStateChanged} + - {id: 200, name: AlarmSet} + - {id: 201, name: AlarmCleared} + # Process lifecycle + - {id: 300, name: ProcessStarted} + - {id: 301, name: ProcessCompleted} + - {id: 302, name: ProcessAborted} + - {id: 303, name: ProcessPaused} + - {id: 304, name: ProcessResumed} + - {id: 310, name: RecipeStepStarted} + - {id: 311, name: RecipeStepCompleted} + # Material movement + - {id: 400, name: ControlJobExecuting} + - {id: 401, name: ControlJobCompleted} + - {id: 410, name: CarrierLoaded} + - {id: 411, name: CarrierUnloaded} + - {id: 420, name: SubstrateAtSource} + - {id: 421, name: SubstrateInProcess} + - {id: 422, name: SubstrateProcessed} + # Equipment performance (E116) + - {id: 500, name: EptEnteredProductive} + - {id: 501, name: EptEnteredStandby} + - {id: 502, name: EptEnteredEngineering} + - {id: 503, name: EptEnteredUnscheduledDowntime} + +# Alarms. Categories follow E5 §10.3 ALCD lower-7 bitmap: +# 0x01 PersonalSafety 0x02 EquipmentSafety 0x04 ParameterError +# 0x08 ParameterWarning 0x10 Irrecoverable 0x20 EquipmentStatus +# 0x40 Attention +alarms: + - {id: 1, text: "Chamber pressure high (above setpoint)", category: 0x04} + - {id: 2, text: "Chamber pressure low (below setpoint)", category: 0x04} + - {id: 3, text: "Chamber temperature out of range", category: 0x04} + - {id: 4, text: "Source power loss during process", category: 0x12} # safety + error + - {id: 5, text: "Cooling water flow loss", category: 0x02} # equip safety + - {id: 6, text: "Vacuum pump failure", category: 0x12} + - {id: 7, text: "Door open during process", category: 0x01} # personal safety + - {id: 8, text: "Target end-of-life", category: 0x40} # attention + - {id: 9, text: "Recipe step timeout", category: 0x08} # warning + - {id: 10, text: "Cleaning interval exceeded (500 wafers)", category: 0x40} + - {id: 11, text: "Load port 1 carrier mis-clocked", category: 0x20} + - {id: 12, text: "Substrate scanner unavailable", category: 0x20} + +# Recipes. The body is a placeholder string in this example; real +# tools would store binary recipe bundles (E42 formatted PP) here. +# The recipe runner in main.cpp parses the body line-by-line. +recipes: + - id: "AL-METALLIZATION-V3" + body: | + STEP PUMPDOWN duration=120s target=1.0e-7 + STEP HEAT-SUBSTRATE duration=60s target=300C + STEP SOURCE-PREHEAT duration=30s power=500W + STEP DEPOSIT-AL duration=180s power=2500W gas=Argon flow=50sccm + STEP COOL duration=120s target=25C + STEP VENT duration=60s target=760 + END + - id: "TI-BARRIER-V2" + body: | + STEP PUMPDOWN duration=120s target=1.0e-7 + STEP HEAT-SUBSTRATE duration=60s target=350C + STEP DEPOSIT-TI duration=90s power=1800W gas=Argon flow=40sccm + STEP COOL duration=120s target=25C + STEP VENT duration=60s target=760 + END + - id: "CU-SEED-V1" + body: | + STEP PUMPDOWN duration=120s target=1.0e-7 + STEP HEAT-SUBSTRATE duration=60s target=250C + STEP DEPOSIT-CU duration=240s power=3000W gas=Argon flow=60sccm + STEP COOL duration=120s target=25C + STEP VENT duration=60s target=760 + END + +# Host commands. `emit_ceid` fires the named CEID after dispatch; +# `set_alarm` sets an alarm. `force_spool` is the demo-only test hook. +host_commands: + - {name: START, ack: Accept, emit_ceid: 300} + - {name: STOP, ack: Accept, emit_ceid: 301} + - {name: PAUSE, ack: Accept, emit_ceid: 303} + - {name: RESUME, ack: Accept, emit_ceid: 304} + - {name: ABORT, ack: Accept, emit_ceid: 302} + - {name: CLEAN_CHAMBER, ack: Accept} + - {name: FAULT, ack: Accept, set_alarm: 4} # injected fault for demo + - {name: SPOOL_ON, ack: Accept, force_spool: true} + - {name: SPOOL_OFF, ack: Accept, force_spool: false} + +# Spool (E30 §6.22) — when the host disconnects, queue these streams +# for later drain via S6F23. +spool: + max_size: 1000 + spoolable_streams: [5, 6] + +# Auto-fire CEID 100 on every control-state change. +emit_on_control_change: 100 diff --git a/examples/pvd_tool/main.cpp b/examples/pvd_tool/main.cpp new file mode 100644 index 0000000..99ccab5 --- /dev/null +++ b/examples/pvd_tool/main.cpp @@ -0,0 +1,1093 @@ +// ACME-PVD-3000 — worked vendor application. +// +// This is what a real tool integrator's main() looks like. Everything +// SECS-shaped (the data dictionary, the message catalogue, the state +// tables) comes from YAML; this file is the *vendor side* — the bits +// that connect the library to the actual tool: +// +// §1. Helpers and constants +// §2. Sensor simulator (4 sensors at 3 different cadences) +// §3. Recipe runner (drives PJ FSM through Processing → Complete) +// §4. Alarm threshold monitor (pressure-based) +// §5. EPT cycling (Standby ↔ Productive ↔ UnscheduledDowntime) +// §6. Metrics (Prometheus exporter on :9090) +// §7. Router handlers (the minimum set; mostly copied from +// apps/secs_server.cpp which has the full catalogue) +// §8. main() — wires everything together +// +// This file is deliberately self-contained — no pvd_internal/ helper +// headers, no factored-out "framework." Customers should be able to +// fork this single file as a starting template. +// +// What's *not* here that production deployment needs: +// - SECURITY.md: nftables, stunnel, minisign signing. +// - The other router handlers (S5/S6/S7/S10/S14/S16) — apps/ +// secs_server.cpp has them all. We register the ones a demo +// host actually exercises here. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "secsgem/config/loader.hpp" +#include "secsgem/config/validate.hpp" +#include "secsgem/endpoint.hpp" +#include "secsgem/gem/control_state.hpp" +#include "secsgem/gem/data_model.hpp" +#include "secsgem/gem/e116_constants.hpp" +#include "secsgem/gem/messages.hpp" +#include "secsgem/gem/router.hpp" +#include "secsgem/metrics/prometheus.hpp" +#include "secsgem/secs2/message.hpp" + +using namespace secsgem; +using namespace std::chrono_literals; +namespace s2 = secsgem::secs2; +namespace gem = secsgem::gem; + +// ============================================================================= +// §1. Helpers and constants +// ============================================================================= + +namespace pvd { + +constexpr uint32_t kSvidControlState = 1; +constexpr uint32_t kSvidClock = 2; +constexpr uint32_t kSvidChamberPressure = 10; +constexpr uint32_t kSvidChamberTemp = 11; +constexpr uint32_t kSvidVacuumPumpRpm = 13; +constexpr uint32_t kSvidSourcePower = 20; +constexpr uint32_t kSvidTargetVoltage = 21; +constexpr uint32_t kSvidSourceMaterial = 23; +constexpr uint32_t kSvidArgonFlow = 30; +constexpr uint32_t kSvidNitrogenFlow = 31; +constexpr uint32_t kSvidCoolingWater = 32; +constexpr uint32_t kSvidWaferTotal = 50; +constexpr uint32_t kSvidWafersSinceClean = 51; +constexpr uint32_t kSvidActivePpid = 53; +constexpr uint32_t kSvidStepName = 54; +constexpr uint32_t kSvidStepElapsed = 55; +constexpr uint32_t kSvidEptName = 70; +constexpr uint32_t kSvidProductiveHours = 71; + +constexpr uint32_t kEcChamberPressureSp = 100; +constexpr uint32_t kEcSourcePowerSp = 102; +constexpr uint32_t kEcCleaningInterval = 105; + +constexpr uint32_t kCeidProcessStarted = 300; +constexpr uint32_t kCeidProcessCompleted = 301; +constexpr uint32_t kCeidStepStarted = 310; +constexpr uint32_t kCeidStepCompleted = 311; +constexpr uint32_t kCeidCJExecuting = 400; +constexpr uint32_t kCeidCJCompleted = 401; + +constexpr uint32_t kAlarmPressureHigh = 1; +constexpr uint32_t kAlarmTempOutOfRange = 3; +constexpr uint32_t kAlarmSourcePowerLoss = 4; +constexpr uint32_t kAlarmCoolingWaterLoss = 5; +constexpr uint32_t kAlarmTargetEol = 8; +constexpr uint32_t kAlarmCleaningNeeded = 10; + +// ============================================================================= +// §2. Sensor simulator +// ============================================================================= +// +// Real tool sensors come from PLCs / serial buses / DAQ cards. Here +// we simulate them: a random walk around each sensor's setpoint, +// updated on its natural cadence: +// +// - Chamber pressure: 10 Hz (fast — a real PLC ticks this rate) +// - Chamber temperature, gas flows, water flow: 1 Hz +// - Wafer counters, EPT hours: on-event, not polled +// +// All updates marshal onto the io_context via asio::post — that's +// the thread-safety contract documented in INTEGRATION.md §3. + +struct Simulator { + asio::io_context& io; + std::shared_ptr model; + std::mt19937 rng{std::random_device{}()}; + + // Recipe state — when a recipe is running, sensors track the + // step's target rather than the default setpoint. + std::atomic processing{false}; + std::atomic target_pressure_torr{1.0e-7f}; + std::atomic target_temp_c{25.0f}; + std::atomic source_power_setpoint{0.0f}; + std::atomic argon_flow_setpoint{0.0f}; + + std::shared_ptr fast_timer; + std::shared_ptr slow_timer; + + Simulator(asio::io_context& io_, + std::shared_ptr model_) + : io(io_), model(std::move(model_)), + fast_timer(std::make_shared(io)), + slow_timer(std::make_shared(io)) {} + + // Random walk around `target` with normal-ish jitter. + float jitter(float current, float target, float spread) { + std::normal_distribution n(0.0f, spread); + const float drift = (target - current) * 0.05f; // 5% of error toward target + return current + drift + n(rng); + } + + void tick_fast() { + // Read current values, update toward target, write back. + auto p_sv = model->svids.get(kSvidChamberPressure); + if (p_sv) { + const auto& cur = std::get>(p_sv->value.storage()); + const float new_p = jitter(cur.empty() ? 1.0e-7f : cur[0], + target_pressure_torr.load(), 1.0e-8f); + model->svids.set_value(kSvidChamberPressure, s2::Item::f4(new_p)); + } + fast_timer->expires_after(100ms); + fast_timer->async_wait([this](std::error_code ec) { + if (!ec) tick_fast(); + }); + } + + void tick_slow() { + auto upd_f4 = [&](uint32_t vid, float target, float spread, float fallback) { + auto cur = model->svids.get(vid); + const float c = (cur && std::holds_alternative>(cur->value.storage())) + ? std::get>(cur->value.storage())[0] + : fallback; + model->svids.set_value(vid, s2::Item::f4(jitter(c, target, spread))); + }; + upd_f4(kSvidChamberTemp, target_temp_c.load(), 0.3f, 25.0f); + upd_f4(kSvidSourcePower, source_power_setpoint.load(), 5.0f, 0.0f); + upd_f4(kSvidArgonFlow, argon_flow_setpoint.load(), 0.5f, 0.0f); + upd_f4(40 /*ChuckTemp*/, target_temp_c.load() - 1.0f, 0.2f, 24.0f); + upd_f4(41 /*ShieldTemp*/, target_temp_c.load() - 2.0f, 0.2f, 24.0f); + upd_f4(kSvidCoolingWater, 12.5f, 0.1f, 12.5f); + + // Vacuum pump RPM — discrete state: ~0 when chamber pressure + // > 1 Torr, ramping to ~80 000 when at vacuum. + auto p_sv = model->svids.get(kSvidChamberPressure); + if (p_sv) { + const auto& v = std::get>(p_sv->value.storage()); + const float p = v.empty() ? 1.0f : v[0]; + const uint32_t rpm = p < 1.0e-3f ? 80000u : (p < 1.0f ? 40000u : 0u); + model->svids.set_value(kSvidVacuumPumpRpm, s2::Item::u4(rpm)); + } + + // Update clock SVID for hosts that poll it via S1F3. + model->svids.set_value(kSvidClock, s2::Item::ascii( + model->clock.current_time_string())); + + slow_timer->expires_after(1s); + slow_timer->async_wait([this](std::error_code ec) { + if (!ec) tick_slow(); + }); + } + + void start() { tick_fast(); tick_slow(); } +}; + +// ============================================================================= +// §3. Recipe runner +// ============================================================================= +// +// Drives a PJ through SettingUp → WaitingForStart → Processing → +// ProcessComplete by stepping through the recipe body line by line. +// Each STEP runs for its declared duration, with the sensor +// simulator's targets adjusted to match the step's parameters. + +struct RecipeStep { + std::string name; + std::chrono::seconds duration{0}; + std::optional target_pressure_torr; + std::optional target_temp_c; + std::optional source_power_w; + std::optional gas_flow_sccm; + std::string source_material; +}; + +// Parse "STEP NAME duration=120s power=2500W gas=Argon flow=50sccm". +RecipeStep parse_step(const std::string& line) { + RecipeStep s; + std::istringstream is(line); + std::string tok; + is >> tok; // STEP + is >> s.name; + while (is >> tok) { + auto eq = tok.find('='); + if (eq == std::string::npos) continue; + const auto key = tok.substr(0, eq); + auto val = tok.substr(eq + 1); + auto strip_unit = [&](const char* unit) { + const auto p = val.find(unit); + if (p != std::string::npos) val = val.substr(0, p); + }; + if (key == "duration") { strip_unit("s"); s.duration = std::chrono::seconds(std::stoi(val)); } + else if (key == "target") { + strip_unit("Torr"); strip_unit("C"); + try { s.target_pressure_torr = std::stof(val); } catch (...) {} + try { if (!s.target_pressure_torr) s.target_temp_c = std::stof(val); } catch (...) {} + } + else if (key == "power") { strip_unit("W"); s.source_power_w = std::stof(val); } + else if (key == "flow") { strip_unit("sccm"); s.gas_flow_sccm = std::stof(val); } + else if (key == "gas") { s.source_material = val; } + } + return s; +} + +struct RecipeRunner { + asio::io_context& io; + std::shared_ptr model; + Simulator& sim; + std::function emit_event; + + RecipeRunner(asio::io_context& io_, + std::shared_ptr model_, + Simulator& sim_, + std::function emit_) + : io(io_), model(std::move(model_)), sim(sim_), + emit_event(std::move(emit_)) {} + + void start(const std::string& prjobid) { + auto* pj = model->process_jobs.get(prjobid); + if (!pj) return; + const auto body = model->recipes.get(pj->ppid); + if (!body) return; + + // Parse steps from the recipe body. + auto steps = std::make_shared>(); + std::istringstream is(*body); + std::string line; + while (std::getline(is, line)) { + if (line.rfind("STEP", 0) == 0) steps->push_back(parse_step(line)); + } + model->svids.set_value(kSvidActivePpid, s2::Item::ascii(pj->ppid)); + sim.processing = true; + + run_step(prjobid, steps, 0); + } + + void run_step(const std::string& prjobid, + std::shared_ptr> steps, + std::size_t i) { + if (i >= steps->size()) { + // Recipe complete. + model->process_jobs.fire_internal(prjobid, gem::ProcessJobEvent::ProcessComplete); + model->svids.set_value(kSvidStepName, s2::Item::ascii("")); + model->svids.set_value(kSvidStepElapsed, s2::Item::u4(0)); + sim.processing = false; + sim.target_pressure_torr = 1.0e-7f; + sim.target_temp_c = 25.0f; + sim.source_power_setpoint = 0.0f; + sim.argon_flow_setpoint = 0.0f; + emit_event(kCeidProcessCompleted); + return; + } + + const auto& step = (*steps)[i]; + std::cout << "[recipe] step " << (i + 1) << "/" << steps->size() + << ": " << step.name << " (" << step.duration.count() << "s)\n"; + + model->svids.set_value(kSvidStepName, s2::Item::ascii(step.name)); + model->svids.set_value(kSvidStepElapsed, s2::Item::u4(0)); + if (step.target_pressure_torr) sim.target_pressure_torr = *step.target_pressure_torr; + if (step.target_temp_c) sim.target_temp_c = *step.target_temp_c; + if (step.source_power_w) sim.source_power_setpoint = *step.source_power_w; + if (step.gas_flow_sccm) sim.argon_flow_setpoint = *step.gas_flow_sccm; + if (!step.source_material.empty()) + model->svids.set_value(kSvidSourceMaterial, s2::Item::ascii(step.source_material)); + + emit_event(kCeidStepStarted); + + // Run the step for `duration` seconds (compressed to milliseconds + // for demo runs — a real tool would actually wait the duration). + // We use 100ms per declared second so a 120s step takes 12s. + auto step_timer = std::make_shared(io); + auto tick = std::make_shared>(); + auto elapsed = std::make_shared>(0); + *tick = [this, prjobid, steps, i, step_timer, tick, elapsed, + duration_s = step.duration.count()](std::error_code ec) { + if (ec) return; + const uint32_t e = elapsed->fetch_add(1) + 1; + model->svids.set_value(kSvidStepElapsed, s2::Item::u4(e)); + if (e >= static_cast(duration_s)) { + emit_event(kCeidStepCompleted); + run_step(prjobid, steps, i + 1); + return; + } + step_timer->expires_after(100ms); // 1 demo-second + step_timer->async_wait(*tick); + }; + step_timer->expires_after(100ms); + step_timer->async_wait(*tick); + } +}; + +// ============================================================================= +// §4. Alarm threshold monitor +// ============================================================================= + +struct AlarmMonitor { + asio::io_context& io; + std::shared_ptr model; + std::function emit_alarm_set; + std::function emit_alarm_clear; + std::shared_ptr timer; + + AlarmMonitor(asio::io_context& io_, + std::shared_ptr model_, + std::function set_, + std::function clear_) + : io(io_), model(std::move(model_)), + emit_alarm_set(std::move(set_)), emit_alarm_clear(std::move(clear_)), + timer(std::make_shared(io)) {} + + void start() { tick(); } + + private: + void tick() { + // Chamber pressure alarm: trips if > 2x setpoint. + auto p_sv = model->svids.get(kSvidChamberPressure); + auto sp = model->ecids.get(kEcChamberPressureSp); + if (p_sv && sp) { + const float p = std::get>(p_sv->value.storage())[0]; + const float setpoint = std::get>(sp->value.storage())[0]; + const bool over = p > setpoint * 100.0f; // 2 orders of magnitude + const bool was = model->alarms.active(kAlarmPressureHigh); + if (over && !was) emit_alarm_set(kAlarmPressureHigh); + else if (!over && was) emit_alarm_clear(kAlarmPressureHigh); + } + + // Cleaning interval: alarm 10 fires when wafers-since-cleanup + // exceeds the configured interval. + auto wsc = model->svids.get(kSvidWafersSinceClean); + auto ci = model->ecids.get(kEcCleaningInterval); + if (wsc && ci) { + const uint32_t cur = std::get>(wsc->value.storage())[0]; + const uint32_t limit = std::get>(ci->value.storage())[0]; + const bool over = cur >= limit; + const bool was = model->alarms.active(kAlarmCleaningNeeded); + if (over && !was) emit_alarm_set(kAlarmCleaningNeeded); + else if (!over && was) emit_alarm_clear(kAlarmCleaningNeeded); + } + + timer->expires_after(500ms); + timer->async_wait([this](std::error_code ec) { if (!ec) tick(); }); + } +}; + +// ============================================================================= +// §5. EPT cycling — E116 Equipment Performance Tracking +// ============================================================================= +// +// Real tools transition EPT state based on operator actions, alarm +// activity, and process activity. Here we follow a simple rule: +// - Standby when nothing's running and no alarms active +// - Productive when at least one PJ is in Processing +// - UnscheduledDowntime when a safety alarm is active + +struct EptCycler { + std::shared_ptr model; + asio::io_context& io; + std::shared_ptr timer; + + EptCycler(asio::io_context& io_, + std::shared_ptr m) + : model(std::move(m)), io(io_), + timer(std::make_shared(io)) {} + + void start() { tick(); } + + private: + void tick() { + bool any_processing = false; + for (const auto& id : model->process_jobs.ids()) { + auto* pj = model->process_jobs.get(id); + if (pj && pj->fsm->state() == gem::ProcessJobState::Processing) { + any_processing = true; + break; + } + } + const bool safety_alarm = model->alarms.active(kAlarmSourcePowerLoss) || + model->alarms.active(kAlarmCoolingWaterLoss); + const auto cur = model->ept.state(); + gem::EptState target = gem::EptState::Standby; + if (safety_alarm) target = gem::EptState::UnscheduledDowntime; + else if (any_processing) target = gem::EptState::Productive; + + if (cur != target) { + auto ev = gem::EptEvent::EnterStandby; + if (target == gem::EptState::Productive) ev = gem::EptEvent::EnterProductive; + if (target == gem::EptState::UnscheduledDowntime) ev = gem::EptEvent::EnterUnscheduledDown; + model->ept.on_event(ev); + } + model->svids.set_value(kSvidEptName, + s2::Item::ascii(gem::ept_state_name(target))); + timer->expires_after(2s); + timer->async_wait([this](std::error_code ec) { if (!ec) tick(); }); + } +}; + +} // namespace pvd + +// ============================================================================= +// §6. Router handler registration +// ============================================================================= +// +// This is the smallest set of handlers a host needs to talk to the +// tool and run a recipe. apps/secs_server.cpp has the full +// catalogue (~30 more handlers) for terminal services, slot maps, +// E40/E94 jobs, etc.; in production you'd copy that here too. + +void register_handlers(gem::Router& router, + std::shared_ptr model, + std::shared_ptr sm, + const config::EquipmentDescriptor& desc, + std::function emit_event, + std::function emit_alarm_set, + std::shared_ptr recipe) { + + // S1F1 → S1F2 Are You There + router.on(1, 1, [desc](const s2::Message&) { + return gem::s1f2_on_line_data(desc.model_name, desc.software_rev); + }); + + // S1F3 → S1F4 Selected Status Request + router.on(1, 3, [model](const s2::Message& m) { + auto svids = gem::parse_s1f3(m); + if (!svids) return s2::Message(1, 0, false); + std::vector> values; + if (svids->empty()) { + for (const auto& sv : model->svids.all()) values.push_back(sv.value); + } else { + for (auto id : *svids) { + auto sv = model->svids.get(id); + values.push_back(sv ? std::optional(sv->value) : std::nullopt); + } + } + return gem::s1f4_selected_status_data(values); + }); + + // S1F11 → S1F12 Status Variable Namelist Request + router.on(1, 11, [model](const s2::Message&) { + std::vector rows; + for (const auto& sv : model->svids.all()) + rows.push_back({sv.id, sv.name, sv.units}); + return gem::s1f12_status_namelist_data(rows); + }); + + // S1F13 → S1F14 Establish Communications + router.on(1, 13, [desc](const s2::Message&) { + return gem::s1f14_establish_comms_ack(gem::CommAck::Accept, + {desc.model_name, desc.software_rev}); + }); + + // S1F17 → S1F18 Request Online + router.on(1, 17, [sm](const s2::Message&) { + auto ack = sm->on_host_request_online(); + return gem::s1f18_online_ack(ack); + }); + + // S2F13 → S2F14 EC Values + router.on(2, 13, [model](const s2::Message& m) { + auto ids = gem::parse_u4_list_body(m); + if (!ids) return s2::Message(2, 0, false); + std::vector values; + for (auto id : *ids) { + auto ec = model->ecids.get(id); + values.push_back(ec ? ec->value : s2::Item::list({})); + } + return gem::s2f14_ec_data(values); + }); + + // S2F17 → S2F18 Clock + router.on(2, 17, [model](const s2::Message&) { + return gem::s2f18_date_time_data(model->clock.current_time_string()); + }); + + // S2F41 → S2F42 Host Command + router.on(2, 41, [model, emit_event, emit_alarm_set, recipe] + (const s2::Message& m) { + auto cmd = gem::parse_s2f41(m); + if (!cmd) return gem::s2f42_host_command_ack(gem::HostCmdAck::ParameterInvalid, {}); + auto result = model->commands.dispatch(cmd->rcmd, cmd->params); + if (result.ack == gem::HostCmdAck::Accept) { + if (result.emit_ceid) emit_event(*result.emit_ceid); + if (result.set_alarm) emit_alarm_set(*result.set_alarm); + // Demo: RCMD=START with PJ in WaitingForStart triggers the + // recipe runner. Real tools would gate on richer state. + if (cmd->rcmd == "START") { + for (const auto& pjid : model->process_jobs.ids()) { + auto* pj = model->process_jobs.get(pjid); + if (pj && pj->fsm->state() == gem::ProcessJobState::WaitingForStart) { + model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::Start); + recipe->start(pjid); + break; + } + } + } + } + return gem::s2f42_host_command_ack(result.ack, {}); + }); + + // S5F5 → S5F6 List Alarms + router.on(5, 5, [model](const s2::Message& m) { + auto ids = gem::parse_u4_list_body(m); + std::vector alarms; + if (ids && ids->empty()) alarms = model->alarms.all(); + else if (ids) + for (auto id : *ids) { + auto a = model->alarms.get(id); + if (a) alarms.push_back(*a); + } + return gem::s5f6_list_alarms_data( + alarms, [model](uint32_t id) { return model->alarms.active(id); }); + }); + + // S7F5 → S7F6 Process Program Request + router.on(7, 5, [model](const s2::Message& m) { + auto ppid = gem::parse_s7f5(m); + if (!ppid) return gem::s7f6_process_program_data("", ""); + auto body = model->recipes.get(*ppid); + return gem::s7f6_process_program_data(*ppid, body ? *body : ""); + }); + + // S7F19 → S7F20 Current PP List + router.on(7, 19, [model](const s2::Message&) { + return gem::s7f20_current_eppd_data(model->recipes.list()); + }); + + // -------- Extended handlers (mirrors apps/secs_server.cpp) ---------- + // These follow the demo server's patterns one-for-one. A real + // vendor's main.cpp would either include them inline (as we do here) + // or extract them to a shared helper. + + // S1F15 → S1F16 Request Offline + router.on(1, 15, [sm](const s2::Message&) { + return gem::s1f16_offline_ack(sm->on_host_request_offline()); + }); + // S1F19 → S1F20 GEM Compliance + router.on(1, 19, [desc](const s2::Message&) { + std::vector caps; + for (const auto& c : desc.capabilities) caps.push_back({c.first, c.second}); + return gem::s1f20_get_gem_compliance_data( + desc.software_rev, desc.equipment_type, caps); + }); + // S1F21 → S1F22 DVID Namelist + router.on(1, 21, [model](const s2::Message&) { + std::vector rows; + for (const auto& dv : model->dvids.all()) + rows.push_back({dv.id, dv.name, dv.units}); + return gem::s1f22_data_variable_namelist_data(rows); + }); + // S1F23 → S1F24 CEID Namelist + router.on(1, 23, [model](const s2::Message& m) { + auto req = gem::parse_s1f23(m); + std::vector rows; + if (req && req->empty()) { + for (const auto& e : model->events.all_events()) + rows.push_back({e.id, e.name, model->events.vids_for(e.id)}); + } else if (req) { + for (auto id : *req) { + auto info = model->events.event_info(id); + rows.push_back({id, info ? info->name : "", model->events.vids_for(id)}); + } + } + return gem::s1f24_collection_event_namelist_data(rows); + }); + // S2F15 → S2F16 EC Set + router.on(2, 15, [model](const s2::Message& m) { + auto sets = gem::parse_s2f15(m); + auto eac = gem::EquipmentAck::Accept; + if (!sets) eac = gem::EquipmentAck::Denied_OutOfRange; + else for (const auto& s : *sets) { + auto r = model->ecids.set_value(s.ecid, s.value); + if (r != gem::EquipmentAck::Accept) eac = r; + } + return gem::s2f16_ec_ack(eac); + }); + // S2F29 → S2F30 EC Namelist + router.on(2, 29, [model](const s2::Message& m) { + auto ids = gem::parse_u4_list_body(m); + std::vector ecs; + if (ids && ids->empty()) ecs = model->ecids.all(); + else if (ids) for (auto id : *ids) { + auto ec = model->ecids.get(id); + if (ec) ecs.push_back(*ec); + } + std::vector rows; + for (const auto& ec : ecs) + rows.push_back({ec.id, ec.name, ec.min_str, ec.max_str, "", ec.units}); + return gem::s2f30_ec_namelist_data(rows); + }); + // S2F31 → S2F32 Set Clock + router.on(2, 31, [model](const s2::Message& m) { + auto t = gem::parse_s2f31(m); + return gem::s2f32_date_time_ack( + t ? model->clock.set_time_string(*t) : gem::TimeAck::Error); + }); + // S2F33/F35/F37 Dynamic event report config + router.on(2, 33, [model](const s2::Message& m) { + auto req = gem::parse_s2f33(m); + auto ack = gem::DefineReportAck::InvalidFormat; + if (req) { + std::vector>> rows; + for (const auto& r : req->reports) rows.emplace_back(r.rptid, r.vids); + ack = model->define_reports(rows); + } + return gem::s2f34_define_report_ack(ack); + }); + router.on(2, 35, [model](const s2::Message& m) { + auto req = gem::parse_s2f35(m); + auto ack = gem::LinkEventAck::InvalidFormat; + if (req) { + std::vector>> rows; + for (const auto& l : req->links) rows.emplace_back(l.ceid, l.rptids); + ack = model->link_event_reports(rows); + } + return gem::s2f36_link_event_report_ack(ack); + }); + router.on(2, 37, [model](const s2::Message& m) { + auto req = gem::parse_s2f37(m); + auto ack = req ? model->enable_events(req->enable, req->ceids) + : gem::EnableEventAck::UnknownCeid; + return gem::s2f38_enable_event_ack(ack); + }); + // S2F21 Legacy remote command + router.on(2, 21, [model, emit_event, emit_alarm_set](const s2::Message& m) { + auto rcmd = gem::parse_s2f21(m); + if (!rcmd) return gem::s2f22_remote_command_ack(gem::HostCmdAck::ParameterInvalid); + auto result = model->commands.dispatch(*rcmd, {}); + if (result.ack == gem::HostCmdAck::Accept) { + if (result.emit_ceid) emit_event(*result.emit_ceid); + if (result.set_alarm) emit_alarm_set(*result.set_alarm); + } + return gem::s2f22_remote_command_ack(result.ack); + }); + // S2F23 trace, S2F43 spool reset, S2F45 limits, S2F47 limit attrs + router.on(2, 23, [model](const s2::Message& m) { + auto req = gem::parse_s2f23(m); + auto ack = gem::TraceAck::Accept; + if (!req) ack = gem::TraceAck::InvalidPeriod; + else for (auto v : req->svids) + if (!model->vid_exists(v)) { ack = gem::TraceAck::UnknownVid; break; } + return gem::s2f24_trace_initialize_ack(ack); + }); + router.on(2, 43, [model](const s2::Message& m) { + auto streams = gem::parse_s2f43(m); + if (streams) model->spool.set_spoolable_streams(*streams); + return gem::s2f44_reset_spooling_ack( + streams ? gem::ResetSpoolAck::Accept : gem::ResetSpoolAck::Denied_NotAllowed, {}); + }); + router.on(2, 47, [model](const s2::Message& m) { + auto vids = gem::parse_s2f47(m); + std::vector rows; + if (vids) { + const auto target = vids->empty() ? model->limits.all_vids() : *vids; + for (auto v : target) rows.push_back({v, model->limits.get_for_vid(v)}); + } + return gem::s2f48_variable_limit_attribute_data(rows); + }); + // S2F49 Enhanced remote command + router.on(2, 49, [model, emit_event](const s2::Message& m) { + auto cmd = gem::parse_s2f49(m); + if (!cmd) return gem::s2f50_enhanced_host_command_ack( + gem::HostCmdAck::ParameterInvalid, {}); + auto result = model->commands.dispatch(cmd->rcmd, cmd->params); + if (result.ack == gem::HostCmdAck::Accept && result.emit_ceid) + emit_event(*result.emit_ceid); + return gem::s2f50_enhanced_host_command_ack(result.ack, {}); + }); + // S5F3 enable alarm, S5F7 list enabled + router.on(5, 3, [model](const s2::Message& m) { + auto req = gem::parse_s5f3(m); + return gem::s5f4_enable_alarm_ack( + req ? model->alarms.set_enabled(req->alid, (req->aled & 0x80) != 0) + : gem::AlarmAck::Error); + }); + router.on(5, 7, [model](const s2::Message&) { + std::vector rows; + for (const auto& a : model->alarms.all()) { + if (!model->alarms.enabled(a.id)) continue; + const uint8_t alcd = (a.severity_category & 0x7F) | + (model->alarms.active(a.id) ? 0x80 : 0x00); + rows.push_back({alcd, a.id, a.text}); + } + return gem::s5f8_list_enabled_alarms_data(rows); + }); + // S5F13/F17 exception recover + router.on(5, 13, [model](const s2::Message& m) { + auto req = gem::parse_s5f13(m); + return gem::s5f14_exception_recover_ack( + req ? model->exceptions.on_recover(req->exid, req->exrecvra) + : gem::AlarmAck::Error); + }); + router.on(5, 17, [model](const s2::Message& m) { + auto exid = gem::parse_s5f17(m); + return gem::s5f18_exception_recover_abort_ack( + exid ? model->exceptions.on_recover_abort(*exid) : gem::AlarmAck::Error); + }); + // S6F15/F19/F21 host-initiated event/report queries + router.on(6, 15, [model](const s2::Message& m) { + auto ceid = gem::parse_s6f15(m); + if (!ceid) return gem::s6f16_event_report_data({0, 0, {}}); + return gem::s6f16_event_report_data({0, *ceid, model->compose_reports_for(*ceid)}); + }); + router.on(6, 19, [model](const s2::Message& m) { + auto rptid = gem::parse_s6f19(m); + std::vector values; + if (rptid) for (const auto& r : model->events.all_reports()) { + if (r.id != *rptid) continue; + for (auto vid : r.vids) { + auto v = model->vid_value(vid); + values.push_back(v ? *v : s2::Item::list({})); + } + break; + } + return gem::s6f20_individual_report_data(values); + }); + router.on(6, 21, [model](const s2::Message& m) { + auto rptid = gem::parse_s6f21(m); + std::vector rows; + if (rptid) for (const auto& r : model->events.all_reports()) { + if (r.id != *rptid) continue; + for (auto vid : r.vids) { + auto v = model->vid_value(vid); + rows.push_back({vid, v ? *v : s2::Item::list({})}); + } + break; + } + return gem::s6f22_annotated_report_data(rows); + }); + // S6F23 spool data request + router.on(6, 23, [model](const s2::Message& m) { + auto rsdc = gem::parse_s6f23(m); + if (!rsdc) return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Denied); + if (*rsdc == gem::SpoolRequestCode::Purge) model->spool.clear(); + else model->spool.drain(); // demo: drop the drained messages + return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept); + }); + // S7F1 PP load inquire, S7F3 PP send, S7F17 PP delete + router.on(7, 1, [](const s2::Message& m) { + auto req = gem::parse_s7f1(m); + auto ack = gem::ProcessProgramAck::Accept; + if (!req || req->ppid.empty()) ack = gem::ProcessProgramAck::PpidNotFound; + return gem::s7f2_pp_load_grant(ack); + }); + router.on(7, 3, [model](const s2::Message& m) { + auto pp = gem::parse_s7f3(m); + if (!pp) return gem::s7f4_process_program_ack(gem::ProcessProgramAck::LengthError); + model->recipes.add(pp->ppid, pp->ppbody); + return gem::s7f4_process_program_ack(gem::ProcessProgramAck::Accept); + }); + router.on(7, 17, [model](const s2::Message& m) { + auto req = gem::parse_s7f17(m); + if (!req) return gem::s7f18_delete_pp_ack(gem::ProcessProgramAck::LengthError); + if (req->empty()) for (const auto& id : model->recipes.list()) model->recipes.remove(id); + else for (const auto& id : *req) model->recipes.remove(id); + return gem::s7f18_delete_pp_ack(gem::ProcessProgramAck::Accept); + }); + // S10F3 host→equipment terminal display, S10F5 multi-line + router.on(10, 3, [](const s2::Message& m) { + auto td = gem::parse_s10f3(m); + if (td) std::cout << "[TERMINAL " << static_cast(td->tid) + << "] " << td->text << "\n"; + return gem::s10f4_terminal_display_ack(gem::TerminalAck::Accepted); + }); + router.on(10, 5, [](const s2::Message& m) { + auto td = gem::parse_s10f5(m); + if (td) for (const auto& line : td->lines) + std::cout << "[TERMINAL " << static_cast(td->tid) << "] " << line << "\n"; + return gem::s10f6_terminal_display_multi_ack(gem::TerminalAck::Accepted); + }); + // S3 — E87 carriers (basic acceptance) + router.on(3, 17, [model](const s2::Message& m) { + auto req = gem::parse_s3f17(m); + if (!req) return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::ParameterInvalid); + if (!model->carriers.has(req->carrierid)) + return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::CarrierIDUnknown); + return gem::s3f18_carrier_action_ack(gem::CarrierActionAck::Accept); + }); + router.on(3, 19, [model](const s2::Message& m) { + auto req = gem::parse_s3f19(m); + if (!req) return gem::s3f20_slot_map_verify_ack(gem::SlotMapVerifyAck::Error); + return gem::s3f20_slot_map_verify_ack( + model->carriers.has(req->carrierid) ? gem::SlotMapVerifyAck::Accept + : gem::SlotMapVerifyAck::CarrierUnknown); + }); + router.on(3, 25, [](const s2::Message&) { + return gem::s3f26_carrier_transfer_ack(gem::CarrierActionAck::Accept); + }); + router.on(3, 27, [model](const s2::Message& m) { + auto cid = gem::parse_s3f27(m); + return gem::s3f28_cancel_carrier_ack( + cid && model->carriers.has(*cid) ? gem::CarrierActionAck::Accept + : gem::CarrierActionAck::CarrierIDUnknown); + }); + // S14 — E39 GetAttr + E94 CJ create/delete + router.on(14, 1, [model](const s2::Message& m) { + auto req = gem::parse_s14f1(m); + if (!req) return gem::s14f2_get_attr_data({}, gem::ObjectAck::Error); + auto* obj = model->cem.get(req->objspec); + if (!obj) return gem::s14f2_get_attr_data({}, gem::ObjectAck::Denied_UnknownObject); + std::vector attrs; + for (const auto& id : req->attrids) { + auto v = model->cem.get_attr(req->objspec, id); + attrs.push_back({id, v.value_or(s2::Item::ascii(""))}); + } + return gem::s14f2_get_attr_data(attrs, gem::ObjectAck::Success); + }); + router.on(14, 9, [model](const s2::Message& m) { + auto req = gem::parse_s14f9(m); + if (!req) return gem::s14f10_create_control_job_ack("", gem::ObjectAck::Error); + auto r = model->control_jobs.create(req->ctljobid, req->prjobids, + [model](const std::string& id) { return model->process_jobs.has(id); }); + auto ack = (r == gem::ControlJobStore::CreateResult::Created) + ? gem::ObjectAck::Success + : gem::ObjectAck::Denied_UnknownObject; + return gem::s14f10_create_control_job_ack(req->ctljobid, ack); + }); + router.on(14, 11, [model](const s2::Message& m) { + auto id = gem::parse_s14f11(m); + return gem::s14f12_delete_control_job_ack( + id && model->control_jobs.remove(*id) ? gem::ObjectAck::Success + : gem::ObjectAck::Denied_UnknownObject); + }); + // S16 — E40 PJ create/command/dequeue/monitor, E94 CJ command + router.on(16, 11, [model](const s2::Message& m) { + auto req = gem::parse_s16f11(m); + if (!req) return gem::s16f12_pr_job_create_ack(gem::HostCmdAck::ParameterInvalid); + auto r = model->process_jobs.create(req->prjobid, req->rcpspec.ppid, req->mtrloutspec, + [model](const std::string& ppid) { return model->recipes.get(ppid).has_value(); }); + auto ack = gem::HostCmdAck::Accept; + if (r == gem::ProcessJobStore::CreateResult::Denied_AlreadyExists) + ack = gem::HostCmdAck::Rejected; + else if (r == gem::ProcessJobStore::CreateResult::Denied_InvalidPpid) + ack = gem::HostCmdAck::ParameterInvalid; + return gem::s16f12_pr_job_create_ack(ack); + }); + router.on(16, 5, [model](const s2::Message& m) { + auto req = gem::parse_s16f5(m); + if (!req) return gem::s16f6_pr_job_command_ack(gem::HostCmdAck::ParameterInvalid); + auto ev = gem::pr_cmd_to_event(req->prcmd); + if (!ev) return gem::s16f6_pr_job_command_ack(gem::HostCmdAck::InvalidCommand); + return gem::s16f6_pr_job_command_ack(model->process_jobs.on_host_command(req->prjobid, *ev)); + }); + router.on(16, 7, [model](const s2::Message& m) { + auto req = gem::parse_s16f7(m); + if (!req) return gem::s16f8_pr_job_monitor_ack(gem::HostCmdAck::ParameterInvalid); + for (const auto& e : req->entries) + model->process_jobs.set_alert(e.prjobid, (e.pralert & 0x80) != 0); + return gem::s16f8_pr_job_monitor_ack(gem::HostCmdAck::Accept); + }); + router.on(16, 13, [model](const s2::Message& m) { + auto id = gem::parse_s16f13(m); + return gem::s16f14_pr_job_dequeue_ack( + id ? model->process_jobs.dequeue(*id) : gem::HostCmdAck::ParameterInvalid); + }); + router.on(16, 27, [model](const s2::Message& m) { + auto req = gem::parse_s16f27(m); + if (!req) return gem::s16f28_cj_command_ack(gem::HostCmdAck::ParameterInvalid); + auto ev = gem::ctl_cmd_to_event(req->ctljobcmd); + if (!ev) return gem::s16f28_cj_command_ack(gem::HostCmdAck::InvalidCommand); + return gem::s16f28_cj_command_ack( + model->control_jobs.on_host_command(req->ctljobid, *ev)); + }); +} + +// ============================================================================= +// §7. main() +// ============================================================================= + +int main(int argc, char** argv) { + const std::string config_path = (argc > 1) ? argv[1] : + "/app/examples/pvd_tool/equipment.yaml"; + const std::string state_path = (argc > 2) ? argv[2] : + "/app/data/control_state.yaml"; + const uint16_t port = (argc > 3) ? + static_cast(std::stoi(argv[3])) : 5000; + const uint16_t metrics_port = (argc > 4) ? + static_cast(std::stoi(argv[4])) : 9090; + + // ---- §7.1 Validate the YAML configs before binding the port ---------- + { + config::ConfigValidator v; + v.validate_equipment(config_path); + v.validate_control_state(state_path); + if (v.has_errors()) { + config::format_issues_to(std::cerr, v.issues()); + std::cerr << v.error_count() << " config error(s); refusing to start\n"; + return 1; + } + } + + auto logfn = [](const std::string& m) { + std::cout << "[pvd] " << m << "\n"; + }; + + // ---- §7.2 Build the data model --------------------------------------- + auto model = std::make_shared(); + config::EquipmentDescriptor desc; + config::ControlStateConfig sm_cfg; + try { + desc = config::load_equipment(config_path, *model); + sm_cfg = config::load_control_state(state_path); + } catch (const std::exception& e) { + std::cerr << "[pvd] config load failed: " << e.what() << "\n"; + return 1; + } + auto sm = std::make_shared( + sm_cfg.table, sm_cfg.initial); + + logfn("loaded " + std::to_string(model->svids.size()) + " SVIDs, " + + std::to_string(model->ecids.all().size()) + " ECIDs, " + + std::to_string(model->events.all_events().size()) + " CEIDs, " + + std::to_string(model->alarms.all().size()) + " alarms, " + + std::to_string(model->recipes.list().size()) + " recipes"); + + asio::io_context io; + + // ---- §7.3 Metrics exporter on a second port -------------------------- + auto registry = std::make_shared(); + registry->describe("pvd_messages_total", "SECS messages dispatched", + metrics::MetricType::Counter); + registry->describe("pvd_chamber_pressure_torr", "Process chamber pressure", + metrics::MetricType::Gauge); + registry->describe("pvd_spool_depth", "Queued spool messages", + metrics::MetricType::Gauge); + auto exporter = std::make_shared(io, metrics_port, registry); + exporter->start(); + logfn("metrics exporter on :" + std::to_string(metrics_port) + "/metrics"); + + // ---- §7.4 Sensor simulator, EPT cycler, alarm monitor --------------- + auto sim = std::make_shared(io, model); + sim->start(); + auto ept = std::make_shared(io, model); + ept->start(); + + // ---- §7.5 Server + handler wiring ------------------------------------ + Server::Config server_cfg{port, desc.device_id, {}}; + Server server(io, server_cfg); + server.on_log(logfn); + + auto active_conn = std::make_shared>(); + + // Shared event-emission helper. + auto deliver_or_spool = [active_conn, model, logfn](s2::Message msg) { + auto conn = active_conn->lock(); + if (!conn) { + model->spool.enqueue(msg); + return; + } + if (msg.reply_expected) { + conn->send_request(std::move(msg), [](std::error_code, const s2::Message&) {}); + } else { + conn->send_data(std::move(msg)); + } + }; + auto emit_event = [&io, model, deliver_or_spool, registry](uint32_t ceid) { + asio::post(io, [model, deliver_or_spool, ceid]() { + if (!model->is_event_enabled(ceid)) return; + auto reports = model->compose_reports_for(ceid); + deliver_or_spool(gem::s6f11_event_report(0, ceid, reports)); + }); + registry->inc("pvd_events_total", {{"ceid", std::to_string(ceid)}}); + }; + auto emit_alarm_set = [&io, model, deliver_or_spool, registry](uint32_t alid) { + asio::post(io, [model, deliver_or_spool, alid]() { + auto alarm = model->alarms.get(alid); + auto alcd = model->alarms.set_active(alid); + if (!alarm || !alcd || !model->alarms.enabled(alid)) return; + deliver_or_spool(gem::s5f1_alarm_report(*alcd, alid, alarm->text)); + }); + registry->inc("pvd_alarm_set_total", {{"alid", std::to_string(alid)}}); + }; + auto emit_alarm_clear = [&io, model, deliver_or_spool](uint32_t alid) { + asio::post(io, [model, deliver_or_spool, alid]() { + auto alarm = model->alarms.get(alid); + auto alcd = model->alarms.clear_active(alid); + if (!alarm || !alcd || !model->alarms.enabled(alid)) return; + deliver_or_spool(gem::s5f1_alarm_report(*alcd, alid, alarm->text)); + }); + }; + + auto alarm_mon = std::make_shared( + io, model, emit_alarm_set, emit_alarm_clear); + alarm_mon->start(); + + auto recipe_runner = std::make_shared( + io, model, *sim, emit_event); + + // Wire control-state-change → CEID emission. + sm->set_state_change_handler( + [logfn, emit_event, desc](gem::ControlState from, gem::ControlState to, + gem::ControlEvent ev) { + logfn(std::string("control: ") + gem::control_state_name(from) + + " -> " + gem::control_state_name(to) + + " (" + gem::control_event_name(ev) + ")"); + if (desc.emit_on_control_change) + emit_event(*desc.emit_on_control_change); + }); + + // ---- §7.6 Periodic gauge updates for Prometheus ---------------------- + auto gauge_timer = std::make_shared(io); + std::function gauge_tick = + [&gauge_tick, gauge_timer, model, registry](std::error_code ec) { + if (ec) return; + auto p = model->svids.get(pvd::kSvidChamberPressure); + if (p) { + const float v = std::get>(p->value.storage())[0]; + registry->set_gauge("pvd_chamber_pressure_torr", v); + } + registry->set_gauge("pvd_spool_depth", model->spool.size()); + gauge_timer->expires_after(5s); + gauge_timer->async_wait(gauge_tick); + }; + gauge_timer->expires_after(5s); + gauge_timer->async_wait(gauge_tick); + + // ---- §7.7 Router + per-connection handler wiring -------------------- + gem::Router router; + register_handlers(router, model, sm, desc, + emit_event, emit_alarm_set, recipe_runner); + logfn("registered " + std::to_string(router.size()) + " SECS-II handlers"); + + server.on_connection([&io, sm, model, logfn, active_conn, &router, registry] + (std::shared_ptr conn) { + *active_conn = conn; + conn->set_closed_handler([active_conn](const std::string&) { + active_conn->reset(); + }); + conn->set_selected_handler([logfn, sm]() { + logfn(std::string("host SELECTED; control=") + + gem::control_state_name(sm->state())); + }); + conn->set_message_handler( + [&router, model, conn, registry](const s2::Message& msg) + -> std::optional { + registry->inc("pvd_messages_total", + {{"dir", "rx"}, + {"stream", std::to_string(msg.stream)}, + {"function", std::to_string(msg.function)}}); + return router.dispatch_with_s9( + [&](uint8_t f, const std::array& mh) { + conn->emit_s9(f, mh); + }, + [&]() -> std::optional> { + auto* h = conn->current_header(); + return h ? std::optional{h->encode()} : std::nullopt; + }, msg); + }); + }); + + server.start(); + logfn("ACME-PVD-3000 ready, listening on :" + std::to_string(port)); + io.run(); + return 0; +}