E40 Process Jobs + E94 Control Jobs + E30 communication state

GEM300 layer: SEMI E40-0705 Process Job and E94-0705 Control Job
state machines, plus the E30 §6.1 communication-state machine that
sits between HSMS SELECT and full GEM communication. Data-driven
via data/process_job_state.yaml and data/control_job_state.yaml,
mirroring the existing control_state.yaml pattern.

Wire coverage:
  S14F9/F10   CreateObject (CJ)              host -> equipment
  S14F11/F12  DeleteObject (CJ)              host -> equipment
  S16F5/F6    PRJobCommand                   host -> equipment
  S16F9       PRJobAlert                     equipment -> host
  S16F11/F12  PRJobCreate (simplified body)  host -> equipment
  S16F13/F14  PRJobDequeue                   host -> equipment
  S16F27/F28  CJobCommand                    host -> equipment

Process Job FSM exposes 8 states matching PRJOBSTATE bytes (E40 §10.3.2);
HOQ is reorder-aware (move-to-head against an insertion-order vector);
Stop/Abort on a Queued PJ routes through ABORTING so the host observes
PRJOBSTATE=7 on the wire (§6.3); alert_enabled is settable per-PJ for
PRALERT control; FSM dispatches through ProcessJobStore::on_change_
dynamically so a late set_state_change_handler() reaches existing PJs.

Hardening: loader rejects NoState (sentinel) as initial/from/to and
rejects `on: created` rows; static_asserts pin enum values to wire
bytes; ProcessJobStore is non-movable to keep the per-PJ this-capture
safe.

Server simulator cascades the full CJ -> PJ lifecycle on CJSTART so
the wire trace exercises every legal state. CEIDs 400/401 fire on CJ
state changes via the existing event-report pipeline.

Tests: 60+ new assertions across test_process_jobs, test_control_jobs,
test_communication_state, test_hsms_connection, plus loader and
messages round-trip coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 21:00:32 +02:00
parent 1f67aad985
commit 90c177b7ce
33 changed files with 3122 additions and 62 deletions
+7
View File
@@ -46,6 +46,9 @@ add_library(secsgem
src/hsms/header.cpp src/hsms/header.cpp
src/hsms/connection.cpp src/hsms/connection.cpp
src/gem/control_state.cpp src/gem/control_state.cpp
src/gem/communication_state.cpp
src/gem/process_job_state.cpp
src/gem/control_job_state.cpp
src/config/loader.cpp src/config/loader.cpp
src/endpoint.cpp src/endpoint.cpp
) )
@@ -77,10 +80,14 @@ add_executable(secsgem_tests
tests/test_main.cpp tests/test_main.cpp
tests/test_secs2.cpp tests/test_secs2.cpp
tests/test_hsms.cpp tests/test_hsms.cpp
tests/test_hsms_connection.cpp
tests/test_control_state.cpp tests/test_control_state.cpp
tests/test_communication_state.cpp
tests/test_data_model.cpp tests/test_data_model.cpp
tests/test_messages.cpp tests/test_messages.cpp
tests/test_loader.cpp tests/test_loader.cpp
tests/test_process_jobs.cpp
tests/test_control_jobs.cpp
) )
target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest) target_link_libraries(secsgem_tests PRIVATE secsgem doctest::doctest)
target_compile_definitions(secsgem_tests PRIVATE target_compile_definitions(secsgem_tests PRIVATE
+86 -16
View File
@@ -1,7 +1,7 @@
# SECS/GEM Compliance # SECS/GEM Compliance
A per-capability accounting against SEMI **E5 (SECS-II)**, **E30 (GEM)**, A per-capability accounting against SEMI **E5 (SECS-II)**, **E30 (GEM)**,
and **E37 (HSMS)**. **E37 (HSMS)**, and **GEM300 E40 / E94** (process / control jobs).
> **Status.** Every GEM Fundamental capability and every GEM Additional > **Status.** Every GEM Fundamental capability and every GEM Additional
> capability that E30 ties to a concrete SECS-II message set is > capability that E30 ties to a concrete SECS-II message set is
@@ -52,8 +52,9 @@ Legend:
| `U1, U2, U4, U8` (big-endian) | ✅ | E5 §9.5 | | | `U1, U2, U4, U8` (big-endian) | ✅ | E5 §9.5 | |
| `I1, I2, I4, I8` (big-endian, two's complement) | ✅ | E5 §9.5 | | | `I1, I2, I4, I8` (big-endian, two's complement) | ✅ | E5 §9.5 | |
| `F4, F8` (IEEE 754 big-endian) | ✅ | E5 §9.5 | bit-cast round-trip. | | `F4, F8` (IEEE 754 big-endian) | ✅ | E5 §9.5 | bit-cast round-trip. |
| JIS-8 / C2 (Unicode) | | E5 §9.5 | Rarely used in modern fabs. | | JIS-8 (single-byte JIS text) | | E5 §9.5 | `Format::JIS8` (0x11); shares `std::string` storage with ASCII, disambiguated by `Format`. |
| SML text rendering | ✅ | E5 Annex | `secs2::to_sml`. | | C2 (Unicode 2-byte code points) | ✅ | E5 §9.5 | `Format::C2` (0x12); big-endian uint16_t code points. |
| SML text rendering | ✅ | E5 Annex | `secs2::to_sml`. JIS-8 prints as `<J "...">`, C2 as `<C 65 66 ...>`. |
--- ---
@@ -76,7 +77,7 @@ Legend:
| Additional Capability | Status | Spec ref | Messages | Notes | | Additional Capability | Status | Spec ref | Messages | Notes |
|---------------------------------------|--------|----------|----------|-------| |---------------------------------------|--------|----------|----------|-------|
| Establish Communications | ✅ | E30 §6.5 | S1F13/F14 | Both directions modeled; COMMACK enum. | | Establish Communications | ✅ | E30 §6.5 | S1F13/F14 | Both directions modeled; COMMACK enum. Backed by the E30 §6.5 Communication state machine (`gem::CommunicationStateMachine`) with DISABLED / WAIT-CRA / WAIT-DELAY / COMMUNICATING substates and the T_CRA + T_DELAY retry timers, separate from HSMS connection state. |
| Dynamic Event Report Configuration | ✅ | E30 §6.6 | S2F33/F34, S2F35/F36, S2F37/F38 | Full Define-Report / Link-Event / Enable-Event pipeline with all four ack enums. | | Dynamic Event Report Configuration | ✅ | E30 §6.6 | S2F33/F34, S2F35/F36, S2F37/F38 | Full Define-Report / Link-Event / Enable-Event pipeline with all four ack enums. |
| Variable Data Collection | ✅ | E30 §6.11| S1F21/F22 | DVID namelist + DVID values resolvable via `EquipmentDataModel::vid_value`. | | Variable Data Collection | ✅ | E30 §6.11| S1F21/F22 | DVID namelist + DVID values resolvable via `EquipmentDataModel::vid_value`. |
| Trace Data Collection | ✅ | E30 §6.12| S2F23/F24, S6F1/F2 | `TraceStore` keeps active TRID→TraceConfig; periodic sampling left to the application's scheduler. | | Trace Data Collection | ✅ | E30 §6.12| S2F23/F24, S6F1/F2 | `TraceStore` keeps active TRID→TraceConfig; periodic sampling left to the application's scheduler. |
@@ -85,7 +86,7 @@ Legend:
| Remote Control | ✅ | E30 §6.15| S2F41/F42 | Full HCACK 7-value enum + per-parameter CPACKs. | | Remote Control | ✅ | E30 §6.15| S2F41/F42 | Full HCACK 7-value enum + per-parameter CPACKs. |
| Equipment Constants | ✅ | E30 §6.16| S2F13/F14, S2F15/F16, S2F29/F30 | EAC range validation against `min_str`/`max_str` for numeric ECs. | | Equipment Constants | ✅ | E30 §6.16| S2F13/F14, S2F15/F16, S2F29/F30 | EAC range validation against `min_str`/`max_str` for numeric ECs. |
| Process Program Management | ✅ | E30 §6.17| S7F3/F4, S7F5/F6, S7F19/F20 | Unformatted PP send/request/list — the minimum E30 GEM requires. (E42 *enhanced* PP is a separate SEMI standard; see §7.) | | Process Program Management | ✅ | E30 §6.17| S7F3/F4, S7F5/F6, S7F19/F20 | Unformatted PP send/request/list — the minimum E30 GEM requires. (E42 *enhanced* PP is a separate SEMI standard; see §7.) |
| Material Movement | | E30 §6.18| — | See §7. | | Material Movement | 🟡 | E30 §6.18| — | Process Job + Control Job lifecycle covered via E40/E94 (see §4a). Carrier (E87) and substrate (E90) still out of scope. |
| Equipment Terminal Services | ✅ | E30 §6.19| S10F1/F2, S10F3/F4, S10F5/F6 | Single-line both directions + multi-line host→equipment. S10F7 broadcast intentionally omitted (rarely used). | | Equipment Terminal Services | ✅ | E30 §6.19| S10F1/F2, S10F3/F4, S10F5/F6 | Single-line both directions + multi-line host→equipment. S10F7 broadcast intentionally omitted (rarely used). |
| Clock | ✅ | E30 §6.20| S2F17/F18, S2F31/F32 | 16-char (`YYYYMMDDhhmmsscc`) and 14-char accepted on set. | | Clock | ✅ | E30 §6.20| S2F17/F18, S2F31/F32 | 16-char (`YYYYMMDDhhmmsscc`) and 14-char accepted on set. |
| Limits Monitoring | ✅ | E30 §6.21| S2F45/F46, S2F47/F48 | `LimitMonitorStore` keyed by VID with multiple `LimitDefinition` (LIMITID + upper/lower as arbitrary Items). | | Limits Monitoring | ✅ | E30 §6.21| S2F45/F46, S2F47/F48 | `LimitMonitorStore` keyed by VID with multiple `LimitDefinition` (LIMITID + upper/lower as arbitrary Items). |
@@ -94,6 +95,51 @@ Legend:
--- ---
## 4a. E40 Process Jobs + E94 Control Jobs (GEM300)
The first GEM300 extension landing on the spec-as-data architecture.
Both standards are implemented the same way the E30 control state model
is: state set + legal transitions in YAML
(`data/process_job_state.yaml`, `data/control_job_state.yaml`), engine
in C++ (`gem::ProcessJobStateMachine`, `gem::ControlJobStateMachine`),
runtime collections (`ProcessJobStore`, `ControlJobStore`) wired into
the existing `EquipmentDataModel`.
| Capability | Status | Spec ref | Messages | Notes |
|---------------------------------------|--------|----------|----------|-------|
| E40 PJ state model | ✅ | E40 §6.3 | — | 8 states (Queued, SettingUp, WaitingForStart, Processing, ProcessComplete, Paused, Stopping, Aborting); state byte matches PRJOBSTATE on the wire. |
| E40 PRJobCreate | ✅ | E40 §10.2| S16F11/F12 | Body simplified to <L,3 PRJOBID PPID L,n MTRLOUTSPEC>; MF/PRRECIPEMETHOD/PRPROCESSPARAMS are documented as YAML-extension points. PPID validated against `RecipeStore`. |
| E40 PRJobDequeue | ✅ | E40 §10.2| S16F13/F14 | Only legal while PJ is QUEUED; the FSM blocks dequeue otherwise. |
| E40 PRJobCommand | ✅ | E40 §10.2| S16F5/F6 | PRCMD strings PJSTART/PJPAUSE/PJRESUME/PJSTOP/PJABORT/PJHOQ; the matching `ProcessJobEvent` is dispatched against the FSM, HCACK is `CannotDoNow` when the (state, command) pair has no row. |
| E40 PRJobAlert | ✅ | E40 §10.3| S16F9 | Equipment-initiated one-way (W=0). Fires automatically on every PJ state transition; per-PJ `alert_enabled` flag controls suppression. |
| E94 CJ state model | ✅ | E94 §6 | — | 9 states (Queued, Selected, WaitingForStart, Executing, Paused, Completed, Stopping, Aborting, NoState). CJ owns an ordered `prjobids` list. |
| E94 CreateObject (CJ) | ✅ | E94 §6.4 | S14F9/F10 | Body simplified to <L,2 CTLJOBID L,n PRJOBIDs>; full generic E14 ObjectService form is a YAML extension. OBJACK enum covers Success/Error + the four `Denied_*` cases. |
| E94 DeleteObject (CJ) | ✅ | E94 §6.4 | S14F11/F12 | |
| E94 CJobCommand | ✅ | E94 §6.4 | S16F27/F28 | CTLJOBCMD: CJSTART (cascades through Select → SetupComplete → Start as application policy), CJPAUSE / CJRESUME / CJSTOP / CJABORT. |
| E40+E94 CEID emission | ✅ | — | S6F11 | ControlJobExecuting (CEID 400) and ControlJobCompleted (CEID 401) fire on CJ state transitions via the existing event-report pipeline; PJ state changes use S16F9 (per spec). |
The demo's `run_cj_lifecycle` cascade — on CJSTART the CJ steps Queued
→ Selected → WaitingForStart → Executing and every contained PJ steps
through SettingUp → WaitingForStart → Processing → ProcessComplete —
is **application policy**, not the FSM. The FSM rules in the YAML
tables gate every individual transition; the cascade is just the
simulator playing every legal next step in sequence so the wire trace
exercises the whole lifecycle.
What's **out of scope for the E40/E94 first pass** (deliberate; all are
YAML/handler extensions, not surgery):
- Full E40 S16F11 body (MF / PRRECIPEMETHOD / RCPSPEC / PRPROCESSPARAMS).
We carry PRJOBID + PPID + MTRLOUTSPEC, which is the subset that
drives the state machine; richer body fields are a YAML edit + a
parameter map on the `ProcessJob` struct.
- S16F15/F16 PRJobCreateMultiple, S16F17/F18 PRJobMultipleDequeue.
- E14 generic ObjectService form. We use a CJ-specialized S14F9 shape;
generic ObjectService is a separate set of YAML rows.
- E87 Carrier Management and E90 Substrate Tracking — Layer 5
continues there per `implementation_plan.md`.
---
## 5. Message coverage matrix ## 5. Message coverage matrix
| Pair | Direction | Status | Implemented in | Tested | | Pair | Direction | Status | Implemented in | Tested |
@@ -110,6 +156,7 @@ Legend:
| S2F15 / S2F16 | H→E | ✅ | catalog | ✅ round-trip | | S2F15 / S2F16 | H→E | ✅ | catalog | ✅ round-trip |
| S2F17 / S2F18 | H→E | ✅ | catalog | ✅ round-trip + demo | | S2F17 / S2F18 | H→E | ✅ | catalog | ✅ round-trip + demo |
| S2F23 / S2F24 | H→E | ✅ | catalog | ✅ round-trip | | S2F23 / S2F24 | H→E | ✅ | catalog | ✅ round-trip |
| S2F25 / S2F26 | H→E | ✅ | catalog | ✅ round-trip (loopback diagnostic) |
| S2F29 / S2F30 | H→E | ✅ | catalog | ✅ round-trip + demo | | S2F29 / S2F30 | H→E | ✅ | catalog | ✅ round-trip + demo |
| S2F31 / S2F32 | H→E | ✅ | catalog | ✅ round-trip + demo | | S2F31 / S2F32 | H→E | ✅ | catalog | ✅ round-trip + demo |
| S2F33 / S2F34 | H→E | ✅ | catalog | ✅ round-trip + demo | | S2F33 / S2F34 | H→E | ✅ | catalog | ✅ round-trip + demo |
@@ -123,6 +170,8 @@ Legend:
| S5F3 / S5F4 | H→E | ✅ | catalog | ✅ round-trip + demo | | S5F3 / S5F4 | H→E | ✅ | catalog | ✅ round-trip + demo |
| S5F5 / S5F6 | H→E | ✅ | catalog | ✅ round-trip + demo | | S5F5 / S5F6 | H→E | ✅ | catalog | ✅ round-trip + demo |
| S5F7 / S5F8 | H→E | ✅ | catalog | ✅ round-trip | | S5F7 / S5F8 | H→E | ✅ | catalog | ✅ round-trip |
| S5F9 / S5F10 | E→H | ✅ | catalog | ✅ round-trip (exception post) |
| S5F11 / S5F12 | E→H | ✅ | catalog | ✅ round-trip (exception clear) |
| S6F1 / S6F2 | E→H | ✅ | catalog | ✅ round-trip | | S6F1 / S6F2 | E→H | ✅ | catalog | ✅ round-trip |
| S6F11 / S6F12 | E→H | ✅ | catalog | ✅ round-trip + demo | | S6F11 / S6F12 | E→H | ✅ | catalog | ✅ round-trip + demo |
| S6F23 / S6F24 | H→E | ✅ | catalog | ✅ round-trip + demo | | S6F23 / S6F24 | H→E | ✅ | catalog | ✅ round-trip + demo |
@@ -140,6 +189,13 @@ Legend:
| S10F1 / S10F2 | H→E | ✅ | catalog | ✅ round-trip + demo | | S10F1 / S10F2 | H→E | ✅ | catalog | ✅ round-trip + demo |
| S10F3 / S10F4 | E→H | ✅ | catalog | ✅ round-trip + demo | | S10F3 / S10F4 | E→H | ✅ | catalog | ✅ round-trip + demo |
| S10F5 / S10F6 | H→E | ✅ | catalog | ✅ round-trip | | S10F5 / S10F6 | H→E | ✅ | catalog | ✅ round-trip |
| S14F9 / S14F10 | H→E | ✅ | catalog | ✅ round-trip + demo (E94 CJ create) |
| S14F11 / S14F12 | H→E | ✅ | catalog | ✅ round-trip + demo (E94 CJ delete) |
| S16F5 / S16F6 | H→E | ✅ | catalog | ✅ round-trip (E40 PRJobCommand) |
| S16F9 | E→H | ✅ | catalog + server | ✅ round-trip + auto-emitted per PJ transition (E40 PRJobAlert) |
| S16F11 / S16F12 | H→E | ✅ | catalog | ✅ round-trip + demo (E40 PRJobCreate) |
| S16F13 / S16F14 | H→E | ✅ | catalog | ✅ round-trip (E40 PRJobDequeue) |
| S16F27 / S16F28 | H→E | ✅ | catalog | ✅ round-trip + demo (E94 CJobCommand) |
--- ---
@@ -169,11 +225,26 @@ walks ~20 SECS transactions end-to-end:
16. Spool window: `SPOOL_ON``START` (emission goes to spool) → 16. Spool window: `SPOOL_ON``START` (emission goes to spool) →
`SPOOL_OFF``S6F23(Transmit)` → server drains queued S6F11 to host. `SPOOL_OFF``S6F23(Transmit)` → server drains queued S6F11 to host.
17. `S7F19`/`S7F20` recipe list, `S7F5`/`S7F6` fetch RECIPE-A. 17. `S7F19`/`S7F20` recipe list, `S7F5`/`S7F6` fetch RECIPE-A.
18. `S10F1`/`S10F2` host → equipment terminal display. 18. `S16F11`/`S16F12` create Process Job `PJ-1` with PPID `RECIPE-A`.
19. `S1F15`/`S1F16` Request Offline. 19. `S14F9`/`S14F10` create Control Job `CJ-1` containing `[PJ-1]`.
20. `Separate.req` → clean close on both sides. 20. `S16F27`/`S16F28` CJSTART → equipment cascades CJ Queued → Executing
and the contained PJ through SettingUp → WaitingForStart →
Processing → ProcessComplete, emitting one `S16F9 PRJobAlert` per PJ
transition and `S6F11(CEID=400)` / `S6F11(CEID=401)` for CJ Executing
/ Completed.
21. `S14F11`/`S14F12` delete `CJ-1`.
22. `S10F1`/`S10F2` host → equipment terminal display.
23. `S1F15`/`S1F16` Request Offline.
24. `Separate.req` → clean close on both sides.
Unit tests: **84+ cases / 480+ assertions pass** (`docker compose run --rm tests`). Unit tests: **148 cases / 794 assertions pass** (`docker compose run --rm tests`).
The suite includes integration tests that drive a real `hsms::Connection`
over a loopback socket pair to verify the E37 §7.2 / §7.4 / §7.7
edge cases (Select.req while already SELECTED → AlreadyActive, Deselect.req
while NOT_SELECTED → NotEstablished, Reject.req for unsupported SType /
PType, Reject.req for data while NOT_SELECTED) — not just the happy path.
The E30 §6.5 Communication state machine is unit-tested independently of
the transport (timer firings simulated via test callbacks).
--- ---
@@ -184,14 +255,13 @@ compliance claim.
| Item | Why it's out of scope | | Item | Why it's out of scope |
|---------------------------------------|----------------------| |---------------------------------------|----------------------|
| Material Movement (E30 §6.18) | This is **E40 / E87 / E90** (carrier management, substrate tracking, process jobs) — separate SEMI standards layered *on top of* E30. E30 GEM compliance does not require them. Adding E40 is a Layer-5 deliverable per `implementation_plan.md`. | | Material Movement (E30 §6.18) | The job-management half is now in (E40 + E94, §4a). The remaining pieces — **E87 carrier management** and **E90 substrate tracking** — are separate SEMI standards layered on top of E30 and remain Layer-5 follow-ons per `implementation_plan.md`. |
| Multi-block SECS-I transfers | Multi-block (S6F5/F6, S6F7/F8 etc.) is a **SECS-I** concept for 244-byte serial frames. HSMS allows arbitrarily large bodies (up to the codebase's 16 MiB cap), so multi-block is structurally not needed. E37-based GEM equipment does not require it. | | Multi-block SECS-I transfers | Multi-block (S6F5/F6, S6F7/F8 etc.) is a **SECS-I** concept for 244-byte serial frames. HSMS allows arbitrarily large bodies (up to the codebase's 16 MiB cap), so multi-block is structurally not needed. E37-based GEM equipment does not require it. |
| HSMS-GS (multi-session) | Out of scope — modern HSMS-SS covers virtually all current GEM equipment. | | HSMS-GS (multi-session) | Out of scope — modern HSMS-SS covers virtually all current GEM equipment. |
| Equipment Processing States (concrete states) | E30 §6.3 says the specific states are tool-defined. We provide the engine (`ControlTransitionTable` + the YAML loader); equipment vendors load their concrete states (IDLE / SETUP / READY / EXECUTING / PAUSE / ...) the same way `data/control_state.yaml` is loaded today. Spec-compliant either way. | | Equipment Processing States (concrete states) | E30 §6.3 says the specific states are tool-defined. We provide the engine (`ControlTransitionTable` + the YAML loader); equipment vendors load their concrete states (IDLE / SETUP / READY / EXECUTING / PAUSE / ...) the same way `data/control_state.yaml` is loaded today. Spec-compliant either way. |
| Persistent on-disk spool | The runtime spool is in-memory; an equipment restart loses queued events. Real fab equipment would back it with a journal. Standard does not mandate persistence. | | Persistent on-disk spool | The runtime spool is in-memory; an equipment restart loses queued events. Real fab equipment would back it with a journal. Standard does not mandate persistence. |
| E42 Enhanced Process Programs (S7F23F26) | A separate SEMI standard. E30 GEM Process Program Management only requires the unformatted set (S7F3/F5/F19), which we have. | | E42 Enhanced Process Programs (S7F23F26) | A separate SEMI standard. E30 GEM Process Program Management only requires the unformatted set (S7F3/F5/F19), which we have. |
| S10F7 Broadcast Terminal Display | Rarely used; equipment vendors typically forgo it. Not required for the Terminal Services capability. | | S10F7 Broadcast Terminal Display | Rarely used; equipment vendors typically forgo it. Not required for the Terminal Services capability. |
| JIS-8 / C2 (Unicode) SECS-II formats | Not used in modern Western fab tooling. Codec is structured so additional formats are trivial to add. |
--- ---
@@ -219,8 +289,8 @@ marketing claim would still need:
the dispatcher; the application is what makes a specific tool the dispatcher; the application is what makes a specific tool
GEM-compliant. GEM-compliant.
In short: this is a **GEM-conformant runtime stack**, not a In short: this is a **GEM-conformant runtime stack** with the first
GEM-conformant *tool*. Pointing the runtime at a real piece of slice of GEM300 (E40 / E94), not a GEM-conformant *tool*. Pointing the
equipment, populating the YAML files with the tool's real SVIDs / ECIDs runtime at a real piece of equipment, populating the YAML files with
/ alarms / capabilities, and wiring the application callbacks completes the tool's real SVIDs / ECIDs / alarms / capabilities / PJ + CJ
the picture. behaviour, and wiring the application callbacks completes the picture.
+44 -11
View File
@@ -2,11 +2,14 @@
A C++20 SECS-II / HSMS / GEM client and server, fully containerised, with A C++20 SECS-II / HSMS / GEM client and server, fully containerised, with
every behavioural rule encoded as YAML data (control state, equipment every behavioural rule encoded as YAML data (control state, equipment
data dictionary, SECS-II message shapes). data dictionary, SECS-II message shapes, **E40 process-job + E94
control-job state machines**).
See [COMPLIANCE.md](COMPLIANCE.md) for the per-capability E5/E30/E37 audit. See [COMPLIANCE.md](COMPLIANCE.md) for the per-capability E5/E30/E37/E40/E94
Every GEM Fundamental and every GEM Additional capability that E30 binds audit. Every GEM Fundamental and every GEM Additional capability that E30
to a concrete SECS-II message set is implemented and round-trip-tested. binds to a concrete SECS-II message set is implemented and
round-trip-tested; E40 PJ and E94 CJ lifecycle messages (S16F5/F6/F9/F11/F12/F13/F14/F27/F28
and S14F9/F10/F11/F12) are wired through the same data-driven runtime.
## Quick start ## Quick start
@@ -14,7 +17,7 @@ Everything runs in Docker — no compiler or build tools on the host.
```bash ```bash
docker compose run --rm builder # configure + compile docker compose run --rm builder # configure + compile
docker compose run --rm tests # 84+ test cases / 480+ assertions docker compose run --rm tests # 148 test cases / 794 assertions
docker compose up --no-deps server client # live two-container demo docker compose up --no-deps server client # live two-container demo
``` ```
@@ -26,8 +29,10 @@ the C++ is the engine that reads them.
``` ```
┌──────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────┐
│ data/ │ │ data/ │
│ messages.yaml SECS-II message catalog (44 SxFy) │ messages.yaml SECS-II message catalog
│ control_state.yaml E30 §6.2 transition table │ control_state.yaml E30 §6.2 control transition table │
│ process_job_state.yaml E40 §6 PJ transition table │
│ control_job_state.yaml E94 §6 CJ transition table │
│ equipment.yaml SVIDs / DVIDs / ECIDs / CEIDs / │ │ equipment.yaml SVIDs / DVIDs / ECIDs / CEIDs / │
│ alarms / recipes / commands / │ │ alarms / recipes / commands / │
│ capabilities / spool / DVID list │ │ capabilities / spool / DVID list │
@@ -51,10 +56,12 @@ the C++ is the engine that reads them.
┌──────────────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────────┐
│ secsgem::config loader.hpp: YAML -> tables + data model │ │ secsgem::config loader.hpp: YAML -> tables + data model │
│ secsgem::gem ControlTransitionTable + ControlStateMachine,│ │ secsgem::gem ControlTransitionTable + ControlStateMachine,│
EquipmentDataModel composing nine stores: ProcessJobStateMachine (E40),
│ ControlJobStateMachine (E94), │
│ EquipmentDataModel composing the stores: │
│ SVID, DVID, ECID, Event Subscriptions, │ │ SVID, DVID, ECID, Event Subscriptions, │
│ Alarms, Recipes, Clock, Commands, Spool, │ │ Alarms, Recipes, Clock, Commands, Spool, │
│ Limits, Traces │ Limits, Traces, ProcessJobs, ControlJobs
│ Router (stream, function) -> handler │ │ Router (stream, function) -> handler │
│ generated messages.hpp (all 44 SxFy) │ │ generated messages.hpp (all 44 SxFy) │
│ secsgem::hsms Connection (Asio), Header, Frame, Timers │ │ secsgem::hsms Connection (Asio), Header, Frame, Timers │
@@ -81,7 +88,8 @@ secs-gem/
├── include/secsgem/ ├── include/secsgem/
│ ├── secs2/{item,codec,message}.hpp │ ├── secs2/{item,codec,message}.hpp
│ ├── hsms/{header,connection}.hpp │ ├── hsms/{header,connection}.hpp
│ ├── gem/{control_state,data_model,messages_helpers,router}.hpp │ ├── gem/{control_state,communication_state,data_model,messages_helpers,router}.hpp
│ ├── gem/{process_job_state,control_job_state}.hpp # E40 / E94 FSMs
│ ├── gem/store/ # one file per focused store: │ ├── gem/store/ # one file per focused store:
│ │ ├── status_variables.hpp # SVIDs + DVIDs │ │ ├── status_variables.hpp # SVIDs + DVIDs
│ │ ├── equipment_constants.hpp # ECIDs + EAC range validation │ │ ├── equipment_constants.hpp # ECIDs + EAC range validation
@@ -92,7 +100,9 @@ secs-gem/
│ │ ├── host_commands.hpp # RCMD registry │ │ ├── host_commands.hpp # RCMD registry
│ │ ├── spool.hpp # spool queue + state │ │ ├── spool.hpp # spool queue + state
│ │ ├── limits.hpp # variable limit definitions │ │ ├── limits.hpp # variable limit definitions
│ │ ── trace.hpp # active trace configs │ │ ── trace.hpp # active trace configs
│ │ ├── process_jobs.hpp # E40 PJ collection
│ │ └── control_jobs.hpp # E94 CJ collection
│ ├── config/loader.hpp │ ├── config/loader.hpp
│ └── endpoint.hpp │ └── endpoint.hpp
├── src/{secs2,hsms,gem,config}/*.cpp + endpoint.cpp ├── src/{secs2,hsms,gem,config}/*.cpp + endpoint.cpp
@@ -131,6 +141,19 @@ transitions:
- {from: OnlineRemote, on: host_request_offline, to: EquipmentOffline, ack: Accept} - {from: OnlineRemote, on: host_request_offline, to: EquipmentOffline, ack: Accept}
``` ```
### Add an E40 PJ transition (e.g. a tool-specific HOLD state)
```yaml
# data/process_job_state.yaml
transitions:
- {from: Processing, on: hold, to: OnHold}
- {from: OnHold, on: resume, to: Processing}
```
(Adding a brand-new state requires bumping the `ProcessJobState` enum
in `include/secsgem/gem/process_job_state.hpp` too — it's the wire enum
that S16F9 carries.)
### Add a new SECS-II message ### Add a new SECS-II message
```yaml ```yaml
@@ -184,6 +207,16 @@ The two-container demo walks ~20 SECS transactions:
[host] EVENT CEID=300 (from spool, post-fact) [host] EVENT CEID=300 (from spool, post-fact)
[host] -> S7F19 W [equip] -> S7F20 (2 PPIDs) [host] -> S7F19 W [equip] -> S7F20 (2 PPIDs)
[host] -> S7F5 W RECIPE-A [equip] -> S7F6 [host] -> S7F5 W RECIPE-A [equip] -> S7F6
[host] -> S16F11 W PJ-1 RECIPE-A [equip] -> S16F12 HCACK=0
[host] -> S14F9 W CJ-1 [PJ-1] [equip] -> S14F10 OBJACK=0
[host] -> S16F27 W CJ-1 CJSTART [equip] CJ Queued -> Executing
[host] <- S6F11 CEID=400 PJ Queued -> SettingUp
[host] <- S16F9 PJ-1 state=SettingUp PJ -> WaitingForStart
[host] <- S16F9 PJ-1 state=WaitingForStart PJ -> Processing
[host] <- S16F9 PJ-1 state=Processing PJ -> ProcessComplete
[host] <- S16F9 PJ-1 state=ProcessComplete CJ -> Completed
[host] <- S6F11 CEID=401
[host] -> S14F11 W CJ-1 [equip] -> S14F12 OBJACK=0
[host] -> S10F1 W [equip] TERMINAL[0] Hello equipment! [host] -> S10F1 W [equip] TERMINAL[0] Hello equipment!
[host] -> S1F15 W [equip] OnlineRemote -> HostOffline [host] -> S1F15 W [equip] OnlineRemote -> HostOffline
[host] -> Separate.req [equip] <- Separate.req [host] -> Separate.req [equip] <- Separate.req
+74 -2
View File
@@ -40,6 +40,8 @@ constexpr uint32_t kDataIdReports = 7;
constexpr uint32_t kRptidStatus = 1000; constexpr uint32_t kRptidStatus = 1000;
constexpr uint32_t kCeidProcessStarted = 300; constexpr uint32_t kCeidProcessStarted = 300;
constexpr uint32_t kCeidAlarmSetEvent = 200; constexpr uint32_t kCeidAlarmSetEvent = 200;
constexpr uint32_t kCeidCJExecuting = 400;
constexpr uint32_t kCeidCJCompleted = 401;
constexpr uint32_t kAlarmChiller = 1; constexpr uint32_t kAlarmChiller = 1;
struct Sequence : std::enable_shared_from_this<Sequence> { struct Sequence : std::enable_shared_from_this<Sequence> {
@@ -102,6 +104,16 @@ int main(int argc, char** argv) {
logfn("SPOOL READY: " + std::to_string(n.value_or(0)) + " queued messages"); logfn("SPOOL READY: " + std::to_string(n.value_or(0)) + " queued messages");
return gem::s6f26_spool_data_ready_ack(gem::EventReportAck::Accept); return gem::s6f26_spool_data_ready_ack(gem::EventReportAck::Accept);
} }
// S16F9: E40 PRJob alert (one-way, E->H).
if (msg.stream == 16 && msg.function == 9) {
auto a = gem::parse_s16f9(msg);
if (a) {
logfn("PJ ALERT " + a->prjobid + " state=" +
gem::process_job_state_name(a->prjobstate));
}
if (msg.reply_expected) return s2::Message(16, 0, false);
return std::nullopt;
}
// S5F1: alarm send from equipment. // S5F1: alarm send from equipment.
if (msg.stream == 5 && msg.function == 1) { if (msg.stream == 5 && msg.function == 1) {
auto a = gem::parse_s5f1(msg); auto a = gem::parse_s5f1(msg);
@@ -241,7 +253,9 @@ int main(int argc, char** argv) {
conn->send_request( conn->send_request(
gem::s2f35_link_event_report(kDataIdReports, gem::s2f35_link_event_report(kDataIdReports,
{{kCeidProcessStarted, {kRptidStatus}}, {{kCeidProcessStarted, {kRptidStatus}},
{kCeidAlarmSetEvent, {kRptidStatus}}}), {kCeidAlarmSetEvent, {kRptidStatus}},
{kCeidCJExecuting, {kRptidStatus}},
{kCeidCJCompleted, {kRptidStatus}}}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) { [logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S2F35", ec); return; } if (ec) { fail("S2F35", ec); return; }
auto a = gem::ack_byte(reply); auto a = gem::ack_byte(reply);
@@ -253,7 +267,8 @@ int main(int argc, char** argv) {
// 8. Enable the linked CEIDs. // 8. Enable the linked CEIDs.
seq->steps.push_back([conn, logfn, fail](auto next) { seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request( conn->send_request(
gem::s2f37_enable_event(true, {kCeidProcessStarted, kCeidAlarmSetEvent}), gem::s2f37_enable_event(true, {kCeidProcessStarted, kCeidAlarmSetEvent,
kCeidCJExecuting, kCeidCJCompleted}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) { [logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S2F37", ec); return; } if (ec) { fail("S2F37", ec); return; }
auto a = gem::ack_byte(reply); auto a = gem::ack_byte(reply);
@@ -391,6 +406,63 @@ int main(int argc, char** argv) {
}); });
}); });
// ---- E40/E94: create a PJ, wrap it in a CJ, start the CJ ----------
// 14a. S16F11 PRJobCreate PJ-1 with recipe RECIPE-A and 2 wafers.
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(
gem::s16f11_pr_job_create("PJ-1", "RECIPE-A", {"WFR-1", "WFR-2"}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S16F11", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S16F12 HCACK=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 14b. S14F9 CreateControlJob CJ-1 containing [PJ-1].
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(
gem::s14f9_create_control_job("CJ-1", {"PJ-1"}),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S14F9", ec); return; }
auto r = gem::parse_s14f10(reply);
if (r)
logfn("S14F10 CJ=" + r->ctljobid + " OBJACK=" +
std::to_string(static_cast<int>(r->ack)));
next();
});
});
// 14c. S16F27 CJSTART CJ-1 -> equipment cascades the PJ through every
// state, host sees a burst of S16F9 alerts + S6F11(CEID=400/401).
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(
gem::s16f27_cj_command("CJ-1", "CJSTART"),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S16F27", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S16F28 HCACK=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 14d. Pace 200ms so the asynchronous S16F9 / S6F11 alerts arrive
// before we move on to terminal display.
seq->steps.push_back([pause_then](auto next) {
pause_then(std::chrono::milliseconds(200), [next] { next(); });
});
// 14e. Tidy up: delete the CJ via S14F11. The contained PJs are now
// ProcessComplete; the equipment leaves them in the store until
// explicitly dequeued (which can be wired similarly via S16F13).
seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(
gem::s14f11_delete_control_job("CJ-1"),
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
if (ec) { fail("S14F11", ec); return; }
auto a = gem::ack_byte(reply);
logfn("S14F12 OBJACK=" + (a ? std::to_string(*a) : "?"));
next();
});
});
// 15. Send a terminal display to the equipment. // 15. Send a terminal display to the equipment.
seq->steps.push_back([conn, logfn, fail](auto next) { seq->steps.push_back([conn, logfn, fail](auto next) {
conn->send_request(gem::s10f1_terminal_display_single(0, "Hello equipment!"), conn->send_request(gem::s10f1_terminal_display_single(0, "Hello equipment!"),
+195
View File
@@ -91,7 +91,14 @@ int main(int argc, char** argv) {
logfn("spool: " + what + " dropped (stream not spoolable, no host)"); logfn("spool: " + what + " dropped (stream not spoolable, no host)");
return false; return false;
} }
// W=1 primaries use send_request (transaction tracking); W=0 primaries
// (e.g. S16F9 PRJobAlert) go via send_data so we don't register a
// never-arriving "reply" and time out on T3.
if (msg.reply_expected) {
conn->send_request(std::move(msg), [](std::error_code, const s2::Message&) {}); conn->send_request(std::move(msg), [](std::error_code, const s2::Message&) {});
} else {
conn->send_data(std::move(msg));
}
return true; return true;
}; };
@@ -133,6 +140,98 @@ int main(int argc, char** argv) {
if (desc.emit_on_control_change) emit_event(*desc.emit_on_control_change); if (desc.emit_on_control_change) emit_event(*desc.emit_on_control_change);
}); });
// ---- E40/E94: load the PJ/CJ transition tables and wire emitters -----
const auto pj_state_yaml = arg(argc, argv, "--pj-state-table",
"/app/data/process_job_state.yaml");
const auto cj_state_yaml = arg(argc, argv, "--cj-state-table",
"/app/data/control_job_state.yaml");
config::ProcessJobStateConfig pj_cfg;
config::ControlJobStateConfig cj_cfg;
try {
pj_cfg = config::load_process_job_state(pj_state_yaml);
cj_cfg = config::load_control_job_state(cj_state_yaml);
} catch (const std::exception& e) {
std::cerr << "[equip] E40/E94 config error: " << e.what() << std::endl;
return 1;
}
// Each new PJ/CJ gets a fresh copy of the loaded transition table.
model->process_jobs.set_table_factory([t = pj_cfg.table]() { return t; });
model->control_jobs.set_table_factory([t = cj_cfg.table]() { return t; });
// Emit S16F9 PRJobAlert (E40-0705 §10.3). Equipment-initiated; the spec
// is silent on reply expectation, so we send it as a primary one-way.
auto emit_pj_alert = [&io, model, logfn, deliver_or_spool](
const std::string& prjobid,
gem::ProcessJobState state) {
asio::post(io, [model, logfn, deliver_or_spool, prjobid, state]() {
const auto* pj = model->process_jobs.get(prjobid);
if (pj && !pj->alert_enabled) return;
logfn("emit S16F9 PJ=" + prjobid + " state=" +
gem::process_job_state_name(state));
deliver_or_spool(gem::s16f9_pr_job_alert(prjobid, state),
"S16F9 PJ=" + prjobid);
});
};
// PJ state-change handler: log + emit S16F9 (skip the synthetic
// NoState->Queued so we don't alert on a freshly-created PJ that's
// still being acked).
model->process_jobs.set_state_change_handler(
[logfn, emit_pj_alert](const std::string& prjobid,
gem::ProcessJobState from,
gem::ProcessJobState to,
gem::ProcessJobEvent trig) {
logfn(std::string("PJ ") + prjobid + ": " +
gem::process_job_state_name(from) + " -> " +
gem::process_job_state_name(to) + " (" +
gem::process_job_event_name(trig) + ")");
if (from != gem::ProcessJobState::NoState) emit_pj_alert(prjobid, to);
});
// CEIDs the equipment.yaml is expected to register for CJ state
// changes (best-effort: if missing they're silently no-ops via the
// existing CEID-not-enabled guard in emit_event).
constexpr uint32_t kCeidCJExecuting = 400;
constexpr uint32_t kCeidCJCompleted = 401;
model->control_jobs.set_state_change_handler(
[logfn, emit_event](const std::string& ctljobid,
gem::ControlJobState from,
gem::ControlJobState to,
gem::ControlJobEvent trig) {
logfn(std::string("CJ ") + ctljobid + ": " +
gem::control_job_state_name(from) + " -> " +
gem::control_job_state_name(to) + " (" +
gem::control_job_event_name(trig) + ")");
if (to == gem::ControlJobState::Executing) emit_event(kCeidCJExecuting);
if (to == gem::ControlJobState::Completed) emit_event(kCeidCJCompleted);
});
// Drive the contained PJs through the demo lifecycle when the host
// tells the CJ to start. Real equipment would step PJs one at a time
// as material arrives; our simulator cascades through every legal
// state so the wire trace exercises the whole FSM.
auto run_cj_lifecycle = [&io, model, logfn](const std::string& ctljobid) {
asio::post(io, [model, logfn, ctljobid]() {
auto* cj = model->control_jobs.get(ctljobid);
if (!cj) return;
// CJ promotion path: Queued -> Selected -> WaitingForStart -> Executing.
model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::Select);
model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::SetupComplete);
model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::Start);
// Cascade every contained PJ through to ProcessComplete.
for (const auto& pjid : cj->prjobids) {
model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::Select);
model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::SetupComplete);
model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::Start);
model->process_jobs.fire_internal(pjid, gem::ProcessJobEvent::ProcessComplete);
}
// All PJs done -> CJ Completed.
model->control_jobs.fire_internal(ctljobid, gem::ControlJobEvent::AllJobsComplete);
logfn("CJ " + ctljobid + " lifecycle complete");
});
};
// ---- Build the SECS dispatch table once ------------------------------- // ---- Build the SECS dispatch table once -------------------------------
gem::Router router; gem::Router router;
@@ -367,7 +466,11 @@ int main(int argc, char** argv) {
auto conn = active_conn->lock(); auto conn = active_conn->lock();
if (!conn) return; if (!conn) return;
for (auto& m : drained) { for (auto& m : drained) {
const bool w = m.reply_expected;
if (w)
conn->send_request(std::move(m), [](std::error_code, const s2::Message&) {}); conn->send_request(std::move(m), [](std::error_code, const s2::Message&) {});
else
conn->send_data(std::move(m));
} }
}); });
return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept); return gem::s6f24_request_spool_data_ack(gem::SpoolRequestAck::Accept);
@@ -426,6 +529,98 @@ int main(int argc, char** argv) {
return gem::s7f20_current_eppd_data(list); return gem::s7f20_current_eppd_data(list);
}); });
// ---- E40 / E94 -------------------------------------------------------
router.on(14, 9, [model, logfn, run_cj_lifecycle](const s2::Message& msg) {
(void)run_cj_lifecycle;
auto req = gem::parse_s14f9(msg);
if (!req) {
logfn("S14F9 -> S14F10 Error (malformed body)");
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); });
gem::ObjectAck ack = gem::ObjectAck::Success;
switch (r) {
case gem::ControlJobStore::CreateResult::Created: ack = gem::ObjectAck::Success; break;
case gem::ControlJobStore::CreateResult::Denied_AlreadyExists:
ack = gem::ObjectAck::Denied_AlreadyExists; break;
case gem::ControlJobStore::CreateResult::Denied_UnknownPRJob:
ack = gem::ObjectAck::Denied_UnknownObject; break;
case gem::ControlJobStore::CreateResult::Denied_Empty:
ack = gem::ObjectAck::Denied_InvalidAttribute; break;
}
logfn("S14F9 CJ=" + req->ctljobid + " -> S14F10 OBJACK=" +
std::to_string(static_cast<int>(ack)));
return gem::s14f10_create_control_job_ack(req->ctljobid, ack);
});
router.on(14, 11, [model, logfn](const s2::Message& msg) {
auto id = gem::parse_s14f11(msg);
if (!id) return gem::s14f12_delete_control_job_ack(gem::ObjectAck::Error);
const auto removed = model->control_jobs.remove(*id);
logfn("S14F11 delete CJ=" + *id + " -> S14F12 " +
(removed ? "Success" : "UnknownObject"));
return gem::s14f12_delete_control_job_ack(
removed ? gem::ObjectAck::Success : gem::ObjectAck::Denied_UnknownObject);
});
router.on(16, 11, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s16f11(msg);
if (!req) return gem::s16f12_pr_job_create_ack(gem::HostCmdAck::ParameterInvalid);
auto r = model->process_jobs.create(
req->prjobid, req->ppid, req->mtrloutspec,
[model](const std::string& ppid) {
return model->recipes.get(ppid).has_value();
});
gem::HostCmdAck ack = gem::HostCmdAck::Accept;
switch (r) {
case gem::ProcessJobStore::CreateResult::Created: ack = gem::HostCmdAck::Accept; break;
case gem::ProcessJobStore::CreateResult::Denied_AlreadyExists:
ack = gem::HostCmdAck::Rejected; break;
case gem::ProcessJobStore::CreateResult::Denied_InvalidPpid:
ack = gem::HostCmdAck::ParameterInvalid; break;
}
logfn("S16F11 PJ=" + req->prjobid + " PPID=" + req->ppid +
" -> S16F12 HCACK=" + std::to_string(static_cast<int>(ack)));
return gem::s16f12_pr_job_create_ack(ack);
});
router.on(16, 13, [model, logfn](const s2::Message& msg) {
auto id = gem::parse_s16f13(msg);
auto ack = id ? model->process_jobs.dequeue(*id) : gem::HostCmdAck::ParameterInvalid;
logfn("S16F13 PJ=" + (id ? *id : std::string{"?"}) +
" -> S16F14 HCACK=" + std::to_string(static_cast<int>(ack)));
return gem::s16f14_pr_job_dequeue_ack(ack);
});
router.on(16, 5, [model, logfn](const s2::Message& msg) {
auto req = gem::parse_s16f5(msg);
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);
auto ack = model->process_jobs.on_host_command(req->prjobid, *ev);
logfn("S16F5 PJ=" + req->prjobid + " " + req->prcmd +
" -> S16F6 HCACK=" + std::to_string(static_cast<int>(ack)));
return gem::s16f6_pr_job_command_ack(ack);
});
router.on(16, 27, [model, logfn, run_cj_lifecycle](const s2::Message& msg) {
auto req = gem::parse_s16f27(msg);
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);
// CJSTART semantics: implicit Select -> SetupComplete -> Start
// cascade so a Queued CJ can be started in one host action. The
// cascade is the equipment policy; the FSM rules still gate every
// step.
auto ack = gem::HostCmdAck::Accept;
if (*ev == gem::ControlJobEvent::Start) {
run_cj_lifecycle(req->ctljobid);
} else {
ack = model->control_jobs.on_host_command(req->ctljobid, *ev);
}
logfn("S16F27 CJ=" + req->ctljobid + " " + req->ctljobcmd +
" -> S16F28 HCACK=" + std::to_string(static_cast<int>(ack)));
return gem::s16f28_cj_command_ack(ack);
});
router.on(10, 1, [logfn](const s2::Message& msg) { router.on(10, 1, [logfn](const s2::Message& msg) {
auto td = gem::parse_s10f1(msg); auto td = gem::parse_s10f1(msg);
if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text); if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text);
+48
View File
@@ -0,0 +1,48 @@
# E94 §6 Control Job state model — encoded as a transition table.
#
# CJ governs the batch — it owns an ordered list of PRJOBIDs and
# proceeds Queued -> Selected -> WaitingForStart -> Executing ->
# Completed; from any of those a Stop or Abort diverts to Stopping or
# Aborting (then Completed).
#
# State values are this project's encoding (see control_job_state.hpp).
# Host-initiated events (start / pause / resume / stop / abort) arrive
# via S16F27; internal events (select / setup_complete /
# all_jobs_complete / abort_complete) fire from the equipment's
# application logic as the contained PJs progress.
initial: Queued
transitions:
# --- QUEUED ----------------------------------------------------------
- {from: Queued, on: select, to: Selected}
- {from: Queued, on: stop, to: Completed}
- {from: Queued, on: abort, to: Completed}
# --- SELECTED --------------------------------------------------------
- {from: Selected, on: setup_complete, to: WaitingForStart}
- {from: Selected, on: stop, to: Stopping}
- {from: Selected, on: abort, to: Aborting}
# --- WAITING-FOR-START -----------------------------------------------
- {from: WaitingForStart, on: start, to: Executing}
- {from: WaitingForStart, on: stop, to: Stopping}
- {from: WaitingForStart, on: abort, to: Aborting}
# --- EXECUTING -------------------------------------------------------
- {from: Executing, on: pause, to: Paused}
- {from: Executing, on: stop, to: Stopping}
- {from: Executing, on: abort, to: Aborting}
- {from: Executing, on: all_jobs_complete, to: Completed}
# --- PAUSED ----------------------------------------------------------
- {from: Paused, on: resume, to: Executing}
- {from: Paused, on: stop, to: Stopping}
- {from: Paused, on: abort, to: Aborting}
# --- STOPPING --------------------------------------------------------
- {from: Stopping, on: all_jobs_complete, to: Completed}
- {from: Stopping, on: abort, to: Aborting}
# --- ABORTING --------------------------------------------------------
- {from: Aborting, on: abort_complete, to: Completed}
+5
View File
@@ -26,6 +26,9 @@ capabilities:
- {code: 12, name: "Clock"} - {code: 12, name: "Clock"}
- {code: 14, name: "Spooling (partial; S2F43/F44 + S6F23/F24)"} - {code: 14, name: "Spooling (partial; S2F43/F44 + S6F23/F24)"}
- {code: 15, name: "Control (operator initiated)"} - {code: 15, name: "Control (operator initiated)"}
# GEM300 extensions (declared so the host can probe with S1F19).
- {code: 40, name: "E40 Process Job Management"}
- {code: 94, name: "E94 Control Job Management"}
# Reported on S1F3 (values) and S1F11 (namelist). `value` is the initial. # Reported on S1F3 (values) and S1F11 (namelist). `value` is the initial.
# `type` controls the SECS-II Item format. # `type` controls the SECS-II Item format.
@@ -50,6 +53,8 @@ ceids:
- {id: 100, name: ControlStateChanged} - {id: 100, name: ControlStateChanged}
- {id: 200, name: AlarmSetEvent} - {id: 200, name: AlarmSetEvent}
- {id: 300, name: ProcessStarted} - {id: 300, name: ProcessStarted}
- {id: 400, name: ControlJobExecuting} # E94 CJ entered Executing
- {id: 401, name: ControlJobCompleted} # E94 CJ entered Completed
# Reported on S5F5 / S5F1. `category` is the lower-7 of ALCD. # Reported on S5F5 / S5F1. `category` is the lower-7 of ALCD.
alarms: alarms:
+230
View File
@@ -251,6 +251,24 @@ messages:
parser: parse_s2f18 parser: parse_s2f18
body: {kind: scalar, item_type: ASCII, param: time_str} body: {kind: scalar, item_type: ASCII, param: time_str}
# S2F25 / S2F26 — Loopback Diagnostic. Host sends an arbitrary byte
# blob; equipment echoes it back unchanged in S2F26. Useful for round-
# trip diagnostics and as a "keep-alive at the application layer."
- id: S2F25
stream: 2
function: 25
w: true
builder: s2f25_loopback_diagnostic_request
parser: parse_s2f25
body: {kind: scalar, item_type: BINARY, param: payload}
- id: S2F26
stream: 2
function: 26
builder: s2f26_loopback_diagnostic_data
parser: parse_s2f26
body: {kind: scalar, item_type: BINARY, param: payload}
- id: S2F29 - id: S2F29
stream: 2 stream: 2
function: 29 function: 29
@@ -660,6 +678,53 @@ messages:
- {name: alid, shape: {kind: scalar, item_type: U4}} - {name: alid, shape: {kind: scalar, item_type: U4}}
- {name: altx, shape: {kind: scalar, item_type: ASCII}} - {name: altx, shape: {kind: scalar, item_type: ASCII}}
# S5F9 / S5F10 — Exception Post Notify / Confirm (E5).
# Equipment posts a recoverable exception; the body lists the allowed
# recovery actions that S5F13 may then send. Host acks via S5F10.
- id: S5F9
stream: 5
function: 9
w: true
builder: s5f9_exception_post_notify
parser: parse_s5f9
body:
kind: list
struct_name: ExceptionPost
fields:
- {name: exid, shape: {kind: scalar, item_type: U4}}
- {name: extype, shape: {kind: scalar, item_type: ASCII}}
- {name: exmessage, shape: {kind: scalar, item_type: ASCII}}
- name: exrecvra
shape: {kind: list_of, element: {kind: scalar, item_type: ASCII}}
- id: S5F10
stream: 5
function: 10
builder: s5f10_exception_post_confirm
body: {kind: scalar, item_type: BINARY_BYTE, enum: AlarmAck, param: ack}
# S5F11 / S5F12 — Exception Clear Notify / Confirm. Equipment signals
# that a previously-posted exception has cleared.
- id: S5F11
stream: 5
function: 11
w: true
builder: s5f11_exception_clear_notify
parser: parse_s5f11
body:
kind: list
struct_name: ExceptionClear
fields:
- {name: exid, shape: {kind: scalar, item_type: U4}}
- {name: extype, shape: {kind: scalar, item_type: ASCII}}
- {name: exmessage, shape: {kind: scalar, item_type: ASCII}}
- id: S5F12
stream: 5
function: 12
builder: s5f12_exception_clear_confirm
body: {kind: scalar, item_type: BINARY_BYTE, enum: AlarmAck, param: ack}
# ===================================================================== # =====================================================================
# S6 — Data collection # S6 — Data collection
# ===================================================================== # =====================================================================
@@ -909,3 +974,168 @@ messages:
function: 6 function: 6
builder: s10f6_terminal_display_multi_ack builder: s10f6_terminal_display_multi_ack
body: {kind: scalar, item_type: BINARY_BYTE, enum: TerminalAck, param: ack} body: {kind: scalar, item_type: BINARY_BYTE, enum: TerminalAck, param: ack}
# =====================================================================
# S14 — Object services (E94 ControlJob create/delete).
#
# Real E14 ObjectService is a fully generic generic-attribute carrier;
# for the E94 demo we use a simplified shape that names the object
# type ("ControlJob") and carries the CTLJOBID + contained PRJOBIDs
# directly. The wire shape is a defensible subset of E14F9 with the
# generic ATTRIBUTES list specialized.
# =====================================================================
# S14F9 / F10 — CreateObject (CJ). Host asks the equipment to bring
# a new ControlJob into existence and registers the list of PJs it
# contains; the CJ starts in CJ-QUEUED.
- id: S14F9
stream: 14
function: 9
w: true
builder: s14f9_create_control_job
parser: parse_s14f9
body:
kind: list
struct_name: CreateControlJobRequest
fields:
- {name: ctljobid, shape: {kind: scalar, item_type: ASCII}}
- name: prjobids
shape: {kind: list_of, element: {kind: scalar, item_type: ASCII}}
- id: S14F10
stream: 14
function: 10
builder: s14f10_create_control_job_ack
parser: parse_s14f10
body:
kind: list
struct_name: CreateControlJobAck
fields:
- {name: ctljobid, shape: {kind: scalar, item_type: ASCII}}
- {name: ack, shape: {kind: scalar, item_type: BINARY_BYTE, enum: ObjectAck}}
# S14F11 / F12 — DeleteObject (CJ).
- id: S14F11
stream: 14
function: 11
w: true
builder: s14f11_delete_control_job
parser: parse_s14f11
body: {kind: scalar, item_type: ASCII, param: ctljobid}
- id: S14F12
stream: 14
function: 12
builder: s14f12_delete_control_job_ack
body: {kind: scalar, item_type: BINARY_BYTE, enum: ObjectAck, param: ack}
# =====================================================================
# S16 — Process Management (E40 Process Jobs, E94 Control Jobs).
#
# Wire shapes are E40-0705 / E94-0705 with a couple of deliberate
# simplifications documented inline. The PJ/CJ state set and the legal
# transitions live in data/process_job_state.yaml and
# data/control_job_state.yaml respectively (data-driven, same pattern
# as data/control_state.yaml).
# =====================================================================
# S16F5 / F6 — PRJobCommand. Host issues Start / Pause / Resume /
# Stop / Abort / HOQ against an existing process job.
- id: S16F5
stream: 16
function: 5
w: true
builder: s16f5_pr_job_command
parser: parse_s16f5
body:
kind: list
struct_name: PRJobCommandRequest
fields:
- {name: prjobid, shape: {kind: scalar, item_type: ASCII}}
- {name: prcmd, shape: {kind: scalar, item_type: ASCII}}
- id: S16F6
stream: 16
function: 6
builder: s16f6_pr_job_command_ack
body: {kind: scalar, item_type: BINARY_BYTE, enum: HostCmdAck, param: ack}
# S16F9 — PRJobAlert. E->H one-way alert that a PJ entered a new
# state. PRJOBSTATE is the E40 PJ state code (0..7). Equipment
# emits one per PJ state transition when alerts are enabled.
- id: S16F9
stream: 16
function: 9
builder: s16f9_pr_job_alert
parser: parse_s16f9
body:
kind: list
struct_name: PRJobAlert
fields:
- {name: prjobid, shape: {kind: scalar, item_type: ASCII}}
- {name: prjobstate, shape: {kind: scalar, item_type: BINARY_BYTE, enum: ProcessJobState}}
# S16F11 / F12 — PRJobCreate.
#
# Real E40-0705 S16F11 body is <L,5 PRJOBID MF PRRECIPEMETHOD
# RCPSPEC L MTRLOUTSPEC L PRPROCESSPARAMS>. We simplify to the
# three pieces that actually drive the demo state machine:
# PRJOBID, recipe (PPID), and the list of material identifiers. MF
# / PRRECIPEMETHOD / PRPROCESSPARAMS are tool-specific; layering
# them in is a YAML edit + builder overload, not surgery.
- id: S16F11
stream: 16
function: 11
w: true
builder: s16f11_pr_job_create
parser: parse_s16f11
body:
kind: list
struct_name: PRJobCreateRequest
fields:
- {name: prjobid, shape: {kind: scalar, item_type: ASCII}}
- {name: ppid, shape: {kind: scalar, item_type: ASCII}}
- name: mtrloutspec
shape: {kind: list_of, element: {kind: scalar, item_type: ASCII}}
- id: S16F12
stream: 16
function: 12
builder: s16f12_pr_job_create_ack
body: {kind: scalar, item_type: BINARY_BYTE, enum: HostCmdAck, param: ack}
# S16F13 / F14 — PRJobDequeue. Host removes a queued PJ.
- id: S16F13
stream: 16
function: 13
w: true
builder: s16f13_pr_job_dequeue
parser: parse_s16f13
body: {kind: scalar, item_type: ASCII, param: prjobid}
- id: S16F14
stream: 16
function: 14
builder: s16f14_pr_job_dequeue_ack
body: {kind: scalar, item_type: BINARY_BYTE, enum: HostCmdAck, param: ack}
# S16F27 / F28 — CJobCommand (E94). Host issues Start / Pause /
# Resume / Stop / Abort against an existing CJ.
- id: S16F27
stream: 16
function: 27
w: true
builder: s16f27_cj_command
parser: parse_s16f27
body:
kind: list
struct_name: CJobCommandRequest
fields:
- {name: ctljobid, shape: {kind: scalar, item_type: ASCII}}
- {name: ctljobcmd, shape: {kind: scalar, item_type: ASCII}}
- id: S16F28
stream: 16
function: 28
builder: s16f28_cj_command_ack
body: {kind: scalar, item_type: BINARY_BYTE, enum: HostCmdAck, param: ack}
+57
View File
@@ -0,0 +1,57 @@
# E40 §6 Process Job state model — encoded as a transition table.
#
# Each row is a (from, on) -> (to [, ack]) tuple, mirroring the encoding
# in data/control_state.yaml. Events are the lower-snake-case verbs the
# loader maps to ProcessJobEvent: start / pause / resume / stop / abort /
# hoq / select / setup_complete / process_complete / abort_complete /
# created. Host-initiated events (start, pause, resume, stop, abort,
# hoq) carry an `ack` when the row should override the default
# HostCmdAck::Accept (e.g. a row that only validates an event without
# changing state).
#
# Wire reference: S16F9 PRJOBSTATE byte (E40-0705 §10.3.2):
# 0 Queued, 1 SettingUp, 2 WaitingForStart, 3 Processing,
# 4 ProcessComplete, 5 Paused, 6 Stopping, 7 Aborting.
#
# Process-Complete is terminal — deletion happens out-of-band via the
# ProcessJobStore (CJ cleanup or explicit S16F13 while still QUEUED).
initial: Queued
transitions:
# --- QUEUED ----------------------------------------------------------
# PJSTOP/PJABORT on a Queued PJ routes through Aborting so the host
# observes PRJOBSTATE=7 on the wire (E40-0705 §6.3). PJDELETE / S16F13
# is the dequeue-without-state-change path.
- {from: Queued, on: select, to: SettingUp}
- {from: Queued, on: hoq} # reorder only, no state change
- {from: Queued, on: stop, to: Aborting}
- {from: Queued, on: abort, to: Aborting}
# --- SETTING-UP -------------------------------------------------------
- {from: SettingUp, on: setup_complete, to: WaitingForStart}
- {from: SettingUp, on: stop, to: Stopping}
- {from: SettingUp, on: abort, to: Aborting}
# --- WAITING-FOR-START -----------------------------------------------
- {from: WaitingForStart, on: start, to: Processing}
- {from: WaitingForStart, on: stop, to: Stopping}
- {from: WaitingForStart, on: abort, to: Aborting}
# --- PROCESSING -------------------------------------------------------
- {from: Processing, on: pause, to: Paused}
- {from: Processing, on: stop, to: Stopping}
- {from: Processing, on: abort, to: Aborting}
- {from: Processing, on: process_complete, to: ProcessComplete}
# --- PAUSED ----------------------------------------------------------
- {from: Paused, on: resume, to: Processing}
- {from: Paused, on: stop, to: Stopping}
- {from: Paused, on: abort, to: Aborting}
# --- STOPPING --------------------------------------------------------
- {from: Stopping, on: process_complete, to: ProcessComplete}
- {from: Stopping, on: abort, to: Aborting}
# --- ABORTING --------------------------------------------------------
- {from: Aborting, on: abort_complete, to: ProcessComplete}
+18
View File
@@ -7,8 +7,10 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
#include "secsgem/gem/control_job_state.hpp"
#include "secsgem/gem/control_state.hpp" #include "secsgem/gem/control_state.hpp"
#include "secsgem/gem/data_model.hpp" #include "secsgem/gem/data_model.hpp"
#include "secsgem/gem/process_job_state.hpp"
// YAML-driven loaders for the E30 control-state transition table and the // YAML-driven loaders for the E30 control-state transition table and the
// equipment data dictionary. Behaviour rules live in the YAML; this is the // equipment data dictionary. Behaviour rules live in the YAML; this is the
@@ -42,4 +44,20 @@ struct EquipmentDescriptor {
EquipmentDescriptor load_equipment(const std::string& yaml_path, EquipmentDescriptor load_equipment(const std::string& yaml_path,
gem::EquipmentDataModel& model); gem::EquipmentDataModel& model);
struct ProcessJobStateConfig {
gem::ProcessJobTransitionTable table;
gem::ProcessJobState initial = gem::ProcessJobState::Queued;
};
// Loads data/process_job_state.yaml.
ProcessJobStateConfig load_process_job_state(const std::string& yaml_path);
struct ControlJobStateConfig {
gem::ControlJobTransitionTable table;
gem::ControlJobState initial = gem::ControlJobState::Queued;
};
// Loads data/control_job_state.yaml.
ControlJobStateConfig load_control_job_state(const std::string& yaml_path);
} // namespace secsgem::config } // namespace secsgem::config
+140
View File
@@ -0,0 +1,140 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
namespace secsgem::gem {
// E30 §6.5 — GEM Communication State Model.
//
// This is a *separate* state machine from the HSMS connection state
// (NOT-SELECTED / SELECTED) and from the E30 control state model. It
// governs whether the equipment is currently in a "communicating"
// relationship with the host as established by the S1F13 / S1F14
// handshake. Per E30 §6.5:
//
// DISABLED
// +-- (operator enables comms) ----------+
// | v
// ENABLED.NOT-COMMUNICATING.WAIT-CRA (sent S1F13; awaiting S1F14)
// | ^ |
// | | (T_DELAY elapses) | (S1F14 COMMACK=Accept)
// | | v
// ENABLED.NOT-COMMUNICATING.WAIT-DELAY ENABLED.COMMUNICATING
// ^ ^ |
// | +------ (T_CRA timeout, or /
// | COMMACK!=Accept) ---------+
// +-- (any event below; comms re-attempted on T_DELAY)
//
// (anywhere in ENABLED) -- (operator disables) --> DISABLED
//
// Timers (E30 §6.5):
// T_CRA — Communication Response Awaited; default 30 s.
// Bounds how long we'll wait for S1F14 after sending S1F13.
// T_DELAY — Delay between retry attempts; default 10 s.
//
// This class is pure logic. It owns NO sockets and NO timers — instead
// it asks its embedder to arm a timer via the `OnTimer` callback (and
// cancel via OnCancelTimer), and the embedder reports the timer firing
// by calling on_cra_timeout() / on_delay_elapsed(). That keeps the
// state machine testable without Asio.
enum class CommState : uint8_t {
Disabled, // operator-disabled; no comm attempts
WaitCRA, // S1F13 sent, waiting for S1F14
WaitDelay, // S1F14 failed or timed out; waiting before retry
Communicating, // S1F13/F14 handshake succeeded
};
const char* comm_state_name(CommState s);
// Optional listener for state changes (mainly for logging).
using CommStateChangeHandler =
std::function<void(CommState from, CommState to, const std::string& reason)>;
struct CommTimers {
std::chrono::milliseconds t_cra{std::chrono::seconds(30)};
std::chrono::milliseconds t_delay{std::chrono::seconds(10)};
};
// Embedder callbacks. arm_t_cra() / arm_t_delay() must schedule a
// one-shot callback that calls on_cra_timeout() / on_delay_elapsed()
// respectively when the duration elapses. cancel_timers() must cancel
// any pending arming. We deliberately do not embed an asio::steady_timer
// here so the state machine is unit-testable.
struct CommEnvironment {
std::function<void(std::chrono::milliseconds)> arm_t_cra;
std::function<void(std::chrono::milliseconds)> arm_t_delay;
std::function<void()> cancel_timers;
// Asked to send the equipment-initiated S1F13. May be empty; only
// equipment-initiated establishment uses this.
std::function<void()> send_s1f13;
};
class CommunicationStateMachine {
public:
// The communication-establishment direction. Per E30 the host may
// also initiate by sending S1F13 first; in that case we go directly
// from DISABLED to COMMUNICATING on receipt of a successful S1F13.
enum class Initiator {
Equipment, // we send S1F13 ourselves on enable
Host, // we wait for host's S1F13
};
explicit CommunicationStateMachine(CommTimers timers = {},
Initiator initiator = Initiator::Equipment);
// Injection point for the embedder. Required before enable() can fire
// the equipment-initiated S1F13.
void set_environment(CommEnvironment env) { env_ = std::move(env); }
void set_state_change_handler(CommStateChangeHandler h) { on_change_ = std::move(h); }
CommState state() const { return state_; }
bool communicating() const { return state_ == CommState::Communicating; }
// Operator actions.
void enable(); // DISABLED -> WaitCRA (equipment-initiated) or stays
// in DISABLED-equivalent-NotCommunicating (host-initiated)
void disable(); // any -> DISABLED
// ---- Events from the message layer / timer layer --------------------
// Inbound S1F14 with the given COMMACK byte. Accept (0) transitions
// us to COMMUNICATING; anything else drops us to WAIT-DELAY for a
// retry. Only meaningful in WAIT-CRA; ignored elsewhere.
void on_s1f14_received(uint8_t commack);
// Inbound S1F13 from the host. The equipment must reply with S1F14;
// we transition to COMMUNICATING immediately (the reply is the
// embedder's responsibility — typically via a Router handler).
void on_s1f13_received();
// The transport layer dropped (HSMS Connection closed). Per E30 any
// active communications transition back into NOT-COMMUNICATING.
void on_connection_lost();
// Timer firings — called by the embedder's scheduled callbacks.
void on_cra_timeout();
void on_delay_elapsed();
// Manual retry, mostly for tests. Equivalent to "T_DELAY elapsed
// right now"; only meaningful in WAIT-DELAY.
void retry_now() { on_delay_elapsed(); }
Initiator initiator() const { return initiator_; }
const CommTimers& timers() const { return timers_; }
private:
void transition(CommState next, const std::string& reason);
CommTimers timers_;
Initiator initiator_;
CommState state_ = CommState::Disabled;
CommEnvironment env_;
CommStateChangeHandler on_change_;
};
} // namespace secsgem::gem
+111
View File
@@ -0,0 +1,111 @@
#pragma once
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <vector>
#include "secsgem/gem/store/host_commands.hpp" // HostCmdAck
namespace secsgem::gem {
// E94 §6 Control Job state model.
//
// E94 governs the *batch* of work — a CJ owns an ordered list of PRJOBIDs
// (process jobs) and a processing policy. States below match E94-0705 §6
// (the values are this project's own opaque encoding — E94 does not pin a
// PRJOBSTATE-style wire enum for CJ; we surface state via S6F11 CEIDs in
// the demo).
enum class ControlJobState : uint8_t {
Queued = 0,
Selected = 1,
WaitingForStart = 2,
Executing = 3,
Paused = 4,
Completed = 5, // terminal: all PJs done, awaiting deletion
Stopping = 6,
Aborting = 7,
NoState = 255,
};
const char* control_job_state_name(ControlJobState s);
std::optional<ControlJobState> parse_control_job_state(const std::string& s);
enum class ControlJobEvent {
Created, // -> Queued
Select, // Queued -> Selected
SetupComplete, // Selected -> WaitingForStart
Start, // host: CJSTART -> Executing
Pause, // host: CJPAUSE -> Paused
Resume, // host: CJRESUME -> Executing
Stop, // host: CJSTOP -> Stopping
Abort, // host: CJABORT -> Aborting
AllJobsComplete, // internal: Executing/Stopping -> Completed
AbortComplete, // internal: Aborting -> Completed
};
const char* control_job_event_name(ControlJobEvent e);
std::optional<ControlJobEvent> parse_control_job_event(const std::string& s);
// Wire-name -> event mapping for the CTLJOBCMD string on S16F27.
std::optional<ControlJobEvent> ctl_cmd_to_event(const std::string& cmd);
struct ControlJobTransition {
ControlJobState from;
ControlJobEvent on;
std::optional<ControlJobState> to;
std::optional<uint8_t> ack_code; // HostCmdAck when applicable
};
class ControlJobTransitionTable {
public:
void add(ControlJobTransition row);
const ControlJobTransition* find(ControlJobState from,
ControlJobEvent on) const;
std::size_t size() const { return rows_.size(); }
const std::vector<ControlJobTransition>& rows() const { return rows_; }
static ControlJobTransitionTable default_table();
private:
std::vector<ControlJobTransition> rows_;
};
class ControlJobStateMachine {
public:
using StateChangeHandler =
std::function<void(ControlJobState from, ControlJobState to,
ControlJobEvent trigger)>;
ControlJobStateMachine();
explicit ControlJobStateMachine(ControlJobTransitionTable table,
ControlJobState initial = ControlJobState::Queued);
ControlJobState state() const { return state_; }
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
HostCmdAck on_host_command(ControlJobEvent event);
bool on_internal(ControlJobEvent event);
private:
const ControlJobTransition* fire(ControlJobEvent on);
void transition(ControlJobState next, ControlJobEvent trigger);
ControlJobTransitionTable table_;
ControlJobState state_;
StateChangeHandler on_change_;
};
// S14F10 / F12 OBJACK — generic ObjectService ack. Used by the
// CreateControlJob / DeleteControlJob handlers.
enum class ObjectAck : uint8_t {
Success = 0,
Error = 1,
Denied_UnknownObject = 2,
Denied_AlreadyExists = 3,
Denied_InvalidAttribute = 4,
Denied_BadState = 5,
};
} // namespace secsgem::gem
+4
View File
@@ -2,10 +2,12 @@
#include "secsgem/gem/store/alarms.hpp" #include "secsgem/gem/store/alarms.hpp"
#include "secsgem/gem/store/clock.hpp" #include "secsgem/gem/store/clock.hpp"
#include "secsgem/gem/store/control_jobs.hpp"
#include "secsgem/gem/store/equipment_constants.hpp" #include "secsgem/gem/store/equipment_constants.hpp"
#include "secsgem/gem/store/event_reports.hpp" #include "secsgem/gem/store/event_reports.hpp"
#include "secsgem/gem/store/host_commands.hpp" #include "secsgem/gem/store/host_commands.hpp"
#include "secsgem/gem/store/limits.hpp" #include "secsgem/gem/store/limits.hpp"
#include "secsgem/gem/store/process_jobs.hpp"
#include "secsgem/gem/store/recipes.hpp" #include "secsgem/gem/store/recipes.hpp"
#include "secsgem/gem/store/spool.hpp" #include "secsgem/gem/store/spool.hpp"
#include "secsgem/gem/store/status_variables.hpp" #include "secsgem/gem/store/status_variables.hpp"
@@ -30,6 +32,8 @@ struct EquipmentDataModel {
SpoolStore spool; SpoolStore spool;
LimitMonitorStore limits; LimitMonitorStore limits;
TraceStore traces; TraceStore traces;
ProcessJobStore process_jobs;
ControlJobStore control_jobs;
// Convenience: VID -> value lookup spanning SVIDs and DVIDs. // Convenience: VID -> value lookup spanning SVIDs and DVIDs.
std::optional<s2::Item> vid_value(uint32_t vid) const { std::optional<s2::Item> vid_value(uint32_t vid) const {
+130
View File
@@ -0,0 +1,130 @@
#pragma once
#include <cstdint>
#include <functional>
#include <optional>
#include <string>
#include <vector>
#include "secsgem/gem/store/host_commands.hpp" // HostCmdAck
namespace secsgem::gem {
// E40 §6 Process Job state model.
//
// Per E40-0705 §6.3 the PJ lifecycle is a directed graph over the eight
// states below. The values match the PRJOBSTATE byte that S16F9 carries
// on the wire (E40-0705 §10.3.2): 0 QUEUED, 1 SETTING-UP,
// 2 WAITING-FOR-START, 3 PROCESSING, 4 PROCESS-COMPLETE, 5 PAUSED,
// 6 STOPPING, 7 ABORTING. NoState (255) is our sentinel for "doesn't
// exist yet / freshly deleted" so the same enum can be used in the API.
enum class ProcessJobState : uint8_t {
Queued = 0,
SettingUp = 1,
WaitingForStart = 2,
Processing = 3,
ProcessComplete = 4,
Paused = 5,
Stopping = 6,
Aborting = 7,
NoState = 255,
};
const char* process_job_state_name(ProcessJobState s);
std::optional<ProcessJobState> parse_process_job_state(const std::string& s);
// Inputs that drive the PJ FSM. Names mirror the E40 spec verbs: the
// host-initiated PJSTART / PJPAUSE / ... arrive as S16F5 PRCMD strings;
// the internal SetupComplete / ProcessComplete fire from the equipment's
// application logic (recipe runner, abort controller, etc.).
//
// `Created` is a synthetic observer signal — the store fires it through
// the change handler when a PJ first lands in Queued, so subscribers can
// distinguish "created in Queued" from "transitioned into Queued" (the
// latter never happens, but the signal is still useful for bookkeeping).
// It deliberately does NOT appear in the transition table and the YAML
// loader rejects `on: created` rows.
enum class ProcessJobEvent {
Created, // synthetic NoState -> Queued (observer only)
Select, // QUEUED -> SETTING-UP (CJ promoted this PJ)
SetupComplete, // SETTING-UP -> WAITING-FOR-START
Start, // WAITING-FOR-START -> PROCESSING (host: PJSTART)
Pause, // PROCESSING -> PAUSED (host: PJPAUSE)
Resume, // PAUSED -> PROCESSING (host: PJRESUME)
Stop, // ... -> STOPPING (host: PJSTOP)
Abort, // ... -> ABORTING (host: PJABORT)
HeadOfQueue, // QUEUED -> QUEUED (host: PJHOQ; reorder only)
ProcessComplete, // PROCESSING/STOPPING -> PROCESS-COMPLETE
AbortComplete, // ABORTING -> PROCESS-COMPLETE
};
const char* process_job_event_name(ProcessJobEvent e);
std::optional<ProcessJobEvent> parse_process_job_event(const std::string& s);
// Wire-name -> event mapping for the PRCMD string on S16F5. Returns
// nullopt for unknown verbs (server should reply HCACK=InvalidCommand).
std::optional<ProcessJobEvent> pr_cmd_to_event(const std::string& prcmd);
// One row of the PJ transition table. Same shape conventions as
// ControlTransition (see control_state.hpp): `to` absent means the row
// only validates the event (e.g. HOQ is a reorder, not a state change),
// and `ack_code` is the HCACK the equipment should return to the host
// when the event came from S16F5.
struct ProcessJobTransition {
ProcessJobState from;
ProcessJobEvent on;
std::optional<ProcessJobState> to;
std::optional<uint8_t> ack_code; // HostCmdAck when applicable
};
class ProcessJobTransitionTable {
public:
void add(ProcessJobTransition row);
const ProcessJobTransition* find(ProcessJobState from,
ProcessJobEvent on) const;
std::size_t size() const { return rows_.size(); }
const std::vector<ProcessJobTransition>& rows() const { return rows_; }
// Built-in default table matching data/process_job_state.yaml exactly.
// Tests use this so they don't depend on the YAML being present.
static ProcessJobTransitionTable default_table();
private:
std::vector<ProcessJobTransition> rows_;
};
// PJ FSM engine. One instance per Process Job — the ProcessJobStore
// keeps them keyed by PRJOBID. Behaviour rules live in the table.
class ProcessJobStateMachine {
public:
using StateChangeHandler =
std::function<void(ProcessJobState from, ProcessJobState to,
ProcessJobEvent trigger)>;
ProcessJobStateMachine();
explicit ProcessJobStateMachine(ProcessJobTransitionTable table,
ProcessJobState initial = ProcessJobState::Queued);
ProcessJobState state() const { return state_; }
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
// Apply the host-initiated event from S16F5. Returns the HCACK the
// equipment should reply with: Accept if a row exists with no
// ack_code override, otherwise the row's ack. CannotDoNow if the
// (state, event) pair has no matching row.
HostCmdAck on_host_command(ProcessJobEvent event);
// Apply an internal event (SetupComplete, ProcessComplete,
// AbortComplete). Returns true if a transition fired.
bool on_internal(ProcessJobEvent event);
private:
const ProcessJobTransition* fire(ProcessJobEvent on);
void transition(ProcessJobState next, ProcessJobEvent trigger);
ProcessJobTransitionTable table_;
ProcessJobState state_;
StateChangeHandler on_change_;
};
} // namespace secsgem::gem
+120
View File
@@ -0,0 +1,120 @@
#pragma once
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "secsgem/gem/control_job_state.hpp"
namespace secsgem::gem {
// One Control Job — owns an ordered list of PRJOBIDs (process jobs).
struct ControlJob {
std::string ctljobid;
std::vector<std::string> prjobids;
std::unique_ptr<ControlJobStateMachine> fsm;
};
class ControlJobStore {
public:
using TransitionTableFactory =
std::function<ControlJobTransitionTable()>;
using StateChangeHandler =
std::function<void(const std::string& ctljobid,
ControlJobState from, ControlJobState to,
ControlJobEvent trigger)>;
ControlJobStore()
: factory_([] { return ControlJobTransitionTable::default_table(); }) {}
void set_table_factory(TransitionTableFactory f) { factory_ = std::move(f); }
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
enum class CreateResult {
Created,
Denied_AlreadyExists,
Denied_UnknownPRJob,
Denied_Empty,
};
CreateResult create(std::string ctljobid,
std::vector<std::string> prjobids,
const std::function<bool(const std::string&)>& pj_exists =
[](const std::string&) { return true; }) {
if (jobs_.count(ctljobid)) return CreateResult::Denied_AlreadyExists;
if (prjobids.empty()) return CreateResult::Denied_Empty;
for (const auto& id : prjobids) {
if (!pj_exists(id)) return CreateResult::Denied_UnknownPRJob;
}
auto fsm = std::make_unique<ControlJobStateMachine>(factory_(),
ControlJobState::Queued);
const std::string id_for_handler = ctljobid;
if (on_change_) {
auto cb = on_change_;
fsm->set_state_change_handler(
[cb, id_for_handler](ControlJobState from, ControlJobState to,
ControlJobEvent trig) {
cb(id_for_handler, from, to, trig);
});
}
jobs_.emplace(ctljobid, ControlJob{ctljobid, std::move(prjobids),
std::move(fsm)});
if (on_change_) {
on_change_(id_for_handler, ControlJobState::NoState,
ControlJobState::Queued, ControlJobEvent::Created);
}
return CreateResult::Created;
}
bool has(const std::string& ctljobid) const {
return jobs_.count(ctljobid) > 0;
}
const ControlJob* get(const std::string& ctljobid) const {
auto it = jobs_.find(ctljobid);
return it == jobs_.end() ? nullptr : &it->second;
}
ControlJob* get(const std::string& ctljobid) {
auto it = jobs_.find(ctljobid);
return it == jobs_.end() ? nullptr : &it->second;
}
ControlJobState state(const std::string& ctljobid) const {
auto it = jobs_.find(ctljobid);
return it == jobs_.end() ? ControlJobState::NoState
: it->second.fsm->state();
}
HostCmdAck on_host_command(const std::string& ctljobid, ControlJobEvent event) {
auto* cj = get(ctljobid);
if (!cj) return HostCmdAck::InvalidObject;
return cj->fsm->on_host_command(event);
}
bool fire_internal(const std::string& ctljobid, ControlJobEvent event) {
auto* cj = get(ctljobid);
if (!cj) return false;
return cj->fsm->on_internal(event);
}
bool remove(const std::string& ctljobid) {
return jobs_.erase(ctljobid) > 0;
}
std::size_t size() const { return jobs_.size(); }
std::vector<std::string> ids() const {
std::vector<std::string> out;
out.reserve(jobs_.size());
for (const auto& kv : jobs_) out.push_back(kv.first);
return out;
}
private:
std::map<std::string, ControlJob> jobs_;
TransitionTableFactory factory_;
StateChangeHandler on_change_;
};
} // namespace secsgem::gem
@@ -25,11 +25,13 @@ struct EquipmentConstant {
std::string max_str; std::string max_str;
}; };
// S2F16 EAC per SEMI E5: 0=OK, 1=one or more constants does not exist,
// 2=busy, 3=one or more values out of range. Values 4-127 are reserved.
enum class EquipmentAck : uint8_t { enum class EquipmentAck : uint8_t {
Accept = 0, Accept = 0,
Denied_UnknownEcid = 1, Denied_UnknownEcid = 1,
Denied_Busy = 3, Denied_Busy = 2,
Denied_OutOfRange = 4, Denied_OutOfRange = 3,
}; };
class EquipmentConstantStore { class EquipmentConstantStore {
@@ -82,17 +84,22 @@ class EquipmentConstantStore {
} }
static bool extract_number(const s2::Item& item, double& out) { static bool extract_number(const s2::Item& item, double& out) {
auto first = [&](const auto& v) -> bool {
if (v.empty()) return false;
out = static_cast<double>(v.front());
return true;
};
switch (item.format()) { switch (item.format()) {
case s2::Format::U1: out = std::get<std::vector<uint8_t>>(item.storage()).front(); return true; case s2::Format::U1: return first(std::get<std::vector<uint8_t>>(item.storage()));
case s2::Format::U2: out = std::get<std::vector<uint16_t>>(item.storage()).front(); return true; case s2::Format::U2: return first(std::get<std::vector<uint16_t>>(item.storage()));
case s2::Format::U4: out = std::get<std::vector<uint32_t>>(item.storage()).front(); return true; case s2::Format::U4: return first(std::get<std::vector<uint32_t>>(item.storage()));
case s2::Format::U8: out = static_cast<double>(std::get<std::vector<uint64_t>>(item.storage()).front()); return true; case s2::Format::U8: return first(std::get<std::vector<uint64_t>>(item.storage()));
case s2::Format::I1: out = std::get<std::vector<int8_t>>(item.storage()).front(); return true; case s2::Format::I1: return first(std::get<std::vector<int8_t>>(item.storage()));
case s2::Format::I2: out = std::get<std::vector<int16_t>>(item.storage()).front(); return true; case s2::Format::I2: return first(std::get<std::vector<int16_t>>(item.storage()));
case s2::Format::I4: out = std::get<std::vector<int32_t>>(item.storage()).front(); return true; case s2::Format::I4: return first(std::get<std::vector<int32_t>>(item.storage()));
case s2::Format::I8: out = static_cast<double>(std::get<std::vector<int64_t>>(item.storage()).front()); return true; case s2::Format::I8: return first(std::get<std::vector<int64_t>>(item.storage()));
case s2::Format::F4: out = std::get<std::vector<float>>(item.storage()).front(); return true; case s2::Format::F4: return first(std::get<std::vector<float>>(item.storage()));
case s2::Format::F8: out = std::get<std::vector<double>>(item.storage()).front(); return true; case s2::Format::F8: return first(std::get<std::vector<double>>(item.storage()));
default: return false; default: return false;
} }
} }
+4 -1
View File
@@ -30,12 +30,15 @@ struct ReportData {
std::vector<s2::Item> values; std::vector<s2::Item> values;
}; };
// S2F34 DRACK per SEMI E5/E30: 0=accept, 1=insufficient space,
// 2=invalid format, 3=at least one RPTID already defined,
// 4=at least one VID does not exist.
enum class DefineReportAck : uint8_t { enum class DefineReportAck : uint8_t {
Accept = 0, Accept = 0,
InsufficientSpace = 1, InsufficientSpace = 1,
InvalidFormat = 2, InvalidFormat = 2,
RptidAlreadyDefined = 3, RptidAlreadyDefined = 3,
InvalidVid = 5, InvalidVid = 4,
}; };
enum class LinkEventAck : uint8_t { enum class LinkEventAck : uint8_t {
+185
View File
@@ -0,0 +1,185 @@
#pragma once
#include <algorithm>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include "secsgem/gem/process_job_state.hpp"
namespace secsgem::gem {
// One Process Job record. The FSM is heap-allocated through unique_ptr so
// the per-PJ state-change handler can capture a stable pointer to
// `this`-style state without invalidation on map rehash.
struct ProcessJob {
std::string prjobid;
std::string ppid; // recipe identifier
std::vector<std::string> mtrloutspec; // material identifiers
bool alert_enabled = true; // S16F9 alerts on/off
std::unique_ptr<ProcessJobStateMachine> fsm;
};
class ProcessJobStore {
public:
using TransitionTableFactory =
std::function<ProcessJobTransitionTable()>;
using StateChangeHandler =
std::function<void(const std::string& prjobid,
ProcessJobState from, ProcessJobState to,
ProcessJobEvent trigger)>;
ProcessJobStore()
: factory_([] { return ProcessJobTransitionTable::default_table(); }) {}
// The per-PJ FSM closes over `this`, so the store must keep a stable
// address. unique_ptr makes copying impossible anyway; moves would
// silently dangle the per-PJ lambdas — disallow them explicitly.
ProcessJobStore(const ProcessJobStore&) = delete;
ProcessJobStore& operator=(const ProcessJobStore&) = delete;
ProcessJobStore(ProcessJobStore&&) = delete;
ProcessJobStore& operator=(ProcessJobStore&&) = delete;
void set_table_factory(TransitionTableFactory f) { factory_ = std::move(f); }
void set_state_change_handler(StateChangeHandler h) { on_change_ = std::move(h); }
enum class CreateResult {
Created,
Denied_AlreadyExists,
Denied_InvalidPpid,
};
// Validate `ppid` against the optional callback; if accepted, create
// the PJ in Queued and fire the Created -> Queued change handler.
CreateResult create(std::string prjobid, std::string ppid,
std::vector<std::string> materials,
const std::function<bool(const std::string&)>& ppid_exists =
[](const std::string&) { return true; }) {
if (jobs_.count(prjobid)) return CreateResult::Denied_AlreadyExists;
if (!ppid_exists(ppid)) return CreateResult::Denied_InvalidPpid;
auto fsm = std::make_unique<ProcessJobStateMachine>(factory_(),
ProcessJobState::Queued);
// Dispatch through *this so a later set_state_change_handler() takes
// effect for existing PJs (the captured-at-create snapshot pattern
// would otherwise pin the old handler on jobs already in the map).
fsm->set_state_change_handler(
[this, id = prjobid](ProcessJobState from, ProcessJobState to,
ProcessJobEvent trig) {
if (on_change_) on_change_(id, from, to, trig);
});
order_.push_back(prjobid);
jobs_.emplace(prjobid, ProcessJob{prjobid, std::move(ppid),
std::move(materials), true,
std::move(fsm)});
// Synthetic NoState -> Queued so subscribers observe creation. The
// server filters this so it doesn't emit a bogus S16F9 for a PJ
// that's still being acked.
if (on_change_) {
on_change_(jobs_.find(prjobid)->first, ProcessJobState::NoState,
ProcessJobState::Queued, ProcessJobEvent::Created);
}
return CreateResult::Created;
}
bool has(const std::string& prjobid) const {
return jobs_.count(prjobid) > 0;
}
const ProcessJob* get(const std::string& prjobid) const {
auto it = jobs_.find(prjobid);
return it == jobs_.end() ? nullptr : &it->second;
}
ProcessJob* get(const std::string& prjobid) {
auto it = jobs_.find(prjobid);
return it == jobs_.end() ? nullptr : &it->second;
}
ProcessJobState state(const std::string& prjobid) const {
auto it = jobs_.find(prjobid);
return it == jobs_.end() ? ProcessJobState::NoState
: it->second.fsm->state();
}
// Host-initiated S16F5 PRJobCommand. Returns InvalidObject for unknown
// PJs; CannotDoNow when the current PJ state has no row for the event.
// For HOQ the FSM gates legality (Queued only) and the store performs
// the actual reorder so the next CJ Select picks this PJ first.
HostCmdAck on_host_command(const std::string& prjobid, ProcessJobEvent event) {
auto* pj = get(prjobid);
if (!pj) return HostCmdAck::InvalidObject;
HostCmdAck ack = pj->fsm->on_host_command(event);
if (ack == HostCmdAck::Accept && event == ProcessJobEvent::HeadOfQueue) {
move_to_head(prjobid);
}
return ack;
}
// Position of `prjobid` in the queue order (insertion order; HOQ rewrites
// it). Returns -1 if unknown. Exposed for tests and CJ promotion logic.
int position(const std::string& prjobid) const {
auto it = std::find(order_.begin(), order_.end(), prjobid);
return it == order_.end() ? -1 : static_cast<int>(it - order_.begin());
}
// Internal events from the application (recipe runner etc.).
bool fire_internal(const std::string& prjobid, ProcessJobEvent event) {
auto* pj = get(prjobid);
if (!pj) return false;
return pj->fsm->on_internal(event);
}
// Dequeue S16F13: only legal while QUEUED.
HostCmdAck dequeue(const std::string& prjobid) {
auto it = jobs_.find(prjobid);
if (it == jobs_.end()) return HostCmdAck::InvalidObject;
if (it->second.fsm->state() != ProcessJobState::Queued)
return HostCmdAck::CannotDoNow;
erase_from_order(prjobid);
jobs_.erase(it);
return HostCmdAck::Accept;
}
// Remove a terminal job from the store (caller responsibility to ensure
// ProcessComplete before deleting). Used after CJ Completed cleanup.
bool remove(const std::string& prjobid) {
erase_from_order(prjobid);
return jobs_.erase(prjobid) > 0;
}
// Per-PJ S16F9 alert gate. E40 §10.3 leaves PRALERT control to the host
// (S16F1/F2 in the full multi-create form, which we don't model yet);
// exposed here so application code can toggle alerts directly.
bool set_alert(const std::string& prjobid, bool enabled) {
auto* pj = get(prjobid);
if (!pj) return false;
pj->alert_enabled = enabled;
return true;
}
std::size_t size() const { return jobs_.size(); }
// Insertion-order list of PRJOBIDs, mutated by HOQ. Used by CJ promotion
// logic and by tests that need to assert ordering.
const std::vector<std::string>& ids() const { return order_; }
private:
void erase_from_order(const std::string& prjobid) {
auto it = std::find(order_.begin(), order_.end(), prjobid);
if (it != order_.end()) order_.erase(it);
}
void move_to_head(const std::string& prjobid) {
auto it = std::find(order_.begin(), order_.end(), prjobid);
if (it == order_.end() || it == order_.begin()) return;
std::rotate(order_.begin(), it, it + 1);
}
std::map<std::string, ProcessJob> jobs_;
std::vector<std::string> order_; // queue position (E40 HOQ-aware)
TransitionTableFactory factory_;
StateChangeHandler on_change_;
};
} // namespace secsgem::gem
+15 -2
View File
@@ -16,6 +16,8 @@ enum class Format : uint8_t {
Binary = 010, // 8 Binary = 010, // 8
Boolean = 011, // 9 Boolean = 011, // 9
ASCII = 020, // 16 ASCII = 020, // 16
JIS8 = 021, // 17 — 8-bit JIS (single-byte Japanese text)
C2 = 022, // 18 — 2-byte Unicode code points (big-endian)
I8 = 030, // 24 I8 = 030, // 24
I1 = 031, // 25 I1 = 031, // 25
I2 = 032, // 26 I2 = 032, // 26
@@ -34,6 +36,8 @@ inline const char* format_name(Format f) {
case Format::Binary: return "B"; case Format::Binary: return "B";
case Format::Boolean: return "BOOLEAN"; case Format::Boolean: return "BOOLEAN";
case Format::ASCII: return "A"; case Format::ASCII: return "A";
case Format::JIS8: return "J";
case Format::C2: return "C";
case Format::I8: return "I8"; case Format::I8: return "I8";
case Format::I1: return "I1"; case Format::I1: return "I1";
case Format::I2: return "I2"; case Format::I2: return "I2";
@@ -55,10 +59,12 @@ inline std::size_t element_size(Format f) {
switch (f) { switch (f) {
case Format::List: return 0; case Format::List: return 0;
case Format::ASCII: case Format::ASCII:
case Format::JIS8:
case Format::Binary: case Format::Binary:
case Format::Boolean: case Format::Boolean:
case Format::U1: case Format::U1:
case Format::I1: return 1; case Format::I1: return 1;
case Format::C2:
case Format::U2: case Format::U2:
case Format::I2: return 2; case Format::I2: return 2;
case Format::U4: case Format::U4:
@@ -81,13 +87,13 @@ class Item {
using List = std::vector<Item>; using List = std::vector<Item>;
using Storage = std::variant< using Storage = std::variant<
List, // List List, // List
std::string, // ASCII std::string, // ASCII, JIS-8
std::vector<uint8_t>, // Binary, Boolean, U1 std::vector<uint8_t>, // Binary, Boolean, U1
std::vector<int8_t>, // I1 std::vector<int8_t>, // I1
std::vector<int16_t>, // I2 std::vector<int16_t>, // I2
std::vector<int32_t>, // I4 std::vector<int32_t>, // I4
std::vector<int64_t>, // I8 std::vector<int64_t>, // I8
std::vector<uint16_t>, // U2 std::vector<uint16_t>, // U2, C2 (Unicode code points)
std::vector<uint32_t>, // U4 std::vector<uint32_t>, // U4
std::vector<uint64_t>, // U8 std::vector<uint64_t>, // U8
std::vector<float>, // F4 std::vector<float>, // F4
@@ -107,6 +113,8 @@ class Item {
// --- Factory functions ------------------------------------------------- // --- Factory functions -------------------------------------------------
static Item list(List items) { return Item(Format::List, std::move(items)); } static Item list(List items) { return Item(Format::List, std::move(items)); }
static Item ascii(std::string s) { return Item(Format::ASCII, std::move(s)); } static Item ascii(std::string s) { return Item(Format::ASCII, std::move(s)); }
static Item jis8(std::string s) { return Item(Format::JIS8, std::move(s)); }
static Item c2(std::vector<uint16_t> code_points) { return Item(Format::C2, std::move(code_points)); }
static Item binary(std::vector<uint8_t> b) { return Item(Format::Binary, std::move(b)); } static Item binary(std::vector<uint8_t> b) { return Item(Format::Binary, std::move(b)); }
static Item boolean(std::vector<uint8_t> b) { return Item(Format::Boolean, std::move(b)); } static Item boolean(std::vector<uint8_t> b) { return Item(Format::Boolean, std::move(b)); }
static Item boolean(bool b) { return Item(Format::Boolean, std::vector<uint8_t>{static_cast<uint8_t>(b ? 1 : 0)}); } static Item boolean(bool b) { return Item(Format::Boolean, std::vector<uint8_t>{static_cast<uint8_t>(b ? 1 : 0)}); }
@@ -141,6 +149,11 @@ class Item {
const List& as_list() const { return std::get<List>(data_); } const List& as_list() const { return std::get<List>(data_); }
List& as_list() { return std::get<List>(data_); } List& as_list() { return std::get<List>(data_); }
const std::string& as_ascii() const { return std::get<std::string>(data_); } const std::string& as_ascii() const { return std::get<std::string>(data_); }
// JIS-8 shares the std::string storage slot (it's a single-byte
// encoding like ASCII); callers disambiguate via `format()`.
const std::string& as_jis8() const { return std::get<std::string>(data_); }
// C2 (Unicode) shares the std::vector<uint16_t> storage with U2.
const std::vector<uint16_t>& as_c2() const { return std::get<std::vector<uint16_t>>(data_); }
const std::vector<uint8_t>& as_bytes() const { return std::get<std::vector<uint8_t>>(data_); } const std::vector<uint8_t>& as_bytes() const { return std::get<std::vector<uint8_t>>(data_); }
const Storage& storage() const { return data_; } const Storage& storage() const { return data_; }
+85
View File
@@ -242,4 +242,89 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
return desc; return desc;
} }
ProcessJobStateConfig load_process_job_state(const std::string& path) {
YAML::Node root = load(path);
// NoState is a sentinel for "no PJ" used in the synthetic create
// notification; it must never appear in the FSM table or as the
// initial state.
auto reject_nostate = [&path](const std::string& where, gem::ProcessJobState s) {
if (s == gem::ProcessJobState::NoState)
fail(where, "`NoState` is a sentinel, not a valid table state");
};
ProcessJobStateConfig cfg;
if (auto initial = root["initial"]) {
auto parsed = gem::parse_process_job_state(initial.as<std::string>());
if (!parsed) fail(path, "unknown initial state `" + initial.as<std::string>() + "`");
reject_nostate(path + " initial", *parsed);
cfg.initial = *parsed;
}
const auto transitions = root["transitions"];
if (!transitions || !transitions.IsSequence())
fail(path, "missing or non-sequence `transitions`");
for (std::size_t i = 0; i < transitions.size(); ++i) {
const auto& row = transitions[i];
const auto where = path + " transitions[" + std::to_string(i) + "]";
auto from = gem::parse_process_job_state(req_as<std::string>(row["from"], where, "from"));
auto on = gem::parse_process_job_event(req_as<std::string>(row["on"], where, "on"));
if (!from) fail(where, "unknown `from` state");
if (!on) fail(where, "unknown `on` event");
reject_nostate(where + " from", *from);
gem::ProcessJobTransition t{*from, *on, std::nullopt, std::nullopt};
if (auto to = row["to"]) {
auto s = gem::parse_process_job_state(to.as<std::string>());
if (!s) fail(where, "unknown `to` state");
reject_nostate(where + " to", *s);
t.to = *s;
}
if (auto ack = row["ack"]) {
t.ack_code = static_cast<uint8_t>(parse_hcack(ack.as<std::string>(), where));
}
cfg.table.add(t);
}
return cfg;
}
ControlJobStateConfig load_control_job_state(const std::string& path) {
YAML::Node root = load(path);
ControlJobStateConfig cfg;
if (auto initial = root["initial"]) {
auto parsed = gem::parse_control_job_state(initial.as<std::string>());
if (!parsed) fail(path, "unknown initial state `" + initial.as<std::string>() + "`");
cfg.initial = *parsed;
}
const auto transitions = root["transitions"];
if (!transitions || !transitions.IsSequence())
fail(path, "missing or non-sequence `transitions`");
for (std::size_t i = 0; i < transitions.size(); ++i) {
const auto& row = transitions[i];
const auto where = path + " transitions[" + std::to_string(i) + "]";
auto from = gem::parse_control_job_state(req_as<std::string>(row["from"], where, "from"));
auto on = gem::parse_control_job_event(req_as<std::string>(row["on"], where, "on"));
if (!from) fail(where, "unknown `from` state");
if (!on) fail(where, "unknown `on` event");
gem::ControlJobTransition t{*from, *on, std::nullopt, std::nullopt};
if (auto to = row["to"]) {
auto s = gem::parse_control_job_state(to.as<std::string>());
if (!s) fail(where, "unknown `to` state");
t.to = *s;
}
if (auto ack = row["ack"]) {
t.ack_code = static_cast<uint8_t>(parse_hcack(ack.as<std::string>(), where));
}
cfg.table.add(t);
}
return cfg;
}
} // namespace secsgem::config } // namespace secsgem::config
+104
View File
@@ -0,0 +1,104 @@
#include "secsgem/gem/communication_state.hpp"
namespace secsgem::gem {
const char* comm_state_name(CommState s) {
switch (s) {
case CommState::Disabled: return "DISABLED";
case CommState::WaitCRA: return "WAIT-CRA";
case CommState::WaitDelay: return "WAIT-DELAY";
case CommState::Communicating: return "COMMUNICATING";
}
return "?";
}
CommunicationStateMachine::CommunicationStateMachine(CommTimers timers, Initiator initiator)
: timers_(timers), initiator_(initiator) {}
void CommunicationStateMachine::transition(CommState next, const std::string& reason) {
if (state_ == next) return;
const CommState prev = state_;
state_ = next;
if (on_change_) on_change_(prev, next, reason);
}
void CommunicationStateMachine::enable() {
if (state_ != CommState::Disabled) return;
// Cancel any stale timers from a previous lifetime.
if (env_.cancel_timers) env_.cancel_timers();
if (initiator_ == Initiator::Equipment) {
// Equipment-initiated: send S1F13 immediately and wait for S1F14.
transition(CommState::WaitCRA, "enabled; equipment-initiated S1F13");
if (env_.arm_t_cra) env_.arm_t_cra(timers_.t_cra);
if (env_.send_s1f13) env_.send_s1f13();
} else {
// Host-initiated: we stay non-communicating until the host sends
// S1F13; we model the wait as WAIT-CRA (no T_CRA armed since the
// wait is indefinite from our side).
transition(CommState::WaitCRA, "enabled; awaiting host S1F13");
}
}
void CommunicationStateMachine::disable() {
if (state_ == CommState::Disabled) return;
if (env_.cancel_timers) env_.cancel_timers();
transition(CommState::Disabled, "disabled by operator");
}
void CommunicationStateMachine::on_s1f14_received(uint8_t commack) {
if (state_ != CommState::WaitCRA) return; // unexpected S1F14; ignore
if (env_.cancel_timers) env_.cancel_timers();
if (commack == 0) {
transition(CommState::Communicating, "S1F14 COMMACK=Accept");
} else {
transition(CommState::WaitDelay,
"S1F14 COMMACK=" + std::to_string(commack) + " (denied)");
if (env_.arm_t_delay) env_.arm_t_delay(timers_.t_delay);
}
}
void CommunicationStateMachine::on_s1f13_received() {
// Inbound establishment from the host. Spec allows this in any
// ENABLED substate (the host can re-establish). Disabled equipment
// would reply with COMMACK=Denied; that's the embedder's call, not
// ours — we just record the transition if the embedder accepts.
if (state_ == CommState::Disabled) return;
if (env_.cancel_timers) env_.cancel_timers();
transition(CommState::Communicating, "host S1F13 received");
}
void CommunicationStateMachine::on_connection_lost() {
if (state_ == CommState::Disabled) return;
if (env_.cancel_timers) env_.cancel_timers();
// Per E30: a transport drop returns us to NOT-COMMUNICATING. We
// model that as WAIT-DELAY (so we retry after T_DELAY) when we're
// an equipment-initiator, and as WAIT-CRA (awaiting host S1F13)
// otherwise.
if (initiator_ == Initiator::Equipment) {
transition(CommState::WaitDelay, "transport dropped");
if (env_.arm_t_delay) env_.arm_t_delay(timers_.t_delay);
} else {
transition(CommState::WaitCRA, "transport dropped; awaiting host S1F13");
}
}
void CommunicationStateMachine::on_cra_timeout() {
if (state_ != CommState::WaitCRA) return;
transition(CommState::WaitDelay, "T_CRA timeout");
if (env_.arm_t_delay) env_.arm_t_delay(timers_.t_delay);
}
void CommunicationStateMachine::on_delay_elapsed() {
if (state_ != CommState::WaitDelay) return;
// T_DELAY elapsed; re-attempt S1F13 if equipment-initiated.
if (initiator_ == Initiator::Equipment) {
transition(CommState::WaitCRA, "T_DELAY elapsed; re-attempting S1F13");
if (env_.arm_t_cra) env_.arm_t_cra(timers_.t_cra);
if (env_.send_s1f13) env_.send_s1f13();
} else {
transition(CommState::WaitCRA, "T_DELAY elapsed; awaiting host S1F13");
}
}
} // namespace secsgem::gem
+160
View File
@@ -0,0 +1,160 @@
#include "secsgem/gem/control_job_state.hpp"
namespace secsgem::gem {
const char* control_job_state_name(ControlJobState s) {
switch (s) {
case ControlJobState::Queued: return "Queued";
case ControlJobState::Selected: return "Selected";
case ControlJobState::WaitingForStart: return "WaitingForStart";
case ControlJobState::Executing: return "Executing";
case ControlJobState::Paused: return "Paused";
case ControlJobState::Completed: return "Completed";
case ControlJobState::Stopping: return "Stopping";
case ControlJobState::Aborting: return "Aborting";
case ControlJobState::NoState: return "NoState";
}
return "?";
}
std::optional<ControlJobState> parse_control_job_state(const std::string& s) {
if (s == "Queued") return ControlJobState::Queued;
if (s == "Selected") return ControlJobState::Selected;
if (s == "WaitingForStart") return ControlJobState::WaitingForStart;
if (s == "Executing") return ControlJobState::Executing;
if (s == "Paused") return ControlJobState::Paused;
if (s == "Completed") return ControlJobState::Completed;
if (s == "Stopping") return ControlJobState::Stopping;
if (s == "Aborting") return ControlJobState::Aborting;
if (s == "NoState") return ControlJobState::NoState;
return std::nullopt;
}
const char* control_job_event_name(ControlJobEvent e) {
switch (e) {
case ControlJobEvent::Created: return "Created";
case ControlJobEvent::Select: return "Select";
case ControlJobEvent::SetupComplete: return "SetupComplete";
case ControlJobEvent::Start: return "Start";
case ControlJobEvent::Pause: return "Pause";
case ControlJobEvent::Resume: return "Resume";
case ControlJobEvent::Stop: return "Stop";
case ControlJobEvent::Abort: return "Abort";
case ControlJobEvent::AllJobsComplete: return "AllJobsComplete";
case ControlJobEvent::AbortComplete: return "AbortComplete";
}
return "?";
}
std::optional<ControlJobEvent> parse_control_job_event(const std::string& s) {
if (s == "created") return ControlJobEvent::Created;
if (s == "select") return ControlJobEvent::Select;
if (s == "setup_complete") return ControlJobEvent::SetupComplete;
if (s == "start") return ControlJobEvent::Start;
if (s == "pause") return ControlJobEvent::Pause;
if (s == "resume") return ControlJobEvent::Resume;
if (s == "stop") return ControlJobEvent::Stop;
if (s == "abort") return ControlJobEvent::Abort;
if (s == "all_jobs_complete") return ControlJobEvent::AllJobsComplete;
if (s == "abort_complete") return ControlJobEvent::AbortComplete;
return std::nullopt;
}
std::optional<ControlJobEvent> ctl_cmd_to_event(const std::string& cmd) {
if (cmd == "CJSTART" || cmd == "START") return ControlJobEvent::Start;
if (cmd == "CJPAUSE" || cmd == "PAUSE") return ControlJobEvent::Pause;
if (cmd == "CJRESUME" || cmd == "RESUME") return ControlJobEvent::Resume;
if (cmd == "CJSTOP" || cmd == "STOP") return ControlJobEvent::Stop;
if (cmd == "CJABORT" || cmd == "ABORT") return ControlJobEvent::Abort;
return std::nullopt;
}
void ControlJobTransitionTable::add(ControlJobTransition row) {
rows_.push_back(row);
}
const ControlJobTransition* ControlJobTransitionTable::find(
ControlJobState from, ControlJobEvent on) const {
for (const auto& r : rows_) {
if (r.from == from && r.on == on) return &r;
}
return nullptr;
}
ControlJobTransitionTable ControlJobTransitionTable::default_table() {
using S = ControlJobState;
using E = ControlJobEvent;
ControlJobTransitionTable t;
// QUEUED:
t.add({S::Queued, E::Select, S::Selected, std::nullopt});
t.add({S::Queued, E::Stop, S::Completed, std::nullopt}); // empty CJ stop
t.add({S::Queued, E::Abort, S::Completed, std::nullopt});
// SELECTED:
t.add({S::Selected, E::SetupComplete, S::WaitingForStart, std::nullopt});
t.add({S::Selected, E::Stop, S::Stopping, std::nullopt});
t.add({S::Selected, E::Abort, S::Aborting, std::nullopt});
// WAITING-FOR-START:
t.add({S::WaitingForStart, E::Start, S::Executing, std::nullopt});
t.add({S::WaitingForStart, E::Stop, S::Stopping, std::nullopt});
t.add({S::WaitingForStart, E::Abort, S::Aborting, std::nullopt});
// EXECUTING:
t.add({S::Executing, E::Pause, S::Paused, std::nullopt});
t.add({S::Executing, E::Stop, S::Stopping, std::nullopt});
t.add({S::Executing, E::Abort, S::Aborting, std::nullopt});
t.add({S::Executing, E::AllJobsComplete, S::Completed, std::nullopt});
// PAUSED:
t.add({S::Paused, E::Resume, S::Executing, std::nullopt});
t.add({S::Paused, E::Stop, S::Stopping, std::nullopt});
t.add({S::Paused, E::Abort, S::Aborting, std::nullopt});
// STOPPING:
t.add({S::Stopping, E::AllJobsComplete, S::Completed, std::nullopt});
t.add({S::Stopping, E::Abort, S::Aborting, std::nullopt});
// ABORTING:
t.add({S::Aborting, E::AbortComplete, S::Completed, std::nullopt});
// COMPLETED: terminal.
return t;
}
ControlJobStateMachine::ControlJobStateMachine()
: ControlJobStateMachine(ControlJobTransitionTable::default_table(),
ControlJobState::Queued) {}
ControlJobStateMachine::ControlJobStateMachine(ControlJobTransitionTable table,
ControlJobState initial)
: table_(std::move(table)), state_(initial) {}
void ControlJobStateMachine::transition(ControlJobState next,
ControlJobEvent trigger) {
if (state_ == next) return;
const ControlJobState prev = state_;
state_ = next;
if (on_change_) on_change_(prev, next, trigger);
}
const ControlJobTransition* ControlJobStateMachine::fire(ControlJobEvent on) {
const ControlJobTransition* row = table_.find(state_, on);
if (!row) return nullptr;
if (row->to) transition(*row->to, on);
return row;
}
HostCmdAck ControlJobStateMachine::on_host_command(ControlJobEvent event) {
const auto* row = fire(event);
if (!row) return HostCmdAck::CannotDoNow;
if (row->ack_code) return static_cast<HostCmdAck>(*row->ack_code);
return HostCmdAck::Accept;
}
bool ControlJobStateMachine::on_internal(ControlJobEvent event) {
return fire(event) != nullptr;
}
} // namespace secsgem::gem
+183
View File
@@ -0,0 +1,183 @@
#include "secsgem/gem/process_job_state.hpp"
namespace secsgem::gem {
// PRJOBSTATE on the wire is a single byte per E40-0705 §10.3.2. Pin the
// enum values to the spec so a future reorder fails the build instead of
// silently corrupting S16F9 emissions.
static_assert(static_cast<uint8_t>(ProcessJobState::Queued) == 0);
static_assert(static_cast<uint8_t>(ProcessJobState::SettingUp) == 1);
static_assert(static_cast<uint8_t>(ProcessJobState::WaitingForStart) == 2);
static_assert(static_cast<uint8_t>(ProcessJobState::Processing) == 3);
static_assert(static_cast<uint8_t>(ProcessJobState::ProcessComplete) == 4);
static_assert(static_cast<uint8_t>(ProcessJobState::Paused) == 5);
static_assert(static_cast<uint8_t>(ProcessJobState::Stopping) == 6);
static_assert(static_cast<uint8_t>(ProcessJobState::Aborting) == 7);
const char* process_job_state_name(ProcessJobState s) {
switch (s) {
case ProcessJobState::Queued: return "Queued";
case ProcessJobState::SettingUp: return "SettingUp";
case ProcessJobState::WaitingForStart: return "WaitingForStart";
case ProcessJobState::Processing: return "Processing";
case ProcessJobState::ProcessComplete: return "ProcessComplete";
case ProcessJobState::Paused: return "Paused";
case ProcessJobState::Stopping: return "Stopping";
case ProcessJobState::Aborting: return "Aborting";
case ProcessJobState::NoState: return "NoState";
}
return "?";
}
std::optional<ProcessJobState> parse_process_job_state(const std::string& s) {
if (s == "Queued") return ProcessJobState::Queued;
if (s == "SettingUp") return ProcessJobState::SettingUp;
if (s == "WaitingForStart") return ProcessJobState::WaitingForStart;
if (s == "Processing") return ProcessJobState::Processing;
if (s == "ProcessComplete") return ProcessJobState::ProcessComplete;
if (s == "Paused") return ProcessJobState::Paused;
if (s == "Stopping") return ProcessJobState::Stopping;
if (s == "Aborting") return ProcessJobState::Aborting;
if (s == "NoState") return ProcessJobState::NoState;
return std::nullopt;
}
const char* process_job_event_name(ProcessJobEvent e) {
switch (e) {
case ProcessJobEvent::Created: return "Created";
case ProcessJobEvent::Select: return "Select";
case ProcessJobEvent::SetupComplete: return "SetupComplete";
case ProcessJobEvent::Start: return "Start";
case ProcessJobEvent::Pause: return "Pause";
case ProcessJobEvent::Resume: return "Resume";
case ProcessJobEvent::Stop: return "Stop";
case ProcessJobEvent::Abort: return "Abort";
case ProcessJobEvent::HeadOfQueue: return "HeadOfQueue";
case ProcessJobEvent::ProcessComplete: return "ProcessComplete";
case ProcessJobEvent::AbortComplete: return "AbortComplete";
}
return "?";
}
std::optional<ProcessJobEvent> parse_process_job_event(const std::string& s) {
// `Created` is intentionally not parseable — it's a synthetic observer
// signal, never a table row. Returning nullopt makes the loader reject
// `on: created` with "unknown `on` event".
if (s == "select") return ProcessJobEvent::Select;
if (s == "setup_complete") return ProcessJobEvent::SetupComplete;
if (s == "start") return ProcessJobEvent::Start;
if (s == "pause") return ProcessJobEvent::Pause;
if (s == "resume") return ProcessJobEvent::Resume;
if (s == "stop") return ProcessJobEvent::Stop;
if (s == "abort") return ProcessJobEvent::Abort;
if (s == "hoq") return ProcessJobEvent::HeadOfQueue;
if (s == "process_complete") return ProcessJobEvent::ProcessComplete;
if (s == "abort_complete") return ProcessJobEvent::AbortComplete;
return std::nullopt;
}
std::optional<ProcessJobEvent> pr_cmd_to_event(const std::string& prcmd) {
// PRCMD strings per E40-0705 §10.2.5.
if (prcmd == "PJSTART" || prcmd == "START") return ProcessJobEvent::Start;
if (prcmd == "PJPAUSE" || prcmd == "PAUSE") return ProcessJobEvent::Pause;
if (prcmd == "PJRESUME" || prcmd == "RESUME") return ProcessJobEvent::Resume;
if (prcmd == "PJSTOP" || prcmd == "STOP") return ProcessJobEvent::Stop;
if (prcmd == "PJABORT" || prcmd == "ABORT") return ProcessJobEvent::Abort;
if (prcmd == "PJHOQ" || prcmd == "HOQ") return ProcessJobEvent::HeadOfQueue;
return std::nullopt;
}
void ProcessJobTransitionTable::add(ProcessJobTransition row) {
rows_.push_back(row);
}
const ProcessJobTransition* ProcessJobTransitionTable::find(
ProcessJobState from, ProcessJobEvent on) const {
for (const auto& r : rows_) {
if (r.from == from && r.on == on) return &r;
}
return nullptr;
}
ProcessJobTransitionTable ProcessJobTransitionTable::default_table() {
using S = ProcessJobState;
using E = ProcessJobEvent;
ProcessJobTransitionTable t;
// QUEUED:
t.add({S::Queued, E::Select, S::SettingUp, std::nullopt});
t.add({S::Queued, E::HeadOfQueue, std::nullopt, std::nullopt}); // reorder only
// Stop/Abort on a Queued PJ routes through Aborting so the host
// observes PRJOBSTATE=7 on the wire (E40-0705 §6.3).
t.add({S::Queued, E::Stop, S::Aborting, std::nullopt});
t.add({S::Queued, E::Abort, S::Aborting, std::nullopt});
// SETTING-UP:
t.add({S::SettingUp, E::SetupComplete, S::WaitingForStart, std::nullopt});
t.add({S::SettingUp, E::Stop, S::Stopping, std::nullopt});
t.add({S::SettingUp, E::Abort, S::Aborting, std::nullopt});
// WAITING-FOR-START:
t.add({S::WaitingForStart, E::Start, S::Processing, std::nullopt});
t.add({S::WaitingForStart, E::Stop, S::Stopping, std::nullopt});
t.add({S::WaitingForStart, E::Abort, S::Aborting, std::nullopt});
// PROCESSING:
t.add({S::Processing, E::Pause, S::Paused, std::nullopt});
t.add({S::Processing, E::Stop, S::Stopping, std::nullopt});
t.add({S::Processing, E::Abort, S::Aborting, std::nullopt});
t.add({S::Processing, E::ProcessComplete, S::ProcessComplete, std::nullopt});
// PAUSED:
t.add({S::Paused, E::Resume, S::Processing, std::nullopt});
t.add({S::Paused, E::Stop, S::Stopping, std::nullopt});
t.add({S::Paused, E::Abort, S::Aborting, std::nullopt});
// STOPPING:
t.add({S::Stopping, E::ProcessComplete, S::ProcessComplete, std::nullopt});
t.add({S::Stopping, E::Abort, S::Aborting, std::nullopt});
// ABORTING:
t.add({S::Aborting, E::AbortComplete, S::ProcessComplete, std::nullopt});
// PROCESS-COMPLETE: terminal. No further transitions; deletion is via
// the ProcessJobStore (which removes the FSM entirely).
return t;
}
ProcessJobStateMachine::ProcessJobStateMachine()
: ProcessJobStateMachine(ProcessJobTransitionTable::default_table(),
ProcessJobState::Queued) {}
ProcessJobStateMachine::ProcessJobStateMachine(ProcessJobTransitionTable table,
ProcessJobState initial)
: table_(std::move(table)), state_(initial) {}
void ProcessJobStateMachine::transition(ProcessJobState next,
ProcessJobEvent trigger) {
if (state_ == next) return;
const ProcessJobState prev = state_;
state_ = next;
if (on_change_) on_change_(prev, next, trigger);
}
const ProcessJobTransition* ProcessJobStateMachine::fire(ProcessJobEvent on) {
const ProcessJobTransition* row = table_.find(state_, on);
if (!row) return nullptr;
if (row->to) transition(*row->to, on);
return row;
}
HostCmdAck ProcessJobStateMachine::on_host_command(ProcessJobEvent event) {
const auto* row = fire(event);
if (!row) return HostCmdAck::CannotDoNow;
if (row->ack_code) return static_cast<HostCmdAck>(*row->ack_code);
return HostCmdAck::Accept;
}
bool ProcessJobStateMachine::on_internal(ProcessJobEvent event) {
return fire(event) != nullptr;
}
} // namespace secsgem::gem
+65 -10
View File
@@ -107,21 +107,60 @@ void Connection::on_payload(std::error_code ec, std::size_t) {
close("read payload: " + ec.message()); close("read payload: " + ec.message());
return; return;
} }
Frame frame;
try { try {
handle_frame(Frame::decode(payload_.data(), payload_.size())); frame = Frame::decode(payload_.data(), payload_.size());
} catch (const std::exception& e) { } catch (const std::exception& e) {
close(std::string("decode: ") + e.what()); close(std::string("decode: ") + e.what());
return; return;
} }
// E37 §7.7: a non-SECS-II PType must be rejected with Reject.req.
// Convention (matches the EntityNotSelected emission below): the
// offending header byte is echoed in byte2 and the reason code goes
// in byte3.
if (frame.header.ptype != kPTypeSecsII) {
log("!! unsupported PType=" + std::to_string(frame.header.ptype) +
"; emitting Reject.req");
send_frame(Frame(Header::control(
SType::RejectReq, frame.header.system_bytes, frame.header.session_id,
frame.header.ptype,
static_cast<uint8_t>(RejectReason::PtypeNotSupported))));
read_length();
return;
}
handle_frame(std::move(frame));
read_length(); read_length();
} }
void Connection::handle_frame(Frame frame) { void Connection::handle_frame(Frame frame) {
if (frame.header.stype == SType::Data) { if (frame.header.stype == SType::Data) {
handle_data(frame); handle_data(frame);
} else { return;
handle_control(frame);
} }
// E37 §7.7: any SType the receiver does not implement must be answered
// with Reject.req (reason=StypeNotSupported). Defined SType values are
// {1,2,3,4,5,6,7,9}; anything else here is unsupported.
switch (frame.header.stype) {
case SType::SelectReq:
case SType::SelectRsp:
case SType::DeselectReq:
case SType::DeselectRsp:
case SType::LinktestReq:
case SType::LinktestRsp:
case SType::RejectReq:
case SType::SeparateReq:
handle_control(frame);
return;
case SType::Data:
return; // already handled above
}
const uint8_t bad_stype = static_cast<uint8_t>(frame.header.stype);
log("!! unsupported SType=" + std::to_string(bad_stype) +
"; emitting Reject.req");
send_frame(Frame(Header::control(
SType::RejectReq, frame.header.system_bytes, frame.header.session_id,
bad_stype,
static_cast<uint8_t>(RejectReason::StypeNotSupported))));
} }
void Connection::handle_data(const Frame& frame) { void Connection::handle_data(const Frame& frame) {
@@ -210,11 +249,19 @@ void Connection::handle_control(const Frame& frame) {
const Header& h = frame.header; const Header& h = frame.header;
switch (h.stype) { switch (h.stype) {
case SType::SelectReq: { case SType::SelectReq: {
log("<- Select.req"); // E37 §7.2: a Select.req while already SELECTED is answered with
// SelectStatus::AlreadyActive; we do NOT transition (we're already
// there) and we do NOT call the selected handler twice.
const auto status = (state_ == State::Selected)
? SelectStatus::AlreadyActive
: SelectStatus::Ok;
log(std::string("<- Select.req") +
(status == SelectStatus::AlreadyActive ? " (already SELECTED)" : ""));
send_frame(Frame(Header::control(SType::SelectRsp, h.system_bytes, h.session_id, 0, send_frame(Frame(Header::control(SType::SelectRsp, h.system_bytes, h.session_id, 0,
static_cast<uint8_t>(SelectStatus::Ok)))); static_cast<uint8_t>(status))));
log("-> Select.rsp (Ok)"); log(std::string("-> Select.rsp (") +
enter_selected(); (status == SelectStatus::Ok ? "Ok" : "AlreadyActive") + ")");
if (status == SelectStatus::Ok) enter_selected();
break; break;
} }
case SType::SelectRsp: { case SType::SelectRsp: {
@@ -231,11 +278,19 @@ void Connection::handle_control(const Frame& frame) {
break; break;
} }
case SType::DeselectReq: { case SType::DeselectReq: {
log("<- Deselect.req"); // E37 §7.4: Deselect.req while NOT_SELECTED returns NotEstablished
// and leaves state untouched.
const auto status = (state_ == State::Selected)
? DeselectStatus::Ok
: DeselectStatus::NotEstablished;
log(std::string("<- Deselect.req") +
(status == DeselectStatus::NotEstablished ? " (not SELECTED)" : ""));
send_frame(Frame(Header::control(SType::DeselectRsp, h.system_bytes, h.session_id, 0, send_frame(Frame(Header::control(SType::DeselectRsp, h.system_bytes, h.session_id, 0,
static_cast<uint8_t>(DeselectStatus::Ok)))); static_cast<uint8_t>(status))));
if (status == DeselectStatus::Ok) {
state_ = State::NotSelected; state_ = State::NotSelected;
arm_t7(); arm_t7();
}
break; break;
} }
case SType::DeselectRsp: { case SType::DeselectRsp: {
@@ -268,7 +323,7 @@ void Connection::handle_control(const Frame& frame) {
break; break;
} }
case SType::Data: case SType::Data:
break; // unreachable break; // unreachable: SType::Data is dispatched to handle_data
} }
} }
+4
View File
@@ -128,6 +128,10 @@ Item decode_at(const uint8_t* data, std::size_t len, std::size_t& pos) {
switch (fmt) { switch (fmt) {
case Format::ASCII: case Format::ASCII:
return Item::ascii(std::string(reinterpret_cast<const char*>(p), length)); return Item::ascii(std::string(reinterpret_cast<const char*>(p), length));
case Format::JIS8:
return Item::jis8(std::string(reinterpret_cast<const char*>(p), length));
case Format::C2:
return Item::c2(read_array<uint16_t>(p, length));
case Format::Binary: case Format::Binary:
return Item::binary(std::vector<uint8_t>(p, p + length)); return Item::binary(std::vector<uint8_t>(p, p + length));
case Format::Boolean: case Format::Boolean:
+181
View File
@@ -0,0 +1,181 @@
// Unit tests for the E30 §6.5 GEM Communication State Model.
//
// The state machine is pure logic — no Asio, no sockets — so we drive
// it directly: arm_t_cra() and arm_t_delay() are recorded into a
// `Recorder` struct and we call on_cra_timeout() / on_delay_elapsed()
// by hand to simulate the timers firing.
#include <doctest/doctest.h>
#include <chrono>
#include <vector>
#include "secsgem/gem/communication_state.hpp"
using namespace secsgem::gem;
namespace {
struct Recorder {
bool sent_s1f13 = false;
int cra_arms = 0;
int delay_arms = 0;
int cancels = 0;
std::vector<std::pair<CommState, CommState>> transitions;
};
CommEnvironment env_for(Recorder& r) {
CommEnvironment e;
e.arm_t_cra = [&r](std::chrono::milliseconds) { ++r.cra_arms; };
e.arm_t_delay = [&r](std::chrono::milliseconds) { ++r.delay_arms; };
e.cancel_timers = [&r]() { ++r.cancels; };
e.send_s1f13 = [&r]() { r.sent_s1f13 = true; };
return e;
}
CommStateChangeHandler tracker_for(Recorder& r) {
return [&r](CommState from, CommState to, const std::string&) {
r.transitions.emplace_back(from, to);
};
}
} // namespace
TEST_CASE("Initial state is DISABLED") {
CommunicationStateMachine sm;
CHECK(sm.state() == CommState::Disabled);
CHECK_FALSE(sm.communicating());
}
TEST_CASE("Equipment-initiated enable -> WAIT-CRA, fires S1F13, arms T_CRA") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.set_state_change_handler(tracker_for(r));
sm.enable();
CHECK(sm.state() == CommState::WaitCRA);
CHECK(r.sent_s1f13);
CHECK(r.cra_arms == 1);
CHECK(r.delay_arms == 0);
CHECK(r.transitions.size() == 1);
}
TEST_CASE("S1F14 with COMMACK=Accept -> COMMUNICATING") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_s1f14_received(0);
CHECK(sm.state() == CommState::Communicating);
CHECK(sm.communicating());
CHECK(r.cancels >= 1);
}
TEST_CASE("S1F14 with COMMACK=Denied -> WAIT-DELAY and arms T_DELAY") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_s1f14_received(1); // Denied
CHECK(sm.state() == CommState::WaitDelay);
CHECK(r.delay_arms == 1);
}
TEST_CASE("T_CRA timeout -> WAIT-DELAY") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_cra_timeout();
CHECK(sm.state() == CommState::WaitDelay);
CHECK(r.delay_arms == 1);
}
TEST_CASE("T_DELAY elapsed -> retry: back to WAIT-CRA and re-send S1F13") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_cra_timeout(); // -> WaitDelay
r.sent_s1f13 = false; // arm to detect the re-send
sm.on_delay_elapsed();
CHECK(sm.state() == CommState::WaitCRA);
CHECK(r.sent_s1f13); // S1F13 re-sent on retry
CHECK(r.cra_arms == 2); // T_CRA armed again
}
TEST_CASE("Disable from any state returns to DISABLED and cancels timers") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_s1f14_received(0); // COMMUNICATING
REQUIRE(sm.communicating());
sm.disable();
CHECK(sm.state() == CommState::Disabled);
CHECK(r.cancels >= 1);
}
TEST_CASE("Host-initiated mode: enable parks in WAIT-CRA without sending S1F13") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Host);
sm.set_environment(env_for(r));
sm.enable();
CHECK(sm.state() == CommState::WaitCRA);
CHECK_FALSE(r.sent_s1f13);
CHECK(r.cra_arms == 0); // no T_CRA from our side
}
TEST_CASE("Host-initiated: inbound S1F13 transitions us to COMMUNICATING") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Host);
sm.set_environment(env_for(r));
sm.enable();
sm.on_s1f13_received();
CHECK(sm.state() == CommState::Communicating);
}
TEST_CASE("Connection lost from COMMUNICATING returns to NOT-COMMUNICATING") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
sm.enable();
sm.on_s1f14_received(0); // -> COMMUNICATING
sm.on_connection_lost();
// Equipment-initiated falls into WAIT-DELAY (so it retries).
CHECK(sm.state() == CommState::WaitDelay);
CHECK(r.delay_arms == 1);
}
TEST_CASE("Spurious S1F14 outside WAIT-CRA is ignored") {
Recorder r;
CommunicationStateMachine sm({}, CommunicationStateMachine::Initiator::Equipment);
sm.set_environment(env_for(r));
// Disabled: still no transition.
sm.on_s1f14_received(0);
CHECK(sm.state() == CommState::Disabled);
sm.enable();
sm.on_s1f14_received(0); // -> COMMUNICATING
REQUIRE(sm.state() == CommState::Communicating);
// Already communicating: a second spurious S1F14 doesn't move us back.
sm.on_s1f14_received(0);
CHECK(sm.state() == CommState::Communicating);
}
TEST_CASE("State name strings cover all four states") {
CHECK(std::string(comm_state_name(CommState::Disabled)) == "DISABLED");
CHECK(std::string(comm_state_name(CommState::WaitCRA)) == "WAIT-CRA");
CHECK(std::string(comm_state_name(CommState::WaitDelay)) == "WAIT-DELAY");
CHECK(std::string(comm_state_name(CommState::Communicating)) == "COMMUNICATING");
}
+94
View File
@@ -0,0 +1,94 @@
#include <doctest/doctest.h>
#include <vector>
#include "secsgem/gem/control_job_state.hpp"
#include "secsgem/gem/store/control_jobs.hpp"
#include "secsgem/gem/store/process_jobs.hpp"
using namespace secsgem::gem;
TEST_CASE("CJ default initial state is Queued") {
ControlJobStateMachine cj;
CHECK(cj.state() == ControlJobState::Queued);
}
TEST_CASE("CJ full cascade: Queued -> Completed via Start path") {
ControlJobStateMachine cj;
CHECK(cj.on_internal(ControlJobEvent::Select));
CHECK(cj.state() == ControlJobState::Selected);
CHECK(cj.on_internal(ControlJobEvent::SetupComplete));
CHECK(cj.state() == ControlJobState::WaitingForStart);
CHECK(cj.on_host_command(ControlJobEvent::Start) == HostCmdAck::Accept);
CHECK(cj.state() == ControlJobState::Executing);
CHECK(cj.on_internal(ControlJobEvent::AllJobsComplete));
CHECK(cj.state() == ControlJobState::Completed);
}
TEST_CASE("CJ Pause/Resume only from Executing") {
ControlJobStateMachine cj(ControlJobTransitionTable::default_table(),
ControlJobState::Executing);
CHECK(cj.on_host_command(ControlJobEvent::Pause) == HostCmdAck::Accept);
CHECK(cj.state() == ControlJobState::Paused);
CHECK(cj.on_host_command(ControlJobEvent::Resume) == HostCmdAck::Accept);
CHECK(cj.state() == ControlJobState::Executing);
}
TEST_CASE("CJ Stop from Queued goes directly to Completed (empty CJ)") {
ControlJobStateMachine cj;
CHECK(cj.on_host_command(ControlJobEvent::Stop) == HostCmdAck::Accept);
CHECK(cj.state() == ControlJobState::Completed);
}
TEST_CASE("CJ Abort from Executing -> Aborting -> Completed") {
ControlJobStateMachine cj(ControlJobTransitionTable::default_table(),
ControlJobState::Executing);
CHECK(cj.on_host_command(ControlJobEvent::Abort) == HostCmdAck::Accept);
CHECK(cj.state() == ControlJobState::Aborting);
CHECK(cj.on_internal(ControlJobEvent::AbortComplete));
CHECK(cj.state() == ControlJobState::Completed);
}
TEST_CASE("CJ Start from Queued is illegal (must Select first)") {
ControlJobStateMachine cj;
CHECK(cj.on_host_command(ControlJobEvent::Start) == HostCmdAck::CannotDoNow);
CHECK(cj.state() == ControlJobState::Queued);
}
TEST_CASE("CTLJOBCMD wire-name mapping") {
CHECK(ctl_cmd_to_event("CJSTART").value() == ControlJobEvent::Start);
CHECK(ctl_cmd_to_event("CJPAUSE").value() == ControlJobEvent::Pause);
CHECK(ctl_cmd_to_event("STOP").value() == ControlJobEvent::Stop);
CHECK_FALSE(ctl_cmd_to_event("PJSTART").has_value());
}
TEST_CASE("CJStore: create rejects empty PJ list and unknown PJ") {
ProcessJobStore pjs;
pjs.create("PJ-1", "R", {});
ControlJobStore cjs;
auto pj_known = [&pjs](const std::string& id) { return pjs.has(id); };
CHECK(cjs.create("CJ-1", {}, pj_known) ==
ControlJobStore::CreateResult::Denied_Empty);
CHECK(cjs.create("CJ-1", {"GHOST"}, pj_known) ==
ControlJobStore::CreateResult::Denied_UnknownPRJob);
CHECK(cjs.create("CJ-1", {"PJ-1"}, pj_known) ==
ControlJobStore::CreateResult::Created);
CHECK(cjs.create("CJ-1", {"PJ-1"}, pj_known) ==
ControlJobStore::CreateResult::Denied_AlreadyExists);
}
TEST_CASE("CJStore: state-change handler observes synthetic Created event") {
ProcessJobStore pjs;
pjs.create("PJ-1", "R", {});
ControlJobStore cjs;
std::vector<std::tuple<std::string, ControlJobState, ControlJobState>> log;
cjs.set_state_change_handler(
[&log](const std::string& id, ControlJobState f, ControlJobState t,
ControlJobEvent) { log.emplace_back(id, f, t); });
cjs.create("CJ-1", {"PJ-1"});
REQUIRE(log.size() == 1);
CHECK(std::get<0>(log[0]) == "CJ-1");
CHECK(std::get<1>(log[0]) == ControlJobState::NoState);
CHECK(std::get<2>(log[0]) == ControlJobState::Queued);
}
+30
View File
@@ -87,3 +87,33 @@ TEST_CASE("frame decode rejects short payload") {
std::vector<uint8_t> tooShort(5, 0); std::vector<uint8_t> tooShort(5, 0);
CHECK_THROWS_AS(Frame::decode(tooShort.data(), tooShort.size()), FrameError); CHECK_THROWS_AS(Frame::decode(tooShort.data(), tooShort.size()), FrameError);
} }
TEST_CASE("E37 SType / status / reject-reason wire values") {
// SType (E37 §8.3). Defined set is {0,1,2,3,4,5,6,7,9}.
CHECK(static_cast<uint8_t>(SType::Data) == 0);
CHECK(static_cast<uint8_t>(SType::SelectReq) == 1);
CHECK(static_cast<uint8_t>(SType::SelectRsp) == 2);
CHECK(static_cast<uint8_t>(SType::DeselectReq) == 3);
CHECK(static_cast<uint8_t>(SType::DeselectRsp) == 4);
CHECK(static_cast<uint8_t>(SType::LinktestReq) == 5);
CHECK(static_cast<uint8_t>(SType::LinktestRsp) == 6);
CHECK(static_cast<uint8_t>(SType::RejectReq) == 7);
CHECK(static_cast<uint8_t>(SType::SeparateReq) == 9);
// Select.rsp status (E37 §7.2).
CHECK(static_cast<uint8_t>(SelectStatus::Ok) == 0);
CHECK(static_cast<uint8_t>(SelectStatus::AlreadyActive) == 1);
CHECK(static_cast<uint8_t>(SelectStatus::NotReady) == 2);
CHECK(static_cast<uint8_t>(SelectStatus::ConnectExhaust) == 3);
// Deselect.rsp status (E37 §7.4).
CHECK(static_cast<uint8_t>(DeselectStatus::Ok) == 0);
CHECK(static_cast<uint8_t>(DeselectStatus::NotEstablished) == 1);
CHECK(static_cast<uint8_t>(DeselectStatus::Busy) == 2);
// Reject reason (E37 §7.7).
CHECK(static_cast<uint8_t>(RejectReason::StypeNotSupported) == 1);
CHECK(static_cast<uint8_t>(RejectReason::PtypeNotSupported) == 2);
CHECK(static_cast<uint8_t>(RejectReason::TransactionNotOpen) == 3);
CHECK(static_cast<uint8_t>(RejectReason::EntityNotSelected) == 4);
}
+231
View File
@@ -0,0 +1,231 @@
// Integration tests for the HSMS Connection state machine.
//
// We open a pair of connected TCP sockets on loopback, wrap one in a real
// passive Connection (the system under test), and use the other as a raw
// "peer" socket that hand-builds wire frames. This lets us assert exactly
// what bytes the Connection produces in response to specific stimuli — in
// particular the E37 §7.2 / §7.4 / §7.7 corner cases that aren't exercised
// by the happy-path demo: SelectReq while already SELECTED, DeselectReq
// while NOT_SELECTED, and Reject.req emission for unsupported SType / PType.
//
// Both ends share the same io_context, so all socket I/O on the "peer"
// side has to be async too — running an asio::read synchronously on the
// peer would block the thread that also has to drive the Connection's
// own async reads, deadlocking the test.
#include <doctest/doctest.h>
#include <asio.hpp>
#include <array>
#include <chrono>
#include <cstdint>
#include <memory>
#include <thread>
#include <utility>
#include <vector>
#include "secsgem/hsms/connection.hpp"
#include "secsgem/hsms/header.hpp"
using namespace secsgem::hsms;
namespace {
// Pair of TCP sockets connected over loopback; both ends share `io`.
struct SocketPair {
asio::io_context io;
asio::ip::tcp::socket a{io}; // the "system under test" side
asio::ip::tcp::socket b{io}; // the "raw peer" side
SocketPair() {
asio::ip::tcp::acceptor acc(io, asio::ip::tcp::endpoint(
asio::ip::address_v4::loopback(), 0));
const auto port = acc.local_endpoint().port();
std::error_code ec_accept;
bool accepted = false;
acc.async_accept(a, [&](std::error_code ec) { ec_accept = ec; accepted = true; });
std::error_code ec_connect;
bool connected = false;
b.async_connect(asio::ip::tcp::endpoint(asio::ip::address_v4::loopback(), port),
[&](std::error_code ec) { ec_connect = ec; connected = true; });
while (!(accepted && connected)) {
if (io.stopped()) io.restart();
if (io.poll() == 0) std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
REQUIRE_FALSE(ec_accept);
REQUIRE_FALSE(ec_connect);
}
};
// Run the io_context until `pred()` returns true or `budget` is exhausted.
// We drain all currently-ready handlers with poll(), then sleep briefly
// before re-checking — run_one_for() can block for its full timeout even
// when ready work exists, which made earlier iterations of this helper
// look hung.
template <typename Pred>
void pump_until(asio::io_context& io, Pred pred,
std::chrono::milliseconds budget = std::chrono::seconds(5)) {
const auto deadline = std::chrono::steady_clock::now() + budget;
while (!pred()) {
if (std::chrono::steady_clock::now() > deadline) FAIL("pump_until budget exceeded");
if (io.stopped()) io.restart();
const std::size_t n = io.poll();
if (n == 0) std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
// Async write a buffer; pumps the io_context until it completes.
void send_bytes(SocketPair& sp, std::vector<uint8_t> bytes) {
auto buf = std::make_shared<std::vector<uint8_t>>(std::move(bytes));
bool done = false;
asio::async_write(sp.b, asio::buffer(*buf),
[buf, &done](std::error_code ec, std::size_t) {
REQUIRE_FALSE(ec);
done = true;
});
pump_until(sp.io, [&] { return done; });
}
// Async read one full HSMS frame from the peer socket; pumps the io_context.
Frame recv_frame(SocketPair& sp) {
auto lenbuf = std::make_shared<std::array<uint8_t, 4>>();
bool len_done = false;
asio::async_read(sp.b, asio::buffer(*lenbuf),
[lenbuf, &len_done](std::error_code ec, std::size_t) {
REQUIRE_FALSE(ec);
len_done = true;
});
pump_until(sp.io, [&] { return len_done; });
const uint32_t len = (uint32_t((*lenbuf)[0]) << 24) | (uint32_t((*lenbuf)[1]) << 16) |
(uint32_t((*lenbuf)[2]) << 8) | uint32_t((*lenbuf)[3]);
auto payload = std::make_shared<std::vector<uint8_t>>(len);
bool payload_done = false;
asio::async_read(sp.b, asio::buffer(*payload),
[payload, &payload_done](std::error_code ec, std::size_t) {
REQUIRE_FALSE(ec);
payload_done = true;
});
pump_until(sp.io, [&] { return payload_done; });
return Frame::decode(payload->data(), payload->size());
}
Timers default_timers() {
Timers t;
t.linktest = std::chrono::milliseconds(0); // disabled in tests
return t;
}
} // namespace
TEST_CASE("Select.req while already SELECTED returns AlreadyActive (E37 §7.2)") {
SocketPair sp;
auto conn = std::make_shared<Connection>(std::move(sp.a), Connection::Mode::Passive,
/*device_id=*/0, default_timers());
bool selected = false;
conn->set_selected_handler([&] { selected = true; });
conn->start();
// First Select.req: should be answered with Ok (status=0) and transition
// the connection into SELECTED.
send_bytes(sp, Frame(Header::control(SType::SelectReq, /*sys=*/1)).encode());
Frame rsp1 = recv_frame(sp);
CHECK(rsp1.header.stype == SType::SelectRsp);
CHECK(rsp1.header.byte3 == static_cast<uint8_t>(SelectStatus::Ok));
pump_until(sp.io, [&] { return selected; });
// Second Select.req while already SELECTED: must reply AlreadyActive (1)
// and must NOT re-fire the selected handler.
selected = false;
send_bytes(sp, Frame(Header::control(SType::SelectReq, /*sys=*/2)).encode());
Frame rsp2 = recv_frame(sp);
CHECK(rsp2.header.stype == SType::SelectRsp);
CHECK(rsp2.header.byte3 == static_cast<uint8_t>(SelectStatus::AlreadyActive));
CHECK_FALSE(selected);
conn->close("test done");
}
TEST_CASE("Deselect.req while NOT_SELECTED returns NotEstablished (E37 §7.4)") {
SocketPair sp;
auto conn = std::make_shared<Connection>(std::move(sp.a), Connection::Mode::Passive,
/*device_id=*/0, default_timers());
conn->start();
send_bytes(sp, Frame(Header::control(SType::DeselectReq, /*sys=*/1)).encode());
Frame rsp = recv_frame(sp);
CHECK(rsp.header.stype == SType::DeselectRsp);
CHECK(rsp.header.byte3 == static_cast<uint8_t>(DeselectStatus::NotEstablished));
CHECK(conn->state() == Connection::State::NotSelected);
conn->close("test done");
}
TEST_CASE("Unsupported SType triggers Reject.req(StypeNotSupported) (E37 §7.7)") {
SocketPair sp;
auto conn = std::make_shared<Connection>(std::move(sp.a), Connection::Mode::Passive,
/*device_id=*/0, default_timers());
conn->start();
// Standalone Header doesn't let us produce an unknown SType via the
// constructors, so we build the wire bytes by hand.
std::vector<uint8_t> bad = {0x00, 0x00, 0x00, 0x0A,
// header: session=0xFFFF, byte2=0, byte3=0,
// ptype=0, stype=10, sys=5
0xFF, 0xFF, 0x00, 0x00, 0x00, 0x0A,
0x00, 0x00, 0x00, 0x05};
send_bytes(sp, std::move(bad));
Frame rej = recv_frame(sp);
CHECK(rej.header.stype == SType::RejectReq);
CHECK(rej.header.system_bytes == 5);
CHECK(rej.header.byte2 == 10); // offending SType echoed in byte2
CHECK(rej.header.byte3 == static_cast<uint8_t>(RejectReason::StypeNotSupported));
conn->close("test done");
}
TEST_CASE("Non-zero PType triggers Reject.req(PtypeNotSupported) (E37 §7.7)") {
SocketPair sp;
auto conn = std::make_shared<Connection>(std::move(sp.a), Connection::Mode::Passive,
/*device_id=*/0, default_timers());
conn->start();
// Linktest.req-like frame but with PType=7 (unsupported).
std::vector<uint8_t> bad = {0x00, 0x00, 0x00, 0x0A,
0xFF, 0xFF, 0x00, 0x00,
/*ptype=*/0x07, /*stype=LinktestReq*/0x05,
0x00, 0x00, 0x00, 0x09};
send_bytes(sp, std::move(bad));
Frame rej = recv_frame(sp);
CHECK(rej.header.stype == SType::RejectReq);
CHECK(rej.header.system_bytes == 9);
CHECK(rej.header.byte2 == 7); // offending PType echoed in byte2
CHECK(rej.header.byte3 == static_cast<uint8_t>(RejectReason::PtypeNotSupported));
conn->close("test done");
}
TEST_CASE("Data frame while NOT_SELECTED triggers Reject.req(EntityNotSelected)") {
SocketPair sp;
auto conn = std::make_shared<Connection>(std::move(sp.a), Connection::Mode::Passive,
/*device_id=*/0, default_timers());
conn->start();
// Primary S1F1 W=1 before selecting — equipment must Reject with
// EntityNotSelected. Construct via the header API: Data with an empty
// body is a valid wire frame.
Frame data(Header::data_message(/*session=*/1, /*stream=*/1, /*function=*/1,
/*reply_expected=*/true, /*sys=*/42));
send_bytes(sp, data.encode());
Frame rej = recv_frame(sp);
CHECK(rej.header.stype == SType::RejectReq);
CHECK(rej.header.byte3 == static_cast<uint8_t>(RejectReason::EntityNotSelected));
conn->close("test done");
}
+47 -1
View File
@@ -47,9 +47,11 @@ TEST_CASE("equipment.yaml populates SVIDs, ECIDs, CEIDs, alarms, recipes, comman
CHECK(m.ecids.all().size() == 2); CHECK(m.ecids.all().size() == 2);
CHECK(m.ecids.get(10)->value == s2::Item::u4(uint32_t{1})); CHECK(m.ecids.get(10)->value == s2::Item::u4(uint32_t{1}));
CHECK(m.events.all_events().size() == 3); CHECK(m.events.all_events().size() >= 3);
CHECK(m.events.has_event(100)); CHECK(m.events.has_event(100));
CHECK(m.events.has_event(300)); CHECK(m.events.has_event(300));
CHECK(m.events.has_event(400)); // E94 CJ Executing
CHECK(m.events.has_event(401)); // E94 CJ Completed
CHECK(m.alarms.all().size() == 2); CHECK(m.alarms.all().size() == 2);
CHECK(m.alarms.get(1)->text == "Chiller Temp High"); CHECK(m.alarms.get(1)->text == "Chiller Temp High");
@@ -98,3 +100,47 @@ TEST_CASE("loader surfaces YAML errors with file path") {
CHECK_THROWS_AS(config::load_equipment("/tmp/does-not-exist.yaml", *new gem::EquipmentDataModel), CHECK_THROWS_AS(config::load_equipment("/tmp/does-not-exist.yaml", *new gem::EquipmentDataModel),
config::ConfigError); config::ConfigError);
} }
TEST_CASE("loader: process_job_state.yaml -> non-empty table") {
auto cfg = config::load_process_job_state(
std::string(SECSGEM_DATA_DIR) + "/process_job_state.yaml");
CHECK(cfg.initial == gem::ProcessJobState::Queued);
CHECK(cfg.table.size() >= 18);
// Spot-check: Queued + Select -> SettingUp.
const auto* row = cfg.table.find(gem::ProcessJobState::Queued,
gem::ProcessJobEvent::Select);
REQUIRE(row != nullptr);
REQUIRE(row->to.has_value());
CHECK(*row->to == gem::ProcessJobState::SettingUp);
}
TEST_CASE("loader: NoState rejected in PJ state table") {
const std::string path = "/tmp/pj_nostate.yaml";
std::ofstream(path) << "initial: NoState\ntransitions: []\n";
CHECK_THROWS_AS(config::load_process_job_state(path), config::ConfigError);
std::ofstream(path) << "initial: Queued\ntransitions:\n"
<< " - {from: NoState, on: select, to: SettingUp}\n";
CHECK_THROWS_AS(config::load_process_job_state(path), config::ConfigError);
std::ofstream(path) << "initial: Queued\ntransitions:\n"
<< " - {from: Queued, on: select, to: NoState}\n";
CHECK_THROWS_AS(config::load_process_job_state(path), config::ConfigError);
// `created` is the synthetic observer event — never legal in the table.
std::ofstream(path) << "initial: Queued\ntransitions:\n"
<< " - {from: Queued, on: created, to: SettingUp}\n";
CHECK_THROWS_AS(config::load_process_job_state(path), config::ConfigError);
}
TEST_CASE("loader: control_job_state.yaml -> non-empty table") {
auto cfg = config::load_control_job_state(
std::string(SECSGEM_DATA_DIR) + "/control_job_state.yaml");
CHECK(cfg.initial == gem::ControlJobState::Queued);
CHECK(cfg.table.size() >= 18);
const auto* row = cfg.table.find(gem::ControlJobState::Executing,
gem::ControlJobEvent::AllJobsComplete);
REQUIRE(row != nullptr);
REQUIRE(row->to.has_value());
CHECK(*row->to == gem::ControlJobState::Completed);
}
+180
View File
@@ -61,6 +61,67 @@ TEST_CASE("S2F16 EAC ack round-trip") {
CHECK(*byte == static_cast<uint8_t>(EquipmentAck::Denied_OutOfRange)); CHECK(*byte == static_cast<uint8_t>(EquipmentAck::Denied_OutOfRange));
} }
TEST_CASE("S2F16 EAC enum matches SEMI E5 wire values") {
// Pin the spec values so an inadvertent re-numbering breaks the test
// instead of breaking interoperability with conformant hosts.
CHECK(static_cast<uint8_t>(EquipmentAck::Accept) == 0);
CHECK(static_cast<uint8_t>(EquipmentAck::Denied_UnknownEcid) == 1);
CHECK(static_cast<uint8_t>(EquipmentAck::Denied_Busy) == 2);
CHECK(static_cast<uint8_t>(EquipmentAck::Denied_OutOfRange) == 3);
}
TEST_CASE("S2F34 DRACK enum matches SEMI E5 wire values") {
CHECK(static_cast<uint8_t>(DefineReportAck::Accept) == 0);
CHECK(static_cast<uint8_t>(DefineReportAck::InsufficientSpace) == 1);
CHECK(static_cast<uint8_t>(DefineReportAck::InvalidFormat) == 2);
CHECK(static_cast<uint8_t>(DefineReportAck::RptidAlreadyDefined) == 3);
CHECK(static_cast<uint8_t>(DefineReportAck::InvalidVid) == 4);
}
TEST_CASE("S2F36 LRACK enum matches SEMI E5 wire values") {
CHECK(static_cast<uint8_t>(LinkEventAck::Accept) == 0);
CHECK(static_cast<uint8_t>(LinkEventAck::InsufficientSpace) == 1);
CHECK(static_cast<uint8_t>(LinkEventAck::InvalidFormat) == 2);
CHECK(static_cast<uint8_t>(LinkEventAck::UnknownCeid) == 3);
CHECK(static_cast<uint8_t>(LinkEventAck::UnknownRptid) == 4);
CHECK(static_cast<uint8_t>(LinkEventAck::CeidAlreadyLinked) == 5);
}
TEST_CASE("S2F42 HCACK enum matches SEMI E5 wire values") {
CHECK(static_cast<uint8_t>(HostCmdAck::Accept) == 0);
CHECK(static_cast<uint8_t>(HostCmdAck::InvalidCommand) == 1);
CHECK(static_cast<uint8_t>(HostCmdAck::CannotDoNow) == 2);
CHECK(static_cast<uint8_t>(HostCmdAck::ParameterInvalid) == 3);
CHECK(static_cast<uint8_t>(HostCmdAck::AcceptedWillFinishLater) == 4);
CHECK(static_cast<uint8_t>(HostCmdAck::Rejected) == 5);
CHECK(static_cast<uint8_t>(HostCmdAck::InvalidObject) == 6);
}
TEST_CASE("S7F4 ACKC7 enum matches SEMI E5 wire values") {
CHECK(static_cast<uint8_t>(ProcessProgramAck::Accept) == 0);
CHECK(static_cast<uint8_t>(ProcessProgramAck::PermissionNotGranted) == 1);
CHECK(static_cast<uint8_t>(ProcessProgramAck::LengthError) == 2);
CHECK(static_cast<uint8_t>(ProcessProgramAck::MatrixOverflow) == 3);
CHECK(static_cast<uint8_t>(ProcessProgramAck::PpidNotFound) == 4);
CHECK(static_cast<uint8_t>(ProcessProgramAck::ModeUnsupported) == 5);
CHECK(static_cast<uint8_t>(ProcessProgramAck::PerformanceError) == 6);
}
TEST_CASE("S10F2/F4/F6 ACKC10 enum matches SEMI E5 wire values") {
CHECK(static_cast<uint8_t>(TerminalAck::Accepted) == 0);
CHECK(static_cast<uint8_t>(TerminalAck::WillNotDisplay) == 1);
CHECK(static_cast<uint8_t>(TerminalAck::TerminalNotAvailable) == 2);
}
TEST_CASE("S1F18 ONLACK / S1F16 OFLACK / S1F14 COMMACK enum wire values") {
CHECK(static_cast<uint8_t>(OnlineAck::Accept) == 0);
CHECK(static_cast<uint8_t>(OnlineAck::NotAccept) == 1);
CHECK(static_cast<uint8_t>(OnlineAck::AlreadyOnline) == 2);
CHECK(static_cast<uint8_t>(OfflineAck::Accept) == 0);
CHECK(static_cast<uint8_t>(CommAck::Accept) == 0);
CHECK(static_cast<uint8_t>(CommAck::Denied) == 1);
}
TEST_CASE("S2F18 carries 16-char time string") { TEST_CASE("S2F18 carries 16-char time string") {
auto m = s2f18_date_time_data("2026052812345678"); auto m = s2f18_date_time_data("2026052812345678");
auto t = parse_s2f18(m); auto t = parse_s2f18(m);
@@ -392,6 +453,51 @@ TEST_CASE("S7F3 process-program send round-trip") {
CHECK(parsed->ppbody == "step1\nstep2\n"); CHECK(parsed->ppbody == "step1\nstep2\n");
} }
TEST_CASE("S2F25 / S2F26 loopback diagnostic round-trip") {
// Arbitrary binary payload — host sends, equipment echoes back.
const std::string payload("\x00\x01\x02\xFE\xFF some text", 14);
auto req = s2f25_loopback_diagnostic_request(payload);
CHECK(req.stream == 2);
CHECK(req.function == 25);
CHECK(req.reply_expected);
auto parsed_req = parse_s2f25(req);
REQUIRE(parsed_req.has_value());
CHECK(*parsed_req == payload);
auto rsp = s2f26_loopback_diagnostic_data(payload);
auto parsed_rsp = parse_s2f26(rsp);
REQUIRE(parsed_rsp.has_value());
CHECK(*parsed_rsp == payload);
}
TEST_CASE("S5F9 / S5F10 exception post round-trip") {
std::vector<std::string> recovery = {"RETRY", "ABORT", "MANUAL_INTERVENTION"};
auto m = s5f9_exception_post_notify(42, "FATAL", "vacuum lost", recovery);
CHECK(m.stream == 5);
CHECK(m.function == 9);
CHECK(m.reply_expected);
auto parsed = parse_s5f9(m);
REQUIRE(parsed.has_value());
CHECK(parsed->exid == 42);
CHECK(parsed->extype == "FATAL");
CHECK(parsed->exmessage == "vacuum lost");
CHECK(parsed->exrecvra == recovery);
CHECK(*ack_byte(s5f10_exception_post_confirm(AlarmAck::Accept)) == 0);
}
TEST_CASE("S5F11 / S5F12 exception clear round-trip") {
auto m = s5f11_exception_clear_notify(42, "FATAL", "vacuum restored");
auto parsed = parse_s5f11(m);
REQUIRE(parsed.has_value());
CHECK(parsed->exid == 42);
CHECK(parsed->extype == "FATAL");
CHECK(parsed->exmessage == "vacuum restored");
CHECK(*ack_byte(s5f12_exception_clear_confirm(AlarmAck::Accept)) == 0);
}
TEST_CASE("S7F19 / S7F20 EPPD list") { TEST_CASE("S7F19 / S7F20 EPPD list") {
auto req = s7f19_current_eppd_request(); auto req = s7f19_current_eppd_request();
CHECK(req.stream == 7); CHECK(req.stream == 7);
@@ -404,3 +510,77 @@ TEST_CASE("S7F19 / S7F20 EPPD list") {
REQUIRE(parsed.has_value()); REQUIRE(parsed.has_value());
CHECK(*parsed == std::vector<std::string>{"RECIPE-A", "RECIPE-B"}); CHECK(*parsed == std::vector<std::string>{"RECIPE-A", "RECIPE-B"});
} }
TEST_CASE("S14F9 / S14F10 CreateControlJob round-trip") {
auto req = s14f9_create_control_job("CJ-1", {"PJ-1", "PJ-2"});
CHECK(req.stream == 14);
CHECK(req.function == 9);
CHECK(req.reply_expected);
auto parsed = parse_s14f9(req);
REQUIRE(parsed.has_value());
CHECK(parsed->ctljobid == "CJ-1");
CHECK(parsed->prjobids == std::vector<std::string>{"PJ-1", "PJ-2"});
auto ack = s14f10_create_control_job_ack("CJ-1", ObjectAck::Success);
auto parsed_ack = parse_s14f10(ack);
REQUIRE(parsed_ack.has_value());
CHECK(parsed_ack->ctljobid == "CJ-1");
CHECK(parsed_ack->ack == ObjectAck::Success);
}
TEST_CASE("S14F11 / S14F12 DeleteControlJob round-trip") {
auto req = s14f11_delete_control_job("CJ-1");
auto parsed = parse_s14f11(req);
REQUIRE(parsed.has_value());
CHECK(*parsed == "CJ-1");
auto ack = s14f12_delete_control_job_ack(ObjectAck::Denied_UnknownObject);
CHECK(*ack_byte(ack) == 2);
}
TEST_CASE("S16F11 / S16F12 PRJobCreate round-trip") {
auto req = s16f11_pr_job_create("PJ-1", "RECIPE-A", {"WFR-1", "WFR-2"});
CHECK(req.stream == 16);
CHECK(req.function == 11);
auto parsed = parse_s16f11(req);
REQUIRE(parsed.has_value());
CHECK(parsed->prjobid == "PJ-1");
CHECK(parsed->ppid == "RECIPE-A");
CHECK(parsed->mtrloutspec == std::vector<std::string>{"WFR-1", "WFR-2"});
CHECK(*ack_byte(s16f12_pr_job_create_ack(HostCmdAck::Accept)) == 0);
}
TEST_CASE("S16F13 / S16F14 PRJobDequeue round-trip") {
auto req = s16f13_pr_job_dequeue("PJ-1");
CHECK(*parse_s16f13(req) == "PJ-1");
CHECK(*ack_byte(s16f14_pr_job_dequeue_ack(HostCmdAck::CannotDoNow)) == 2);
}
TEST_CASE("S16F5 / S16F6 PRJobCommand round-trip") {
auto req = s16f5_pr_job_command("PJ-1", "PJSTART");
auto parsed = parse_s16f5(req);
REQUIRE(parsed.has_value());
CHECK(parsed->prjobid == "PJ-1");
CHECK(parsed->prcmd == "PJSTART");
CHECK(*ack_byte(s16f6_pr_job_command_ack(HostCmdAck::Accept)) == 0);
}
TEST_CASE("S16F9 PRJobAlert round-trip with typed state byte") {
auto m = s16f9_pr_job_alert("PJ-1", ProcessJobState::Processing);
auto parsed = parse_s16f9(m);
REQUIRE(parsed.has_value());
CHECK(parsed->prjobid == "PJ-1");
CHECK(parsed->prjobstate == ProcessJobState::Processing);
}
TEST_CASE("S16F27 / S16F28 CJobCommand round-trip") {
auto req = s16f27_cj_command("CJ-1", "CJSTART");
auto parsed = parse_s16f27(req);
REQUIRE(parsed.has_value());
CHECK(parsed->ctljobid == "CJ-1");
CHECK(parsed->ctljobcmd == "CJSTART");
CHECK(*ack_byte(s16f28_cj_command_ack(HostCmdAck::Accept)) == 0);
}
+223
View File
@@ -0,0 +1,223 @@
#include <doctest/doctest.h>
#include <vector>
#include "secsgem/gem/process_job_state.hpp"
#include "secsgem/gem/store/process_jobs.hpp"
using namespace secsgem::gem;
namespace {
struct Recorder {
std::vector<std::tuple<ProcessJobState, ProcessJobState, ProcessJobEvent>> changes;
ProcessJobStateMachine::StateChangeHandler handler() {
return [this](ProcessJobState from, ProcessJobState to, ProcessJobEvent ev) {
changes.emplace_back(from, to, ev);
};
}
};
} // namespace
TEST_CASE("PJ default initial state is Queued") {
ProcessJobStateMachine pj;
CHECK(pj.state() == ProcessJobState::Queued);
}
TEST_CASE("PJ Queued -> SettingUp on Select (internal)") {
ProcessJobStateMachine pj;
Recorder rec;
pj.set_state_change_handler(rec.handler());
CHECK(pj.on_internal(ProcessJobEvent::Select));
CHECK(pj.state() == ProcessJobState::SettingUp);
REQUIRE(rec.changes.size() == 1);
CHECK(std::get<1>(rec.changes[0]) == ProcessJobState::SettingUp);
}
TEST_CASE("PJ full happy-path cascade") {
ProcessJobStateMachine pj;
CHECK(pj.on_internal(ProcessJobEvent::Select)); // -> SettingUp
CHECK(pj.on_internal(ProcessJobEvent::SetupComplete)); // -> WaitingForStart
CHECK(pj.on_host_command(ProcessJobEvent::Start) == HostCmdAck::Accept);
CHECK(pj.state() == ProcessJobState::Processing);
CHECK(pj.on_internal(ProcessJobEvent::ProcessComplete));
CHECK(pj.state() == ProcessJobState::ProcessComplete);
}
TEST_CASE("PJ Start while Queued rejected (CannotDoNow)") {
ProcessJobStateMachine pj;
CHECK(pj.on_host_command(ProcessJobEvent::Start) == HostCmdAck::CannotDoNow);
CHECK(pj.state() == ProcessJobState::Queued);
}
TEST_CASE("PJ Pause/Resume preserves Processing context") {
ProcessJobStateMachine pj;
pj.on_internal(ProcessJobEvent::Select);
pj.on_internal(ProcessJobEvent::SetupComplete);
pj.on_host_command(ProcessJobEvent::Start);
REQUIRE(pj.state() == ProcessJobState::Processing);
CHECK(pj.on_host_command(ProcessJobEvent::Pause) == HostCmdAck::Accept);
CHECK(pj.state() == ProcessJobState::Paused);
CHECK(pj.on_host_command(ProcessJobEvent::Resume) == HostCmdAck::Accept);
CHECK(pj.state() == ProcessJobState::Processing);
}
TEST_CASE("PJ Abort from any non-terminal state -> Aborting") {
// Includes Queued: per E40-0705 §6.3 a Queued PJ stops/aborts via the
// Aborting state (not direct-to-ProcessComplete), so the host sees a
// PRJOBSTATE=7 alert on the wire.
for (auto initial : {ProcessJobState::Queued, ProcessJobState::SettingUp,
ProcessJobState::WaitingForStart,
ProcessJobState::Processing, ProcessJobState::Paused,
ProcessJobState::Stopping}) {
ProcessJobStateMachine pj(ProcessJobTransitionTable::default_table(), initial);
CHECK(pj.on_host_command(ProcessJobEvent::Abort) == HostCmdAck::Accept);
CHECK(pj.state() == ProcessJobState::Aborting);
CHECK(pj.on_internal(ProcessJobEvent::AbortComplete));
CHECK(pj.state() == ProcessJobState::ProcessComplete);
}
}
TEST_CASE("PJ Stop on Queued routes through Aborting") {
ProcessJobStateMachine pj;
REQUIRE(pj.state() == ProcessJobState::Queued);
CHECK(pj.on_host_command(ProcessJobEvent::Stop) == HostCmdAck::Accept);
CHECK(pj.state() == ProcessJobState::Aborting);
CHECK(pj.on_internal(ProcessJobEvent::AbortComplete));
CHECK(pj.state() == ProcessJobState::ProcessComplete);
}
TEST_CASE("PJ HeadOfQueue is reorder-only — same state, ack Accept") {
ProcessJobStateMachine pj;
CHECK(pj.on_host_command(ProcessJobEvent::HeadOfQueue) == HostCmdAck::Accept);
CHECK(pj.state() == ProcessJobState::Queued);
}
TEST_CASE("PJ ProcessComplete is terminal — further commands rejected") {
ProcessJobStateMachine pj(ProcessJobTransitionTable::default_table(),
ProcessJobState::ProcessComplete);
CHECK(pj.on_host_command(ProcessJobEvent::Start) == HostCmdAck::CannotDoNow);
CHECK(pj.on_host_command(ProcessJobEvent::Abort) == HostCmdAck::CannotDoNow);
CHECK(pj.state() == ProcessJobState::ProcessComplete);
}
TEST_CASE("PRCMD wire-name mapping") {
CHECK(pr_cmd_to_event("PJSTART").value() == ProcessJobEvent::Start);
CHECK(pr_cmd_to_event("PJSTOP").value() == ProcessJobEvent::Stop);
CHECK(pr_cmd_to_event("PAUSE").value() == ProcessJobEvent::Pause);
CHECK(pr_cmd_to_event("PJABORT").value() == ProcessJobEvent::Abort);
CHECK(pr_cmd_to_event("PJHOQ").value() == ProcessJobEvent::HeadOfQueue);
CHECK_FALSE(pr_cmd_to_event("DELETE").has_value());
}
TEST_CASE("Store: create rejects duplicate PRJOBID") {
ProcessJobStore store;
CHECK(store.create("PJ-1", "RECIPE", {"W1"}) ==
ProcessJobStore::CreateResult::Created);
CHECK(store.create("PJ-1", "RECIPE", {"W1"}) ==
ProcessJobStore::CreateResult::Denied_AlreadyExists);
}
TEST_CASE("Store: create rejects unknown PPID via validator") {
ProcessJobStore store;
auto known = [](const std::string& ppid) { return ppid == "RECIPE-A"; };
CHECK(store.create("PJ-1", "BAD", {}, known) ==
ProcessJobStore::CreateResult::Denied_InvalidPpid);
CHECK(store.create("PJ-1", "RECIPE-A", {}, known) ==
ProcessJobStore::CreateResult::Created);
}
TEST_CASE("Store: dequeue only legal while Queued") {
ProcessJobStore store;
store.create("PJ-1", "R", {});
CHECK(store.dequeue("PJ-1") == HostCmdAck::Accept);
CHECK(store.has("PJ-1") == false);
store.create("PJ-2", "R", {});
store.fire_internal("PJ-2", ProcessJobEvent::Select); // -> SettingUp
CHECK(store.dequeue("PJ-2") == HostCmdAck::CannotDoNow);
CHECK(store.has("PJ-2") == true);
}
TEST_CASE("Store: state-change handler observes synthetic create event") {
ProcessJobStore store;
std::vector<std::tuple<std::string, ProcessJobState, ProcessJobState>> log;
store.set_state_change_handler(
[&log](const std::string& id, ProcessJobState f, ProcessJobState t,
ProcessJobEvent) { log.emplace_back(id, f, t); });
store.create("PJ-1", "R", {});
REQUIRE(log.size() == 1);
CHECK(std::get<0>(log[0]) == "PJ-1");
CHECK(std::get<1>(log[0]) == ProcessJobState::NoState);
CHECK(std::get<2>(log[0]) == ProcessJobState::Queued);
}
TEST_CASE("Store: host command on unknown PJ returns InvalidObject") {
ProcessJobStore store;
CHECK(store.on_host_command("ghost", ProcessJobEvent::Start) ==
HostCmdAck::InvalidObject);
}
TEST_CASE("Store: ids() preserves insertion order") {
ProcessJobStore store;
store.create("PJ-B", "R", {});
store.create("PJ-A", "R", {});
store.create("PJ-C", "R", {});
REQUIRE(store.ids().size() == 3);
CHECK(store.ids()[0] == "PJ-B");
CHECK(store.ids()[1] == "PJ-A");
CHECK(store.ids()[2] == "PJ-C");
}
TEST_CASE("Store: HOQ moves Queued PJ to head of queue") {
ProcessJobStore store;
store.create("PJ-1", "R", {});
store.create("PJ-2", "R", {});
store.create("PJ-3", "R", {});
REQUIRE(store.position("PJ-3") == 2);
CHECK(store.on_host_command("PJ-3", ProcessJobEvent::HeadOfQueue) ==
HostCmdAck::Accept);
CHECK(store.position("PJ-3") == 0);
CHECK(store.position("PJ-1") == 1);
CHECK(store.position("PJ-2") == 2);
}
TEST_CASE("Store: HOQ on the head is a no-op Accept") {
ProcessJobStore store;
store.create("PJ-1", "R", {});
store.create("PJ-2", "R", {});
CHECK(store.on_host_command("PJ-1", ProcessJobEvent::HeadOfQueue) ==
HostCmdAck::Accept);
CHECK(store.position("PJ-1") == 0);
}
TEST_CASE("Store: HOQ rejected for non-Queued PJ") {
ProcessJobStore store;
store.create("PJ-1", "R", {});
store.create("PJ-2", "R", {});
store.fire_internal("PJ-2", ProcessJobEvent::Select); // -> SettingUp
CHECK(store.on_host_command("PJ-2", ProcessJobEvent::HeadOfQueue) ==
HostCmdAck::CannotDoNow);
CHECK(store.position("PJ-2") == 1); // order unchanged
}
TEST_CASE("Store: dequeue removes PJ from order vector") {
ProcessJobStore store;
store.create("PJ-1", "R", {});
store.create("PJ-2", "R", {});
CHECK(store.dequeue("PJ-1") == HostCmdAck::Accept);
REQUIRE(store.ids().size() == 1);
CHECK(store.ids()[0] == "PJ-2");
CHECK(store.position("PJ-1") == -1);
}
TEST_CASE("Store: set_alert toggles per-PJ alert flag") {
ProcessJobStore store;
store.create("PJ-1", "R", {});
REQUIRE(store.get("PJ-1")->alert_enabled);
CHECK(store.set_alert("PJ-1", false));
CHECK(store.get("PJ-1")->alert_enabled == false);
CHECK_FALSE(store.set_alert("ghost", false));
}
+36
View File
@@ -97,3 +97,39 @@ TEST_CASE("SML rendering") {
Item body = Item::list({Item::ascii("MDLN"), Item::u4(uint32_t{42})}); Item body = Item::list({Item::ascii("MDLN"), Item::u4(uint32_t{42})});
CHECK(to_sml(body) == "<L [2] <A \"MDLN\" > <U4 42 > >"); CHECK(to_sml(body) == "<L [2] <A \"MDLN\" > <U4 42 > >");
} }
TEST_CASE("JIS-8 encode/decode (E5 §9.5)") {
// Format byte for JIS-8 = 0x11 << 2 | 0x01 = 0x45 with 1-byte length.
// 3 bytes payload "abc" (we don't bother with real JIS chars; the wire
// format is byte-identical to ASCII, only the format code differs).
Item j = Item::jis8("abc");
auto bytes = encode(j);
CHECK(bytes == std::vector<uint8_t>{0x45, 0x03, 'a', 'b', 'c'});
Item back = decode(bytes);
CHECK(back.format() == Format::JIS8);
CHECK(back == j);
}
TEST_CASE("C2 (Unicode 2-byte) encode/decode (E5 §9.5)") {
// 0x12 << 2 | 0x01 = 0x49 format byte, 1-byte length, then 2 bytes per
// code point big-endian. Code points: U+00E9 (é), U+4E2D (中).
Item c = Item::c2({0x00E9, 0x4E2D});
auto bytes = encode(c);
CHECK(bytes == std::vector<uint8_t>{0x49, 0x04, 0x00, 0xE9, 0x4E, 0x2D});
Item back = decode(bytes);
CHECK(back.format() == Format::C2);
CHECK(back == c);
}
TEST_CASE("JIS-8 and C2 disambiguate from ASCII / U2 by Format") {
// Same backing storage, different format code → not equal.
CHECK(Item::jis8("hi") != Item::ascii("hi"));
CHECK(Item::c2({0x41, 0x42}) != Item::u2({0x41, 0x42}));
}
TEST_CASE("SML rendering tags JIS-8 with J and C2 with C") {
CHECK(to_sml(Item::jis8("hi")) == "<J \"hi\" >");
CHECK(to_sml(Item::c2({0x41, 0x42})) == "<C 65 66 >");
}