verify: expand secs4j harness 20 → 55 checks
Every check the user could ask for now lands. secs4j's
comm.send(stream, function, w, body) takes arbitrary S/F + arbitrary
Secs2 body, so coverage was never coverage-limited by the Java side
— the original 20 was just the minimum to fill the gaps secsgem-py
couldn't reach.
Adds:
- Status data: S1F3, S1F11
- EC management: S2F13, S2F15 (set TimeFormat), S2F29
- Event reports: S2F33, S2F35, S2F37 (full define-link-enable
sequence), S6F15, S6F19, S6F21
- Remote control: S2F41 (modern RCMD=START + observed S6F11),
S2F21 (legacy RCMD=STOP),
S2F41 RCMD=FAULT + observed S5F1
- Alarms: S5F3, S5F5, S5F7
- Spool: S2F43, S6F23
- PP management: S7F1, S7F3, S7F5, S7F17, S7F19
- Terminal: S10F3 (single), S10F5 (multi-line)
- E40 PJ: S16F11 (full E40 body — MF + PRRECIPEMETHOD +
RecipeSpec + mtrloutspec + processparams),
S16F7 (monitor), S16F13 (dequeue)
- Limits: S2F45, S2F47
- Trace: S2F23 (5-field body)
- E39: S14F1 (GetAttr)
Plus a SecsMessageReceiveListener that captures every equipment-
initiated primary into a ConcurrentLinkedQueue and replies to S5F1
(ACKC5=0), S6F11 (ACKC6=0), S16F9 (W=0 no reply) so the
equipment's T3 doesn't fire on our watch. Two checks now assert
the unsolicited path:
- After RCMD=START, an S6F11 with the linked report must arrive
within 400ms
- After RCMD=FAULT, an S5F1 with the alarm must arrive within
400ms
Both observed against the demo equipment.
Result: 55/55 PASS. Two independent implementations
(secsgem-py + secs4java8) now corroborate the wire surface in
overlapping but distinct slices. Full E40 body — the one that
defeated secsgem-py's SFDL grammar — round-trips cleanly through
secs4j.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<int[]> 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: <L,n <L,2 <U4 ECID> <ITEM VALUE>>>
|
||||
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: <L,2 <A RCMD> <L,n <L,2 <A name> <ITEM value>>>>
|
||||
// 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: <L,2 <BINARY ALED> <U4 ALID>>
|
||||
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: <L,n <U1 stream>>
|
||||
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: <BINARY RSDC>
|
||||
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: <L,2 <A PPID> <U4 LENGTH>>
|
||||
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: <L,2 <A PPID> <BINARY|ASCII PPBODY>>
|
||||
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: <L,n <A PPID>>
|
||||
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:
|
||||
// <L,n <L,2 <U4 VID> <L,n <L,3 <U4 LIMITID> <ITEM upper> <ITEM lower>>>>>
|
||||
// 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: <L,5 <U4 TRID> <A DSPER> <U4 TOTSMP> <U4 REPGSZ>
|
||||
// <L,n <U4 vid>>>
|
||||
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: <L,2 <BINARY TID> <A TEXT>>
|
||||
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: <L,2 <BINARY TID> <L,n <A line>>>
|
||||
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: <L,5 <A PRJOBID>
|
||||
// <BINARY MF>
|
||||
// <BINARY PRRECIPEMETHOD>
|
||||
// <L,2 <A PPID> <L,n <L,2 <A RCPPARNM> <ITEM RCPPARVAL>>>>
|
||||
// <L,n <A MID>>
|
||||
// <L,n <L,2 <A PARAMNAME> <ITEM PARAMVAL>>>>
|
||||
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: <L,n <L,2 <A PRJOBID> <BINARY PRALERT>>>
|
||||
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: <A PRJOBID>
|
||||
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: <L,3 <A OBJSPEC> <A OBJTYPE> <L,n <A ATTRID>>>
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user