diff --git a/README.md b/README.md index 2d12c26..6a5c2dd 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ a fresh clone, the codebase implements what | 4 | `SECSGEM_ROBUSTNESS_SOAK=1 docker compose run --rm builder /app/build/secsgem_tests -tc='*soak*'` | **100 000 random tool operations** execute with all invariants and persistence round-trips holding | | 5 | `docker compose run --rm builder /app/build/secs_server --validate-config --config /app/data/equipment.yaml --state-table /app/data/control_state.yaml --pj-state-table /app/data/process_job_state.yaml --cj-state-table /app/data/control_job_state.yaml` | Every shipped YAML config passes structural + referential validation | | 6 | `docker compose run --rm builder bash /app/interop/tshark_validate.sh` | **69 HSMS frames** dissected by Wireshark's HSMS dissector (independent third codec) with no malformed packets | -| 7 | `bash interop/secs4j_validate.sh` | **20 cross-validation checks** PASS against [secs4java8](https://github.com/kenta-shimizu/secs4java8) (independent Java implementation), covering full GEM 300 body shapes (S3, S14, S16) plus S2F49 / S5F13 / S2F23 | +| 7 | `bash interop/secs4j_validate.sh` | **55 cross-validation checks** PASS against [secs4java8](https://github.com/kenta-shimizu/secs4java8) (independent Java implementation), covering S1/S2/S3/S5/S6/S7/S10/S14/S16, the full E40 PJ body, dynamic event reports + unsolicited S6F11 / S5F1 observation, alarm management, spool, PP management, terminal services, limits, trace, E39, and the GEM 300 streams secsgem-py couldn't easily drive | | 8 | `cmake -B build-fuzz -DSECSGEM_FUZZ=ON && build-fuzz/fuzz_secs2_decode -max_total_time=60` | **~70 000 random inputs** through `secs2::decode`, **~285 000** through `try_parse_sml`, ASan + UBSan + libFuzzer coverage, **0 crashes** | Plus, on every push to `main`, [Gitea Actions](.gitea/workflows/ci.yml) diff --git a/interop/secs4j/Secs4jHostHarness.java b/interop/secs4j/Secs4jHostHarness.java index ef2cefd..263154b 100644 --- a/interop/secs4j/Secs4jHostHarness.java +++ b/interop/secs4j/Secs4jHostHarness.java @@ -16,6 +16,7 @@ import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -90,6 +91,27 @@ public class Secs4jHostHarness { comm.addSecsCommunicatableStateChangeListener(s -> { if (s) selected.countDown(); }); + + // Capture every equipment-initiated primary into a queue + // so we can assert on unsolicited S6F11 / S5F1 / S16F9 + // emissions after triggering events. + ConcurrentLinkedQueue observed = new ConcurrentLinkedQueue<>(); + comm.addSecsMessageReceiveListener(msg -> { + observed.add(new int[]{msg.getStream(), msg.getFunction()}); + // Reply to known equipment-initiated primaries so the + // equipment's T3 doesn't fire on our watch. + try { + int s = msg.getStream(), f = msg.getFunction(); + if (s == 5 && f == 1) { + comm.send(msg, 5, 2, false, Secs2.binary((byte)0x00)); + } else if (s == 6 && f == 11) { + comm.send(msg, 6, 12, false, Secs2.binary((byte)0x00)); + } else if (s == 16 && f == 9) { + // W=0 PJ alert — no reply expected + } + } catch (Exception ignore) {} + }); + comm.open(); if (!selected.await(15, TimeUnit.SECONDS)) { @@ -130,6 +152,273 @@ public class Secs4jHostHarness { roundtrip(comm, "S2F17 → S2F18 (Clock)", 2, 17, true, Secs2.empty(), 2, 18); + // -------- Status data (E30 §6.13) ----------------------------- + + // S1F3 → S1F4 (SVID values) — empty list = all SVIDs + roundtrip(comm, "S1F3 → S1F4 (SVID values, all)", + 1, 3, true, Secs2.list(), 1, 4); + + // S1F11 → S1F12 (SVID namelist) — empty list = all SVIDs + roundtrip(comm, "S1F11 → S1F12 (SVID namelist, all)", + 1, 11, true, Secs2.list(), 1, 12); + + // -------- Equipment constants (E30 §6.16) --------------------- + + // S2F13 → S2F14 (EC values) — empty list = all ECs + roundtrip(comm, "S2F13 → S2F14 (EC values, all)", + 2, 13, true, Secs2.list(), 2, 14); + + // S2F29 → S2F30 (EC namelist) — empty list = all ECs + roundtrip(comm, "S2F29 → S2F30 (EC namelist, all)", + 2, 29, true, Secs2.list(), 2, 30); + + // S2F15 → S2F16 (set EC). ECID 10 = TimeFormat (U4), valid + // range 0..1 per equipment.yaml. Set to 1 — round-trip + // proves the (ecid, value) tuple body parses. + // body: >> + roundtrip(comm, "S2F15 → S2F16 (set EC TimeFormat=1)", + 2, 15, true, + Secs2.list( + Secs2.list(Secs2.uint4(10L), Secs2.uint4(1L))), + 2, 16); + + // -------- Dynamic event reports (E30 §6.6) -------------------- + // Define RPTID 9001 covering SVIDs {1, 2}, link CEID 300 + // (ProcessStarted) → RPTID 9001, enable CEID 300, then + // query the bound report back via S6F15/F19/F21. + + // S2F33 → S2F34 (define report) + roundtrip(comm, "S2F33 → S2F34 (define RPTID 9001 → SVID {1,2})", + 2, 33, true, + Secs2.list( + Secs2.uint4(9000L), + Secs2.list( + Secs2.list( + Secs2.uint4(9001L), + Secs2.list(Secs2.uint4(1L), Secs2.uint4(2L))))), + 2, 34); + + // S2F35 → S2F36 (link event report) + roundtrip(comm, "S2F35 → S2F36 (link CEID 300 → RPTID 9001)", + 2, 35, true, + Secs2.list( + Secs2.uint4(9000L), + Secs2.list( + Secs2.list( + Secs2.uint4(300L), + Secs2.list(Secs2.uint4(9001L))))), + 2, 36); + + // S2F37 → S2F38 (enable event) + roundtrip(comm, "S2F37 → S2F38 (enable CEID 300)", + 2, 37, true, + Secs2.list(Secs2.bool(true), Secs2.list(Secs2.uint4(300L))), + 2, 38); + + // S6F15 → S6F16 (event report request) + roundtrip(comm, "S6F15 → S6F16 (event report request CEID=300)", + 6, 15, true, Secs2.uint4(300L), 6, 16); + + // S6F19 → S6F20 (individual report request) + roundtrip(comm, "S6F19 → S6F20 (individual report RPTID=9001)", + 6, 19, true, Secs2.uint4(9001L), 6, 20); + + // S6F21 → S6F22 (annotated individual report) + roundtrip(comm, "S6F21 → S6F22 (annotated report RPTID=9001)", + 6, 21, true, Secs2.uint4(9001L), 6, 22); + + // -------- Remote control (E30 §6.15) -------------------------- + // S2F41 → S2F42 (modern host command), DATAID-less form: + // body: >>> + // The equipment.yaml binds START → emit_ceid=300, so this + // also triggers an unsolicited S6F11 we observe below. + roundtrip(comm, "S2F41 → S2F42 (RCMD=START)", + 2, 41, true, + Secs2.list(Secs2.ascii("START"), Secs2.list()), + 2, 42); + + // Give the equipment 400ms to emit the linked S6F11 to us. + Thread.sleep(400); + boolean sawS6F11 = observed.stream() + .anyMatch(p -> p[0] == 6 && p[1] == 11); + check("S6F11 received after RCMD=START (unsolicited)", + sawS6F11, + sawS6F11 ? "observed" : "no S6F11 in 400ms"); + + // S2F21 → S2F22 (legacy remote command, no params) + roundtrip(comm, "S2F21 → S2F22 (legacy RCMD=STOP)", + 2, 21, true, Secs2.ascii("STOP"), 2, 22); + + // -------- Alarm management (E30 §6.14) ------------------------ + + // S5F5 → S5F6 (list alarms) — empty list = all + roundtrip(comm, "S5F5 → S5F6 (list alarms, all)", + 5, 5, true, Secs2.list(), 5, 6); + + // S5F7 → S5F8 (list enabled) + roundtrip(comm, "S5F7 → S5F8 (list enabled alarms)", + 5, 7, true, Secs2.empty(), 5, 8); + + // S5F3 → S5F4 (enable alarm 1). ALED bit-7 set = enable. + // body: > + roundtrip(comm, "S5F3 → S5F4 (enable ALID=1)", + 5, 3, true, + Secs2.list(Secs2.binary((byte)0x80), Secs2.uint4(1L)), + 5, 4); + + // Trigger FAULT host command — equipment.yaml binds it to + // alarm 1 → emits S5F1. + roundtrip(comm, "S2F41 → S2F42 (RCMD=FAULT)", + 2, 41, true, + Secs2.list(Secs2.ascii("FAULT"), Secs2.list()), + 2, 42); + Thread.sleep(400); + boolean sawS5F1 = observed.stream() + .anyMatch(p -> p[0] == 5 && p[1] == 1); + check("S5F1 received after RCMD=FAULT (unsolicited)", + sawS5F1, + sawS5F1 ? "observed" : "no S5F1 in 400ms"); + + // -------- Spool (E30 §6.22) ----------------------------------- + + // S2F43 → S2F44 (reset spoolable streams) + // body: > + roundtrip(comm, "S2F43 → S2F44 (reset spool to {5, 6})", + 2, 43, true, + Secs2.list(Secs2.uint1(5), Secs2.uint1(6)), + 2, 44); + + // S6F23 → S6F24 (request spool data — Transmit=0) + // body: + roundtrip(comm, "S6F23 → S6F24 (request spool transmit)", + 6, 23, true, Secs2.binary((byte)0x00), 6, 24); + + // -------- Process program management (E30 §6.17) -------------- + + // S7F19 → S7F20 (current PPID list) + roundtrip(comm, "S7F19 → S7F20 (current PPIDs)", + 7, 19, true, Secs2.empty(), 7, 20); + + // S7F5 → S7F6 (request PP RECIPE-A) + roundtrip(comm, "S7F5 → S7F6 (request RECIPE-A)", + 7, 5, true, Secs2.ascii("RECIPE-A"), 7, 6); + + // S7F1 → S7F2 (PP load inquire) + // body: > + roundtrip(comm, "S7F1 → S7F2 (PP load inquire RECIPE-NEW)", + 7, 1, true, + Secs2.list(Secs2.ascii("RECIPE-NEW"), Secs2.uint4(128L)), + 7, 2); + + // S7F3 → S7F4 (PP send) + // body: > + roundtrip(comm, "S7F3 → S7F4 (send PP RECIPE-J)", + 7, 3, true, + Secs2.list(Secs2.ascii("RECIPE-J"), + Secs2.binary("from-java".getBytes())), + 7, 4); + + // S7F17 → S7F18 (delete PP) + // body: > + roundtrip(comm, "S7F17 → S7F18 (delete RECIPE-J)", + 7, 17, true, Secs2.list(Secs2.ascii("RECIPE-J")), + 7, 18); + + // -------- Limits monitoring (E30 §6.21) ----------------------- + + // S2F47 → S2F48 (variable limit attributes) — empty list = all + roundtrip(comm, "S2F47 → S2F48 (variable limit attributes)", + 2, 47, true, Secs2.list(), 2, 48); + + // S2F45 → S2F46 (define variable limits). Body: + // >>>> + // Define an empty limits set on VID 1 — proves the body parses. + roundtrip(comm, "S2F45 → S2F46 (define variable limits, empty set)", + 2, 45, true, + Secs2.list( + Secs2.list(Secs2.uint4(1L), Secs2.list())), + 2, 46); + + // -------- Trace (E30 §6.12) ----------------------------------- + // body: + // >> + roundtrip(comm, "S2F23 → S2F24 (trace init, 5-field body)", + 2, 23, true, + Secs2.list( + Secs2.uint4(8002L), + Secs2.ascii("000002"), + Secs2.uint4(0L), + Secs2.uint4(2L), + Secs2.list(Secs2.uint4(1L), Secs2.uint4(2L))), + 2, 24); + + // -------- Terminal services (E30 §6.19) ----------------------- + + // S10F3 → S10F4 (terminal display single, host→equipment) + // body: > + roundtrip(comm, "S10F3 → S10F4 (terminal display single)", + 10, 3, true, + Secs2.list(Secs2.binary((byte)0x00), + Secs2.ascii("hello from secs4j")), + 10, 4); + + // S10F5 → S10F6 (terminal display multi-line) + // body: >> + roundtrip(comm, "S10F5 → S10F6 (terminal multi-line)", + 10, 5, true, + Secs2.list( + Secs2.binary((byte)0x00), + Secs2.list(Secs2.ascii("line A"), Secs2.ascii("line B"))), + 10, 6); + + // -------- E40 process jobs with full body -------------------- + // S16F11 → S16F12 (PRJobCreate, full E40 body). This is + // the body that defeated secsgem-py's SFDL grammar. + // body: + // + // + // >>> + // > + // >>> + roundtrip(comm, "S16F11 → S16F12 (E40 PJ create, full body)", + 16, 11, true, + Secs2.list( + Secs2.ascii("PJ-SECS4J-2"), + Secs2.binary((byte)0x01), // MF = Substrate + Secs2.binary((byte)0x02), // RM = RecipeOnly + Secs2.list( + Secs2.ascii("RECIPE-A"), + Secs2.list()), // no rcpvars + Secs2.list(Secs2.ascii("WFR-J-1")), + Secs2.list()), // no params + 16, 12); + + // S16F7 → S16F8 (PRJobMonitor) + // body: >> + roundtrip(comm, "S16F7 → S16F8 (PJ monitor)", + 16, 7, true, + Secs2.list( + Secs2.list(Secs2.ascii("PJ-SECS4J-2"), + Secs2.binary((byte)0x80))), + 16, 8); + + // S16F13 → S16F14 (PRJobDequeue) + // body: + roundtrip(comm, "S16F13 → S16F14 (PJ dequeue)", + 16, 13, true, Secs2.ascii("PJ-SECS4J-2"), 16, 14); + + // -------- E39 generic object service -------------------------- + // S14F1 → S14F2 (GetAttr) — Denied_UnknownObject is a + // well-formed F-coded reply for an unknown OBJSPEC. + // body: >> + roundtrip(comm, "S14F1 → S14F2 (E39 GetAttr unknown object)", + 14, 1, true, + Secs2.list( + Secs2.ascii("OBJ-NONEXISTENT"), + Secs2.ascii("EQUIPMENT"), + Secs2.list()), + 14, 2); + // -------- GEM 300 streams: the gap secsgem-py couldn't fill ---- // S3F17 → S3F18 (E87 carrier action):