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:
@@ -46,6 +46,9 @@ add_library(secsgem
|
||||
src/hsms/header.cpp
|
||||
src/hsms/connection.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/endpoint.cpp
|
||||
)
|
||||
@@ -77,10 +80,14 @@ add_executable(secsgem_tests
|
||||
tests/test_main.cpp
|
||||
tests/test_secs2.cpp
|
||||
tests/test_hsms.cpp
|
||||
tests/test_hsms_connection.cpp
|
||||
tests/test_control_state.cpp
|
||||
tests/test_communication_state.cpp
|
||||
tests/test_data_model.cpp
|
||||
tests/test_messages.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_compile_definitions(secsgem_tests PRIVATE
|
||||
|
||||
+86
-16
@@ -1,7 +1,7 @@
|
||||
# SECS/GEM Compliance
|
||||
|
||||
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
|
||||
> 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 | |
|
||||
| `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. |
|
||||
| JIS-8 / C2 (Unicode) | ⬜ | E5 §9.5 | Rarely used in modern fabs. |
|
||||
| SML text rendering | ✅ | E5 Annex | `secs2::to_sml`. |
|
||||
| JIS-8 (single-byte JIS text) | ✅ | E5 §9.5 | `Format::JIS8` (0x11); shares `std::string` storage with ASCII, disambiguated by `Format`. |
|
||||
| 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 |
|
||||
|---------------------------------------|--------|----------|----------|-------|
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -85,7 +86,7 @@ Legend:
|
||||
| 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. |
|
||||
| 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). |
|
||||
| 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). |
|
||||
@@ -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
|
||||
|
||||
| Pair | Direction | Status | Implemented in | Tested |
|
||||
@@ -110,6 +156,7 @@ Legend:
|
||||
| S2F15 / S2F16 | H→E | ✅ | catalog | ✅ round-trip |
|
||||
| S2F17 / S2F18 | H→E | ✅ | catalog | ✅ round-trip + demo |
|
||||
| S2F23 / S2F24 | H→E | ✅ | catalog | ✅ round-trip |
|
||||
| S2F25 / S2F26 | H→E | ✅ | catalog | ✅ round-trip (loopback diagnostic) |
|
||||
| S2F29 / S2F30 | H→E | ✅ | catalog | ✅ round-trip + demo |
|
||||
| S2F31 / S2F32 | 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 |
|
||||
| S5F5 / S5F6 | H→E | ✅ | catalog | ✅ round-trip + demo |
|
||||
| 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 |
|
||||
| S6F11 / S6F12 | E→H | ✅ | 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 |
|
||||
| S10F3 / S10F4 | E→H | ✅ | catalog | ✅ round-trip + demo |
|
||||
| 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) →
|
||||
`SPOOL_OFF` → `S6F23(Transmit)` → server drains queued S6F11 to host.
|
||||
17. `S7F19`/`S7F20` recipe list, `S7F5`/`S7F6` fetch RECIPE-A.
|
||||
18. `S10F1`/`S10F2` host → equipment terminal display.
|
||||
19. `S1F15`/`S1F16` Request Offline.
|
||||
20. `Separate.req` → clean close on both sides.
|
||||
18. `S16F11`/`S16F12` create Process Job `PJ-1` with PPID `RECIPE-A`.
|
||||
19. `S14F9`/`S14F10` create Control Job `CJ-1` containing `[PJ-1]`.
|
||||
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 |
|
||||
|---------------------------------------|----------------------|
|
||||
| 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. |
|
||||
| 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. |
|
||||
| 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 (S7F23–F26) | 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. |
|
||||
| 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
|
||||
GEM-compliant.
|
||||
|
||||
In short: this is a **GEM-conformant runtime stack**, not a
|
||||
GEM-conformant *tool*. Pointing the runtime at a real piece of
|
||||
equipment, populating the YAML files with the tool's real SVIDs / ECIDs
|
||||
/ alarms / capabilities, and wiring the application callbacks completes
|
||||
the picture.
|
||||
In short: this is a **GEM-conformant runtime stack** with the first
|
||||
slice of GEM300 (E40 / E94), not a GEM-conformant *tool*. Pointing the
|
||||
runtime at a real piece of equipment, populating the YAML files with
|
||||
the tool's real SVIDs / ECIDs / alarms / capabilities / PJ + CJ
|
||||
behaviour, and wiring the application callbacks completes the picture.
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
A C++20 SECS-II / HSMS / GEM client and server, fully containerised, with
|
||||
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.
|
||||
Every GEM Fundamental and every GEM Additional capability that E30 binds
|
||||
to a concrete SECS-II message set is implemented and round-trip-tested.
|
||||
See [COMPLIANCE.md](COMPLIANCE.md) for the per-capability E5/E30/E37/E40/E94
|
||||
audit. Every GEM Fundamental and every GEM Additional capability that E30
|
||||
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
|
||||
|
||||
@@ -14,7 +17,7 @@ Everything runs in Docker — no compiler or build tools on the host.
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
@@ -26,8 +29,10 @@ the C++ is the engine that reads them.
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ data/ │
|
||||
│ messages.yaml SECS-II message catalog (44 SxFy) │
|
||||
│ control_state.yaml E30 §6.2 transition table │
|
||||
│ messages.yaml SECS-II message catalog │
|
||||
│ 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 / │
|
||||
│ alarms / recipes / commands / │
|
||||
│ 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::gem ControlTransitionTable + ControlStateMachine,│
|
||||
│ EquipmentDataModel composing nine stores: │
|
||||
│ ProcessJobStateMachine (E40), │
|
||||
│ ControlJobStateMachine (E94), │
|
||||
│ EquipmentDataModel composing the stores: │
|
||||
│ SVID, DVID, ECID, Event Subscriptions, │
|
||||
│ Alarms, Recipes, Clock, Commands, Spool, │
|
||||
│ Limits, Traces │
|
||||
│ Limits, Traces, ProcessJobs, ControlJobs │
|
||||
│ Router (stream, function) -> handler │
|
||||
│ generated messages.hpp (all 44 SxFy) │
|
||||
│ secsgem::hsms Connection (Asio), Header, Frame, Timers │
|
||||
@@ -81,7 +88,8 @@ secs-gem/
|
||||
├── include/secsgem/
|
||||
│ ├── secs2/{item,codec,message}.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:
|
||||
│ │ ├── status_variables.hpp # SVIDs + DVIDs
|
||||
│ │ ├── equipment_constants.hpp # ECIDs + EAC range validation
|
||||
@@ -92,7 +100,9 @@ secs-gem/
|
||||
│ │ ├── host_commands.hpp # RCMD registry
|
||||
│ │ ├── spool.hpp # spool queue + state
|
||||
│ │ ├── 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
|
||||
│ └── endpoint.hpp
|
||||
├── src/{secs2,hsms,gem,config}/*.cpp + endpoint.cpp
|
||||
@@ -131,6 +141,19 @@ transitions:
|
||||
- {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
|
||||
|
||||
```yaml
|
||||
@@ -184,6 +207,16 @@ The two-container demo walks ~20 SECS transactions:
|
||||
[host] EVENT CEID=300 (from spool, post-fact)
|
||||
[host] -> S7F19 W [equip] -> S7F20 (2 PPIDs)
|
||||
[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] -> S1F15 W [equip] OnlineRemote -> HostOffline
|
||||
[host] -> Separate.req [equip] <- Separate.req
|
||||
|
||||
+74
-2
@@ -40,6 +40,8 @@ constexpr uint32_t kDataIdReports = 7;
|
||||
constexpr uint32_t kRptidStatus = 1000;
|
||||
constexpr uint32_t kCeidProcessStarted = 300;
|
||||
constexpr uint32_t kCeidAlarmSetEvent = 200;
|
||||
constexpr uint32_t kCeidCJExecuting = 400;
|
||||
constexpr uint32_t kCeidCJCompleted = 401;
|
||||
constexpr uint32_t kAlarmChiller = 1;
|
||||
|
||||
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");
|
||||
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.
|
||||
if (msg.stream == 5 && msg.function == 1) {
|
||||
auto a = gem::parse_s5f1(msg);
|
||||
@@ -241,7 +253,9 @@ int main(int argc, char** argv) {
|
||||
conn->send_request(
|
||||
gem::s2f35_link_event_report(kDataIdReports,
|
||||
{{kCeidProcessStarted, {kRptidStatus}},
|
||||
{kCeidAlarmSetEvent, {kRptidStatus}}}),
|
||||
{kCeidAlarmSetEvent, {kRptidStatus}},
|
||||
{kCeidCJExecuting, {kRptidStatus}},
|
||||
{kCeidCJCompleted, {kRptidStatus}}}),
|
||||
[logfn, fail, next](std::error_code ec, const s2::Message& reply) {
|
||||
if (ec) { fail("S2F35", ec); return; }
|
||||
auto a = gem::ack_byte(reply);
|
||||
@@ -253,7 +267,8 @@ int main(int argc, char** argv) {
|
||||
// 8. Enable the linked CEIDs.
|
||||
seq->steps.push_back([conn, logfn, fail](auto next) {
|
||||
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) {
|
||||
if (ec) { fail("S2F37", ec); return; }
|
||||
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.
|
||||
seq->steps.push_back([conn, logfn, fail](auto next) {
|
||||
conn->send_request(gem::s10f1_terminal_display_single(0, "Hello equipment!"),
|
||||
|
||||
@@ -91,7 +91,14 @@ int main(int argc, char** argv) {
|
||||
logfn("spool: " + what + " dropped (stream not spoolable, no host)");
|
||||
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&) {});
|
||||
} else {
|
||||
conn->send_data(std::move(msg));
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
// ---- 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 -------------------------------
|
||||
gem::Router router;
|
||||
|
||||
@@ -367,7 +466,11 @@ int main(int argc, char** argv) {
|
||||
auto conn = active_conn->lock();
|
||||
if (!conn) return;
|
||||
for (auto& m : drained) {
|
||||
const bool w = m.reply_expected;
|
||||
if (w)
|
||||
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);
|
||||
@@ -426,6 +529,98 @@ int main(int argc, char** argv) {
|
||||
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) {
|
||||
auto td = gem::parse_s10f1(msg);
|
||||
if (td) logfn("TERMINAL[" + std::to_string(td->tid) + "] " + td->text);
|
||||
|
||||
@@ -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}
|
||||
@@ -26,6 +26,9 @@ capabilities:
|
||||
- {code: 12, name: "Clock"}
|
||||
- {code: 14, name: "Spooling (partial; S2F43/F44 + S6F23/F24)"}
|
||||
- {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.
|
||||
# `type` controls the SECS-II Item format.
|
||||
@@ -50,6 +53,8 @@ ceids:
|
||||
- {id: 100, name: ControlStateChanged}
|
||||
- {id: 200, name: AlarmSetEvent}
|
||||
- {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.
|
||||
alarms:
|
||||
|
||||
@@ -251,6 +251,24 @@ messages:
|
||||
parser: parse_s2f18
|
||||
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
|
||||
stream: 2
|
||||
function: 29
|
||||
@@ -660,6 +678,53 @@ messages:
|
||||
- {name: alid, shape: {kind: scalar, item_type: U4}}
|
||||
- {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
|
||||
# =====================================================================
|
||||
@@ -909,3 +974,168 @@ messages:
|
||||
function: 6
|
||||
builder: s10f6_terminal_display_multi_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}
|
||||
|
||||
@@ -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}
|
||||
@@ -7,8 +7,10 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "secsgem/gem/control_job_state.hpp"
|
||||
#include "secsgem/gem/control_state.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
|
||||
// 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,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
#include "secsgem/gem/store/alarms.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/event_reports.hpp"
|
||||
#include "secsgem/gem/store/host_commands.hpp"
|
||||
#include "secsgem/gem/store/limits.hpp"
|
||||
#include "secsgem/gem/store/process_jobs.hpp"
|
||||
#include "secsgem/gem/store/recipes.hpp"
|
||||
#include "secsgem/gem/store/spool.hpp"
|
||||
#include "secsgem/gem/store/status_variables.hpp"
|
||||
@@ -30,6 +32,8 @@ struct EquipmentDataModel {
|
||||
SpoolStore spool;
|
||||
LimitMonitorStore limits;
|
||||
TraceStore traces;
|
||||
ProcessJobStore process_jobs;
|
||||
ControlJobStore control_jobs;
|
||||
|
||||
// Convenience: VID -> value lookup spanning SVIDs and DVIDs.
|
||||
std::optional<s2::Item> vid_value(uint32_t vid) const {
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// 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 {
|
||||
Accept = 0,
|
||||
Denied_UnknownEcid = 1,
|
||||
Denied_Busy = 3,
|
||||
Denied_OutOfRange = 4,
|
||||
Denied_Busy = 2,
|
||||
Denied_OutOfRange = 3,
|
||||
};
|
||||
|
||||
class EquipmentConstantStore {
|
||||
@@ -82,17 +84,22 @@ class EquipmentConstantStore {
|
||||
}
|
||||
|
||||
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()) {
|
||||
case s2::Format::U1: out = std::get<std::vector<uint8_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::U2: out = std::get<std::vector<uint16_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::U4: out = std::get<std::vector<uint32_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::U8: out = static_cast<double>(std::get<std::vector<uint64_t>>(item.storage()).front()); return true;
|
||||
case s2::Format::I1: out = std::get<std::vector<int8_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::I2: out = std::get<std::vector<int16_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::I4: out = std::get<std::vector<int32_t>>(item.storage()).front(); return true;
|
||||
case s2::Format::I8: out = static_cast<double>(std::get<std::vector<int64_t>>(item.storage()).front()); return true;
|
||||
case s2::Format::F4: out = std::get<std::vector<float>>(item.storage()).front(); return true;
|
||||
case s2::Format::F8: out = std::get<std::vector<double>>(item.storage()).front(); return true;
|
||||
case s2::Format::U1: return first(std::get<std::vector<uint8_t>>(item.storage()));
|
||||
case s2::Format::U2: return first(std::get<std::vector<uint16_t>>(item.storage()));
|
||||
case s2::Format::U4: return first(std::get<std::vector<uint32_t>>(item.storage()));
|
||||
case s2::Format::U8: return first(std::get<std::vector<uint64_t>>(item.storage()));
|
||||
case s2::Format::I1: return first(std::get<std::vector<int8_t>>(item.storage()));
|
||||
case s2::Format::I2: return first(std::get<std::vector<int16_t>>(item.storage()));
|
||||
case s2::Format::I4: return first(std::get<std::vector<int32_t>>(item.storage()));
|
||||
case s2::Format::I8: return first(std::get<std::vector<int64_t>>(item.storage()));
|
||||
case s2::Format::F4: return first(std::get<std::vector<float>>(item.storage()));
|
||||
case s2::Format::F8: return first(std::get<std::vector<double>>(item.storage()));
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,15 @@ struct ReportData {
|
||||
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 {
|
||||
Accept = 0,
|
||||
InsufficientSpace = 1,
|
||||
InvalidFormat = 2,
|
||||
RptidAlreadyDefined = 3,
|
||||
InvalidVid = 5,
|
||||
InvalidVid = 4,
|
||||
};
|
||||
|
||||
enum class LinkEventAck : uint8_t {
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,8 @@ enum class Format : uint8_t {
|
||||
Binary = 010, // 8
|
||||
Boolean = 011, // 9
|
||||
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
|
||||
I1 = 031, // 25
|
||||
I2 = 032, // 26
|
||||
@@ -34,6 +36,8 @@ inline const char* format_name(Format f) {
|
||||
case Format::Binary: return "B";
|
||||
case Format::Boolean: return "BOOLEAN";
|
||||
case Format::ASCII: return "A";
|
||||
case Format::JIS8: return "J";
|
||||
case Format::C2: return "C";
|
||||
case Format::I8: return "I8";
|
||||
case Format::I1: return "I1";
|
||||
case Format::I2: return "I2";
|
||||
@@ -55,10 +59,12 @@ inline std::size_t element_size(Format f) {
|
||||
switch (f) {
|
||||
case Format::List: return 0;
|
||||
case Format::ASCII:
|
||||
case Format::JIS8:
|
||||
case Format::Binary:
|
||||
case Format::Boolean:
|
||||
case Format::U1:
|
||||
case Format::I1: return 1;
|
||||
case Format::C2:
|
||||
case Format::U2:
|
||||
case Format::I2: return 2;
|
||||
case Format::U4:
|
||||
@@ -81,13 +87,13 @@ class Item {
|
||||
using List = std::vector<Item>;
|
||||
using Storage = std::variant<
|
||||
List, // List
|
||||
std::string, // ASCII
|
||||
std::string, // ASCII, JIS-8
|
||||
std::vector<uint8_t>, // Binary, Boolean, U1
|
||||
std::vector<int8_t>, // I1
|
||||
std::vector<int16_t>, // I2
|
||||
std::vector<int32_t>, // I4
|
||||
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<uint64_t>, // U8
|
||||
std::vector<float>, // F4
|
||||
@@ -107,6 +113,8 @@ class Item {
|
||||
// --- Factory functions -------------------------------------------------
|
||||
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 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 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)}); }
|
||||
@@ -141,6 +149,11 @@ class Item {
|
||||
const List& as_list() const { 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_); }
|
||||
// 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 Storage& storage() const { return data_; }
|
||||
|
||||
@@ -242,4 +242,89 @@ EquipmentDescriptor load_equipment(const std::string& path, gem::EquipmentDataMo
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -107,21 +107,60 @@ void Connection::on_payload(std::error_code ec, std::size_t) {
|
||||
close("read payload: " + ec.message());
|
||||
return;
|
||||
}
|
||||
Frame frame;
|
||||
try {
|
||||
handle_frame(Frame::decode(payload_.data(), payload_.size()));
|
||||
frame = Frame::decode(payload_.data(), payload_.size());
|
||||
} catch (const std::exception& e) {
|
||||
close(std::string("decode: ") + e.what());
|
||||
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();
|
||||
}
|
||||
|
||||
void Connection::handle_frame(Frame frame) {
|
||||
if (frame.header.stype == SType::Data) {
|
||||
handle_data(frame);
|
||||
} else {
|
||||
handle_control(frame);
|
||||
return;
|
||||
}
|
||||
// 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) {
|
||||
@@ -210,11 +249,19 @@ void Connection::handle_control(const Frame& frame) {
|
||||
const Header& h = frame.header;
|
||||
switch (h.stype) {
|
||||
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,
|
||||
static_cast<uint8_t>(SelectStatus::Ok))));
|
||||
log("-> Select.rsp (Ok)");
|
||||
enter_selected();
|
||||
static_cast<uint8_t>(status))));
|
||||
log(std::string("-> Select.rsp (") +
|
||||
(status == SelectStatus::Ok ? "Ok" : "AlreadyActive") + ")");
|
||||
if (status == SelectStatus::Ok) enter_selected();
|
||||
break;
|
||||
}
|
||||
case SType::SelectRsp: {
|
||||
@@ -231,11 +278,19 @@ void Connection::handle_control(const Frame& frame) {
|
||||
break;
|
||||
}
|
||||
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,
|
||||
static_cast<uint8_t>(DeselectStatus::Ok))));
|
||||
static_cast<uint8_t>(status))));
|
||||
if (status == DeselectStatus::Ok) {
|
||||
state_ = State::NotSelected;
|
||||
arm_t7();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SType::DeselectRsp: {
|
||||
@@ -268,7 +323,7 @@ void Connection::handle_control(const Frame& frame) {
|
||||
break;
|
||||
}
|
||||
case SType::Data:
|
||||
break; // unreachable
|
||||
break; // unreachable: SType::Data is dispatched to handle_data
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,10 @@ Item decode_at(const uint8_t* data, std::size_t len, std::size_t& pos) {
|
||||
switch (fmt) {
|
||||
case Format::ASCII:
|
||||
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:
|
||||
return Item::binary(std::vector<uint8_t>(p, p + length));
|
||||
case Format::Boolean:
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -87,3 +87,33 @@ TEST_CASE("frame decode rejects short payload") {
|
||||
std::vector<uint8_t> tooShort(5, 0);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -47,9 +47,11 @@ TEST_CASE("equipment.yaml populates SVIDs, ECIDs, CEIDs, alarms, recipes, comman
|
||||
CHECK(m.ecids.all().size() == 2);
|
||||
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(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.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),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -61,6 +61,67 @@ TEST_CASE("S2F16 EAC ack round-trip") {
|
||||
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") {
|
||||
auto m = s2f18_date_time_data("2026052812345678");
|
||||
auto t = parse_s2f18(m);
|
||||
@@ -392,6 +453,51 @@ TEST_CASE("S7F3 process-program send round-trip") {
|
||||
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") {
|
||||
auto req = s7f19_current_eppd_request();
|
||||
CHECK(req.stream == 7);
|
||||
@@ -404,3 +510,77 @@ TEST_CASE("S7F19 / S7F20 EPPD list") {
|
||||
REQUIRE(parsed.has_value());
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -97,3 +97,39 @@ TEST_CASE("SML rendering") {
|
||||
Item body = Item::list({Item::ascii("MDLN"), Item::u4(uint32_t{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 >");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user