ci-server-engineer/tools/ModbusSim.java
cliu 516729ff24 R1.05 findings from a user project (tested only)
Tested on the R1.05 test server 2026-10-09..12 with a user project (own
objects only); not yet on CI-Eng-Sample (ISS-188). Only results that
were tested are included; inferences, code-only and manual-only points
and the untested sample layout change were left out.
- layouts: 'logout' is a secondary logout, always disabled on the R1.05
  web client; 'exit' closes the window and ends the session (ISS-182).
- create-display: R1.05 web client table (String items in text vs
  textField, fixed text alignment, start-hidden limits ISS-183, two
  visibility conditions, On entry display actions, rectangle blinking,
  verified connections, first load).
- java-class 4a: signal arrays at scale, write on change, attribute
  arrays, offline javac/javap check; change-reset-class: new signals need
  the object recreated, attributes lost (ISS-185); opcdbg reset in cmd,
  TRIGGER_1 via dssqld -m.
- string-items (ISS-187), users-groups, modbus-station (400 registers,
  comm loss), export-dataset (NAME range filter), item-alarming.
- new task system-management/package-project.md; tools/ModbusSim.java
  (tested on R1.05: FC03/04/06/16, exceptions, modes); AMSI (ISS-186).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:57:08 +11:00

142 lines
6.5 KiB
Java

/*
* ModbusSim: a small Modbus TCP server (slave) for testing a CI Server Modbus station without the
* real device. Java 11 single-file program: runs with the JDK that ships with CI Server, nothing
* to install (a PowerShell TCP listener script is blocked by Windows antivirus/AMSI as
* "malicious content", see knowledge/issues.md).
*
* "%TLS_ROOT_PATH%\tls\jre\bin\java.exe" ModbusSim.java [port] [registers] [folder]
* defaults: 15020, 1000, the current folder. Listens on 127.0.0.1 only.
*
* One table of holding registers (FC03 read, FC06 / FC16 write); FC04 reads the same table as input
* registers (like CI Server's own slave mirror). FC07 answers 0, FC08 echoes, others exception 01.
* Register 1 = protocol address 0 (CI Server point RO:1 / RI:1 = 40001 / 30001).
*
* <folder>/mode.txt is read every 2 s and can be changed while it runs:
* pattern raw(k) = ((k * 157) mod 30000) - 5000 (fixed, signed test values; the default)
* zero all registers 0
* csv values from <folder>/values.csv: one integer per line, line k = register k
* hold keep the current values (use with writes from CI Server)
* stop close and exit
* Messages go to the console and <folder>/sim.log.
* To keep it running after an SSH session ends, start it detached, e.g. with PowerShell
* Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{CommandLine='...'}
* Never run it on a production system or on a port a real device uses.
*/
import java.io.*;
import java.net.*;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class ModbusSim {
static int n;
static int[] reg;
static volatile String mode = "";
static Path folder;
public static void main(String[] args) throws Exception {
int port = args.length > 0 ? Integer.parseInt(args[0]) : 15020;
n = args.length > 1 ? Integer.parseInt(args[1]) : 1000;
folder = Paths.get(args.length > 2 ? args[2] : ".").toAbsolutePath();
reg = new int[n + 1];
Path modeFile = folder.resolve("mode.txt");
if (!Files.exists(modeFile)) Files.writeString(modeFile, "pattern");
ServerSocket server = new ServerSocket(port, 10, InetAddress.getLoopbackAddress());
log("listening on 127.0.0.1:" + port + ", " + n + " registers, pid " + ProcessHandle.current().pid());
Thread control = new Thread(() -> {
while (true) {
try {
String m = Files.readAllLines(modeFile).stream().findFirst().orElse("").trim().toLowerCase();
if (m.equals("stop")) { log("stop requested"); System.exit(0); }
if (!m.equals(mode)) { mode = m; log("mode " + m); apply(m); }
Thread.sleep(2000);
} catch (Exception e) { log("control: " + e); }
}
});
control.setDaemon(true);
control.start();
while (true) {
Socket c = server.accept();
new Thread(() -> serve(c)).start();
}
}
static synchronized void apply(String m) throws IOException {
if (m.equals("pattern")) for (int k = 1; k <= n; k++) reg[k] = ((k * 157) % 30000) - 5000;
else if (m.equals("zero")) java.util.Arrays.fill(reg, 0);
else if (m.equals("csv")) {
java.util.List<String> lines = Files.readAllLines(folder.resolve("values.csv"));
for (int k = 1; k <= n && k <= lines.size(); k++) reg[k] = Integer.parseInt(lines.get(k - 1).trim());
}
}
static void serve(Socket c) {
String who = String.valueOf(c.getRemoteSocketAddress());
log("connect " + who);
try (c; DataInputStream in = new DataInputStream(new BufferedInputStream(c.getInputStream()));
OutputStream out = c.getOutputStream()) {
while (true) {
byte[] h = new byte[7];
in.readFully(h);
int len = ((h[4] & 0xFF) << 8) | (h[5] & 0xFF);
if (len < 2 || len > 256) throw new IOException("bad length " + len);
byte[] pdu = new byte[len - 1];
in.readFully(pdu);
byte[] r = handle(pdu);
byte[] resp = new byte[7 + r.length];
System.arraycopy(h, 0, resp, 0, 4);
resp[4] = (byte) ((r.length + 1) >> 8); resp[5] = (byte) (r.length + 1); resp[6] = h[6];
System.arraycopy(r, 0, resp, 7, r.length);
out.write(resp);
out.flush();
}
} catch (EOFException e) {
log("disconnect " + who);
} catch (Exception e) {
log("disconnect " + who + " (" + e + ")");
}
}
static int u16(byte[] b, int i) { return ((b[i] & 0xFF) << 8) | (b[i + 1] & 0xFF); }
static synchronized byte[] handle(byte[] pdu) {
int fc = pdu[0] & 0xFF;
if (fc == 3 || fc == 4) {
int start = u16(pdu, 1), qty = u16(pdu, 3);
if (qty < 1 || qty > 125) return new byte[]{(byte) (fc | 0x80), 3};
if (start + qty > n) return new byte[]{(byte) (fc | 0x80), 2};
byte[] r = new byte[2 + 2 * qty];
r[0] = (byte) fc; r[1] = (byte) (2 * qty);
for (int i = 0; i < qty; i++) {
int v = reg[start + i + 1] & 0xFFFF;
r[2 + 2 * i] = (byte) (v >> 8); r[3 + 2 * i] = (byte) v;
}
return r;
}
if (fc == 6) {
int a = u16(pdu, 1);
if (a >= n) return new byte[]{(byte) 0x86, 2};
reg[a + 1] = (short) u16(pdu, 3);
return pdu.clone();
}
if (fc == 16) {
int start = u16(pdu, 1), qty = u16(pdu, 3);
if (qty < 1 || qty > 123 || start + qty > n) return new byte[]{(byte) 0x90, 2};
for (int i = 0; i < qty; i++) reg[start + i + 1] = (short) u16(pdu, 6 + 2 * i);
return new byte[]{16, pdu[1], pdu[2], pdu[3], pdu[4]};
}
if (fc == 7) return new byte[]{7, 0};
if (fc == 8) return pdu.clone();
return new byte[]{(byte) (fc | 0x80), 1};
}
static synchronized void log(String s) {
String line = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " " + s;
System.out.println(line);
try {
Files.writeString(folder.resolve("sim.log"), line + System.lineSeparator(),
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException ignored) { }
}
}