diff --git a/CMakeLists.txt b/CMakeLists.txt index 40d15ee..8155fcc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/COMPLIANCE.md b/COMPLIANCE.md index 1d38335..a6fce56 100644 --- a/COMPLIANCE.md +++ b/COMPLIANCE.md @@ -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 ``, C2 as ``. | --- @@ -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 ; 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 ; 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. diff --git a/README.md b/README.md index bfa629a..62a30ac 100644 --- a/README.md +++ b/README.md @@ -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,11 +29,13 @@ 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 │ - │ equipment.yaml SVIDs / DVIDs / ECIDs / CEIDs / │ - │ alarms / recipes / commands / │ - │ capabilities / spool / DVID list │ + │ 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 │ └──────────────────────┬───────────────────────────────────────┘ │ (loaded at startup, codegened at build) ▼ @@ -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 diff --git a/apps/secs_client.cpp b/apps/secs_client.cpp index c0be4ec..f2ef259 100644 --- a/apps/secs_client.cpp +++ b/apps/secs_client.cpp @@ -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 { @@ -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(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!"), diff --git a/apps/secs_server.cpp b/apps/secs_server.cpp index bd6e810..ca0fd3a 100644 --- a/apps/secs_server.cpp +++ b/apps/secs_server.cpp @@ -91,7 +91,14 @@ int main(int argc, char** argv) { logfn("spool: " + what + " dropped (stream not spoolable, no host)"); return false; } - conn->send_request(std::move(msg), [](std::error_code, const s2::Message&) {}); + // 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) { - conn->send_request(std::move(m), [](std::error_code, const s2::Message&) {}); + 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(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(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(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(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(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); diff --git a/data/control_job_state.yaml b/data/control_job_state.yaml new file mode 100644 index 0000000..c057926 --- /dev/null +++ b/data/control_job_state.yaml @@ -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} diff --git a/data/equipment.yaml b/data/equipment.yaml index 9f3ad02..4a2781c 100644 --- a/data/equipment.yaml +++ b/data/equipment.yaml @@ -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: diff --git a/data/messages.yaml b/data/messages.yaml index d4818c0..c2a0f7c 100644 --- a/data/messages.yaml +++ b/data/messages.yaml @@ -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 . 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} diff --git a/data/process_job_state.yaml b/data/process_job_state.yaml new file mode 100644 index 0000000..459db85 --- /dev/null +++ b/data/process_job_state.yaml @@ -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} diff --git a/include/secsgem/config/loader.hpp b/include/secsgem/config/loader.hpp index 8f6759c..b31a245 100644 --- a/include/secsgem/config/loader.hpp +++ b/include/secsgem/config/loader.hpp @@ -7,8 +7,10 @@ #include #include +#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 diff --git a/include/secsgem/gem/communication_state.hpp b/include/secsgem/gem/communication_state.hpp new file mode 100644 index 0000000..c50010c --- /dev/null +++ b/include/secsgem/gem/communication_state.hpp @@ -0,0 +1,140 @@ +#pragma once + +#include +#include +#include +#include +#include + +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; + +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 arm_t_cra; + std::function arm_t_delay; + std::function cancel_timers; + // Asked to send the equipment-initiated S1F13. May be empty; only + // equipment-initiated establishment uses this. + std::function 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 diff --git a/include/secsgem/gem/control_job_state.hpp b/include/secsgem/gem/control_job_state.hpp new file mode 100644 index 0000000..da4d76c --- /dev/null +++ b/include/secsgem/gem/control_job_state.hpp @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include +#include +#include + +#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 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 parse_control_job_event(const std::string& s); + +// Wire-name -> event mapping for the CTLJOBCMD string on S16F27. +std::optional ctl_cmd_to_event(const std::string& cmd); + +struct ControlJobTransition { + ControlJobState from; + ControlJobEvent on; + std::optional to; + std::optional 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& rows() const { return rows_; } + + static ControlJobTransitionTable default_table(); + + private: + std::vector rows_; +}; + +class ControlJobStateMachine { + public: + using StateChangeHandler = + std::function; + + 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 diff --git a/include/secsgem/gem/data_model.hpp b/include/secsgem/gem/data_model.hpp index 0cf3cce..fd3e90a 100644 --- a/include/secsgem/gem/data_model.hpp +++ b/include/secsgem/gem/data_model.hpp @@ -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 vid_value(uint32_t vid) const { diff --git a/include/secsgem/gem/process_job_state.hpp b/include/secsgem/gem/process_job_state.hpp new file mode 100644 index 0000000..698ee37 --- /dev/null +++ b/include/secsgem/gem/process_job_state.hpp @@ -0,0 +1,130 @@ +#pragma once + +#include +#include +#include +#include +#include + +#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 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 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 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 to; + std::optional 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& 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 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; + + 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 diff --git a/include/secsgem/gem/store/control_jobs.hpp b/include/secsgem/gem/store/control_jobs.hpp new file mode 100644 index 0000000..938c2a5 --- /dev/null +++ b/include/secsgem/gem/store/control_jobs.hpp @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#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 prjobids; + std::unique_ptr fsm; +}; + +class ControlJobStore { + public: + using TransitionTableFactory = + std::function; + using StateChangeHandler = + std::function; + + 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 prjobids, + const std::function& 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(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 ids() const { + std::vector out; + out.reserve(jobs_.size()); + for (const auto& kv : jobs_) out.push_back(kv.first); + return out; + } + + private: + std::map jobs_; + TransitionTableFactory factory_; + StateChangeHandler on_change_; +}; + +} // namespace secsgem::gem diff --git a/include/secsgem/gem/store/equipment_constants.hpp b/include/secsgem/gem/store/equipment_constants.hpp index 1e84b1e..360c09e 100644 --- a/include/secsgem/gem/store/equipment_constants.hpp +++ b/include/secsgem/gem/store/equipment_constants.hpp @@ -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(v.front()); + return true; + }; switch (item.format()) { - case s2::Format::U1: out = std::get>(item.storage()).front(); return true; - case s2::Format::U2: out = std::get>(item.storage()).front(); return true; - case s2::Format::U4: out = std::get>(item.storage()).front(); return true; - case s2::Format::U8: out = static_cast(std::get>(item.storage()).front()); return true; - case s2::Format::I1: out = std::get>(item.storage()).front(); return true; - case s2::Format::I2: out = std::get>(item.storage()).front(); return true; - case s2::Format::I4: out = std::get>(item.storage()).front(); return true; - case s2::Format::I8: out = static_cast(std::get>(item.storage()).front()); return true; - case s2::Format::F4: out = std::get>(item.storage()).front(); return true; - case s2::Format::F8: out = std::get>(item.storage()).front(); return true; + case s2::Format::U1: return first(std::get>(item.storage())); + case s2::Format::U2: return first(std::get>(item.storage())); + case s2::Format::U4: return first(std::get>(item.storage())); + case s2::Format::U8: return first(std::get>(item.storage())); + case s2::Format::I1: return first(std::get>(item.storage())); + case s2::Format::I2: return first(std::get>(item.storage())); + case s2::Format::I4: return first(std::get>(item.storage())); + case s2::Format::I8: return first(std::get>(item.storage())); + case s2::Format::F4: return first(std::get>(item.storage())); + case s2::Format::F8: return first(std::get>(item.storage())); default: return false; } } diff --git a/include/secsgem/gem/store/event_reports.hpp b/include/secsgem/gem/store/event_reports.hpp index 6bbff89..efddd5c 100644 --- a/include/secsgem/gem/store/event_reports.hpp +++ b/include/secsgem/gem/store/event_reports.hpp @@ -30,12 +30,15 @@ struct ReportData { std::vector 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 { diff --git a/include/secsgem/gem/store/process_jobs.hpp b/include/secsgem/gem/store/process_jobs.hpp new file mode 100644 index 0000000..6d3a17e --- /dev/null +++ b/include/secsgem/gem/store/process_jobs.hpp @@ -0,0 +1,185 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#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 mtrloutspec; // material identifiers + bool alert_enabled = true; // S16F9 alerts on/off + std::unique_ptr fsm; +}; + +class ProcessJobStore { + public: + using TransitionTableFactory = + std::function; + using StateChangeHandler = + std::function; + + 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 materials, + const std::function& 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(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(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& 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 jobs_; + std::vector order_; // queue position (E40 HOQ-aware) + TransitionTableFactory factory_; + StateChangeHandler on_change_; +}; + +} // namespace secsgem::gem diff --git a/include/secsgem/secs2/item.hpp b/include/secsgem/secs2/item.hpp index a562ac1..2cab3f2 100644 --- a/include/secsgem/secs2/item.hpp +++ b/include/secsgem/secs2/item.hpp @@ -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; using Storage = std::variant< List, // List - std::string, // ASCII + std::string, // ASCII, JIS-8 std::vector, // Binary, Boolean, U1 std::vector, // I1 std::vector, // I2 std::vector, // I4 std::vector, // I8 - std::vector, // U2 + std::vector, // U2, C2 (Unicode code points) std::vector, // U4 std::vector, // U8 std::vector, // 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 code_points) { return Item(Format::C2, std::move(code_points)); } static Item binary(std::vector b) { return Item(Format::Binary, std::move(b)); } static Item boolean(std::vector b) { return Item(Format::Boolean, std::move(b)); } static Item boolean(bool b) { return Item(Format::Boolean, std::vector{static_cast(b ? 1 : 0)}); } @@ -141,6 +149,11 @@ class Item { const List& as_list() const { return std::get(data_); } List& as_list() { return std::get(data_); } const std::string& as_ascii() const { return std::get(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(data_); } + // C2 (Unicode) shares the std::vector storage with U2. + const std::vector& as_c2() const { return std::get>(data_); } const std::vector& as_bytes() const { return std::get>(data_); } const Storage& storage() const { return data_; } diff --git a/src/config/loader.cpp b/src/config/loader.cpp index d652aa0..959da0b 100644 --- a/src/config/loader.cpp +++ b/src/config/loader.cpp @@ -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()); + if (!parsed) fail(path, "unknown initial state `" + initial.as() + "`"); + 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(row["from"], where, "from")); + auto on = gem::parse_process_job_event(req_as(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()); + 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(parse_hcack(ack.as(), 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()); + if (!parsed) fail(path, "unknown initial state `" + initial.as() + "`"); + 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(row["from"], where, "from")); + auto on = gem::parse_control_job_event(req_as(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()); + if (!s) fail(where, "unknown `to` state"); + t.to = *s; + } + if (auto ack = row["ack"]) { + t.ack_code = static_cast(parse_hcack(ack.as(), where)); + } + cfg.table.add(t); + } + return cfg; +} + } // namespace secsgem::config diff --git a/src/gem/communication_state.cpp b/src/gem/communication_state.cpp new file mode 100644 index 0000000..6561f67 --- /dev/null +++ b/src/gem/communication_state.cpp @@ -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 diff --git a/src/gem/control_job_state.cpp b/src/gem/control_job_state.cpp new file mode 100644 index 0000000..3249023 --- /dev/null +++ b/src/gem/control_job_state.cpp @@ -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 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 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 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(*row->ack_code); + return HostCmdAck::Accept; +} + +bool ControlJobStateMachine::on_internal(ControlJobEvent event) { + return fire(event) != nullptr; +} + +} // namespace secsgem::gem diff --git a/src/gem/process_job_state.cpp b/src/gem/process_job_state.cpp new file mode 100644 index 0000000..7b2c1bf --- /dev/null +++ b/src/gem/process_job_state.cpp @@ -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(ProcessJobState::Queued) == 0); +static_assert(static_cast(ProcessJobState::SettingUp) == 1); +static_assert(static_cast(ProcessJobState::WaitingForStart) == 2); +static_assert(static_cast(ProcessJobState::Processing) == 3); +static_assert(static_cast(ProcessJobState::ProcessComplete) == 4); +static_assert(static_cast(ProcessJobState::Paused) == 5); +static_assert(static_cast(ProcessJobState::Stopping) == 6); +static_assert(static_cast(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 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 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 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(*row->ack_code); + return HostCmdAck::Accept; +} + +bool ProcessJobStateMachine::on_internal(ProcessJobEvent event) { + return fire(event) != nullptr; +} + +} // namespace secsgem::gem diff --git a/src/hsms/connection.cpp b/src/hsms/connection.cpp index 6647286..d9e6412 100644 --- a/src/hsms/connection.cpp +++ b/src/hsms/connection.cpp @@ -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(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(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(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(SelectStatus::Ok)))); - log("-> Select.rsp (Ok)"); - enter_selected(); + static_cast(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(DeselectStatus::Ok)))); - state_ = State::NotSelected; - arm_t7(); + static_cast(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 } } diff --git a/src/secs2/codec.cpp b/src/secs2/codec.cpp index ceb1458..83d2e33 100644 --- a/src/secs2/codec.cpp +++ b/src/secs2/codec.cpp @@ -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(p), length)); + case Format::JIS8: + return Item::jis8(std::string(reinterpret_cast(p), length)); + case Format::C2: + return Item::c2(read_array(p, length)); case Format::Binary: return Item::binary(std::vector(p, p + length)); case Format::Boolean: diff --git a/tests/test_communication_state.cpp b/tests/test_communication_state.cpp new file mode 100644 index 0000000..1d3e554 --- /dev/null +++ b/tests/test_communication_state.cpp @@ -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 + +#include +#include + +#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> 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"); +} diff --git a/tests/test_control_jobs.cpp b/tests/test_control_jobs.cpp new file mode 100644 index 0000000..cbacdf5 --- /dev/null +++ b/tests/test_control_jobs.cpp @@ -0,0 +1,94 @@ +#include + +#include + +#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> 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); +} diff --git a/tests/test_hsms.cpp b/tests/test_hsms.cpp index 41f0187..2fa7305 100644 --- a/tests/test_hsms.cpp +++ b/tests/test_hsms.cpp @@ -87,3 +87,33 @@ TEST_CASE("frame decode rejects short payload") { std::vector 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(SType::Data) == 0); + CHECK(static_cast(SType::SelectReq) == 1); + CHECK(static_cast(SType::SelectRsp) == 2); + CHECK(static_cast(SType::DeselectReq) == 3); + CHECK(static_cast(SType::DeselectRsp) == 4); + CHECK(static_cast(SType::LinktestReq) == 5); + CHECK(static_cast(SType::LinktestRsp) == 6); + CHECK(static_cast(SType::RejectReq) == 7); + CHECK(static_cast(SType::SeparateReq) == 9); + + // Select.rsp status (E37 §7.2). + CHECK(static_cast(SelectStatus::Ok) == 0); + CHECK(static_cast(SelectStatus::AlreadyActive) == 1); + CHECK(static_cast(SelectStatus::NotReady) == 2); + CHECK(static_cast(SelectStatus::ConnectExhaust) == 3); + + // Deselect.rsp status (E37 §7.4). + CHECK(static_cast(DeselectStatus::Ok) == 0); + CHECK(static_cast(DeselectStatus::NotEstablished) == 1); + CHECK(static_cast(DeselectStatus::Busy) == 2); + + // Reject reason (E37 §7.7). + CHECK(static_cast(RejectReason::StypeNotSupported) == 1); + CHECK(static_cast(RejectReason::PtypeNotSupported) == 2); + CHECK(static_cast(RejectReason::TransactionNotOpen) == 3); + CHECK(static_cast(RejectReason::EntityNotSelected) == 4); +} diff --git a/tests/test_hsms_connection.cpp b/tests/test_hsms_connection.cpp new file mode 100644 index 0000000..0c3888d --- /dev/null +++ b/tests/test_hsms_connection.cpp @@ -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 + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 +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 bytes) { + auto buf = std::make_shared>(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>(); + 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>(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(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(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(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(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(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(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 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(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(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 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(RejectReason::PtypeNotSupported)); + + conn->close("test done"); +} + +TEST_CASE("Data frame while NOT_SELECTED triggers Reject.req(EntityNotSelected)") { + SocketPair sp; + auto conn = std::make_shared(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(RejectReason::EntityNotSelected)); + + conn->close("test done"); +} diff --git a/tests/test_loader.cpp b/tests/test_loader.cpp index 3e5b286..2204775 100644 --- a/tests/test_loader.cpp +++ b/tests/test_loader.cpp @@ -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); +} diff --git a/tests/test_messages.cpp b/tests/test_messages.cpp index 04b4d9f..835bb6e 100644 --- a/tests/test_messages.cpp +++ b/tests/test_messages.cpp @@ -61,6 +61,67 @@ TEST_CASE("S2F16 EAC ack round-trip") { CHECK(*byte == static_cast(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(EquipmentAck::Accept) == 0); + CHECK(static_cast(EquipmentAck::Denied_UnknownEcid) == 1); + CHECK(static_cast(EquipmentAck::Denied_Busy) == 2); + CHECK(static_cast(EquipmentAck::Denied_OutOfRange) == 3); +} + +TEST_CASE("S2F34 DRACK enum matches SEMI E5 wire values") { + CHECK(static_cast(DefineReportAck::Accept) == 0); + CHECK(static_cast(DefineReportAck::InsufficientSpace) == 1); + CHECK(static_cast(DefineReportAck::InvalidFormat) == 2); + CHECK(static_cast(DefineReportAck::RptidAlreadyDefined) == 3); + CHECK(static_cast(DefineReportAck::InvalidVid) == 4); +} + +TEST_CASE("S2F36 LRACK enum matches SEMI E5 wire values") { + CHECK(static_cast(LinkEventAck::Accept) == 0); + CHECK(static_cast(LinkEventAck::InsufficientSpace) == 1); + CHECK(static_cast(LinkEventAck::InvalidFormat) == 2); + CHECK(static_cast(LinkEventAck::UnknownCeid) == 3); + CHECK(static_cast(LinkEventAck::UnknownRptid) == 4); + CHECK(static_cast(LinkEventAck::CeidAlreadyLinked) == 5); +} + +TEST_CASE("S2F42 HCACK enum matches SEMI E5 wire values") { + CHECK(static_cast(HostCmdAck::Accept) == 0); + CHECK(static_cast(HostCmdAck::InvalidCommand) == 1); + CHECK(static_cast(HostCmdAck::CannotDoNow) == 2); + CHECK(static_cast(HostCmdAck::ParameterInvalid) == 3); + CHECK(static_cast(HostCmdAck::AcceptedWillFinishLater) == 4); + CHECK(static_cast(HostCmdAck::Rejected) == 5); + CHECK(static_cast(HostCmdAck::InvalidObject) == 6); +} + +TEST_CASE("S7F4 ACKC7 enum matches SEMI E5 wire values") { + CHECK(static_cast(ProcessProgramAck::Accept) == 0); + CHECK(static_cast(ProcessProgramAck::PermissionNotGranted) == 1); + CHECK(static_cast(ProcessProgramAck::LengthError) == 2); + CHECK(static_cast(ProcessProgramAck::MatrixOverflow) == 3); + CHECK(static_cast(ProcessProgramAck::PpidNotFound) == 4); + CHECK(static_cast(ProcessProgramAck::ModeUnsupported) == 5); + CHECK(static_cast(ProcessProgramAck::PerformanceError) == 6); +} + +TEST_CASE("S10F2/F4/F6 ACKC10 enum matches SEMI E5 wire values") { + CHECK(static_cast(TerminalAck::Accepted) == 0); + CHECK(static_cast(TerminalAck::WillNotDisplay) == 1); + CHECK(static_cast(TerminalAck::TerminalNotAvailable) == 2); +} + +TEST_CASE("S1F18 ONLACK / S1F16 OFLACK / S1F14 COMMACK enum wire values") { + CHECK(static_cast(OnlineAck::Accept) == 0); + CHECK(static_cast(OnlineAck::NotAccept) == 1); + CHECK(static_cast(OnlineAck::AlreadyOnline) == 2); + CHECK(static_cast(OfflineAck::Accept) == 0); + CHECK(static_cast(CommAck::Accept) == 0); + CHECK(static_cast(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 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{"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{"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{"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); +} diff --git a/tests/test_process_jobs.cpp b/tests/test_process_jobs.cpp new file mode 100644 index 0000000..41ea08a --- /dev/null +++ b/tests/test_process_jobs.cpp @@ -0,0 +1,223 @@ +#include + +#include + +#include "secsgem/gem/process_job_state.hpp" +#include "secsgem/gem/store/process_jobs.hpp" + +using namespace secsgem::gem; + +namespace { + +struct Recorder { + std::vector> 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> 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)); +} diff --git a/tests/test_secs2.cpp b/tests/test_secs2.cpp index fd7895c..3657422 100644 --- a/tests/test_secs2.cpp +++ b/tests/test_secs2.cpp @@ -97,3 +97,39 @@ TEST_CASE("SML rendering") { Item body = Item::list({Item::ascii("MDLN"), Item::u4(uint32_t{42})}); CHECK(to_sml(body) == " >"); } + +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{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{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")) == ""); + CHECK(to_sml(Item::c2({0x41, 0x42})) == ""); +}