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>
142 lines
6.5 KiB
Java
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) { }
|
|
}
|
|
}
|