first commit

This commit is contained in:
2026-04-03 13:26:45 +08:00
commit 2f28447a16
11 changed files with 1974 additions and 0 deletions

101
README.md Normal file
View File

@ -0,0 +1,101 @@
# EMS Modbus 模拟器
一个基于 JavaFX 的 Modbus TCP 从站模拟器,监听端口固定为 `4567`
## 功能
- 图形界面查看四类数据区:
- `Holding Register`
- `Input Register`
- `Coil`
- `Discrete Input`
- 手动写入四类数据区(支持地址和值边界限制)
- 设备场景模拟(可启停):
- 温度波动
- 压力变化
- 告警联动
- 泵状态联动
- 场景脚本编辑器(界面内可配置波形、阈值、联动输出地址)
- 故障注入:
- 响应延迟
- 强制异常码(按功能码或全部功能码)
- 收到请求后断开连接
- 点位配置导入:支持 `CSV/XLSX`
- 通信日志 + 报文 HEX 明细(`RX/TX`
- 支持标准 Modbus TCP 功能码:
- `0x01` 读 Coil
- `0x02` 读离散输入
- `0x03` 读保持寄存器
- `0x04` 读输入寄存器
- `0x05` 写单个 Coil
- `0x06` 写单个保持寄存器
- `0x0F` 写多个 Coil
- `0x10` 写多个保持寄存器
## 运行
要求:
- JDK 17+
- Maven 3.9+
在项目目录执行:
```bash
mvn javafx:run
```
## 测试连接参数
- Host: `127.0.0.1`
- Port: `4567`
- Unit ID: `1`(可自定义)
## 点位导入格式
### CSV/XLSX 列
- `area`
- `address`
- `value`
### area 可用值
- `holding` / `holding_register` / `hr`
- `input` / `input_register` / `ir`
- `coil`
- `discrete` / `discrete_input` / `di`
### 示例CSV
```csv
area,address,value
holding,10,350
input,1,1015
coil,0,1
discrete,0,0
```
## 场景脚本示例
```ini
temp.base=25
temp.amplitude=12
temp.step=0.22
pressure.base=1000
pressure.amplitude=20
pressure.factor=0.8
threshold.mode=holding
threshold.holding.addr=10
# threshold.fixed.value=320
pump.margin=20
output.temp.input=0
output.temp.holding=0
output.pressure.input=1
output.pump.coil=0
output.alarm.discrete=0
```

142
pom.xml Normal file
View File

@ -0,0 +1,142 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ems</groupId>
<artifactId>ems-modbus-simulator</artifactId>
<version>1.0.0</version>
<name>EMS Modbus Simulator</name>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<javafx.version>21.0.2</javafx.version>
<javafx.platform>mac-aarch64</javafx.platform>
</properties>
<dependencies>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>${javafx.version}</version>
<classifier>${javafx.platform}</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>${javafx.version}</version>
<classifier>${javafx.platform}</classifier>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>${javafx.version}</version>
<classifier>${javafx.platform}</classifier>
<exclusions>
<exclusion>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
</exclusion>
<exclusion>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>17</release>
</configuration>
</plugin>
<plugin>
<groupId>org.openjfx</groupId>
<artifactId>javafx-maven-plugin</artifactId>
<version>0.0.8</version>
<configuration>
<mainClass>com.ems.modbus.ModbusSimulatorApp</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration>
<archive>
<manifest>
<mainClass>com.ems.modbus.MainLauncher</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.2</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.ems.modbus.MainLauncher</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>windows-dist</id>
<properties>
<javafx.platform>win</javafx.platform>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.7.1</version>
<executions>
<execution>
<id>build-windows-zip</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<descriptors>
<descriptor>src/assembly/windows.xml</descriptor>
</descriptors>
<finalName>${project.artifactId}-${project.version}-windows</finalName>
<appendAssemblyId>false</appendAssemblyId>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

29
src/assembly/windows.xml Normal file
View File

@ -0,0 +1,29 @@
<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.1.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.1.1 https://maven.apache.org/xsd/assembly-2.1.1.xsd">
<id>windows</id>
<formats>
<format>zip</format>
</formats>
<includeBaseDirectory>true</includeBaseDirectory>
<baseDirectory>${project.artifactId}-${project.version}-windows</baseDirectory>
<files>
<file>
<source>${project.build.directory}/${project.build.finalName}.jar</source>
<outputDirectory>/</outputDirectory>
<destName>${project.build.finalName}.jar</destName>
</file>
</files>
<fileSets>
<fileSet>
<directory>src/main/dist/windows</directory>
<outputDirectory>/</outputDirectory>
<includes>
<include>*.bat</include>
<include>README.txt</include>
</includes>
</fileSet>
</fileSets>
</assembly>

5
src/main/dist/windows/README.txt vendored Normal file
View File

@ -0,0 +1,5 @@
Windows 使用说明
1. 保持本目录中的文件结构不变。
2. 双击 run-ems-modbus-simulator.bat 启动程序。
3. 如果无法启动,请先安装 64 位 Java 17 或更高版本,再重新运行。

View File

@ -0,0 +1,13 @@
@echo off
setlocal
set APP_HOME=%~dp0
set APP_JAR=%APP_HOME%ems-modbus-simulator-1.0.0.jar
java -jar "%APP_JAR%"
if errorlevel 1 (
echo.
echo 应用启动失败,请确认已经安装 64 位 Java 17 或更高版本。
pause
)

View File

@ -0,0 +1,43 @@
package com.ems.modbus;
public class FaultInjectionConfig {
private volatile int responseDelayMs;
private volatile int forcedExceptionCode;
private volatile int forcedExceptionFunction;
private volatile boolean disconnectAfterRequest;
public int getResponseDelayMs() {
return responseDelayMs;
}
public void setResponseDelayMs(int responseDelayMs) {
this.responseDelayMs = Math.max(0, responseDelayMs);
}
public int getForcedExceptionCode() {
return forcedExceptionCode;
}
public void setForcedExceptionCode(int forcedExceptionCode) {
if (forcedExceptionCode < 0 || forcedExceptionCode > 11) {
throw new IllegalArgumentException("异常码必须在 0-11 之间");
}
this.forcedExceptionCode = forcedExceptionCode;
}
public int getForcedExceptionFunction() {
return forcedExceptionFunction;
}
public void setForcedExceptionFunction(int forcedExceptionFunction) {
this.forcedExceptionFunction = forcedExceptionFunction;
}
public boolean isDisconnectAfterRequest() {
return disconnectAfterRequest;
}
public void setDisconnectAfterRequest(boolean disconnectAfterRequest) {
this.disconnectAfterRequest = disconnectAfterRequest;
}
}

View File

@ -0,0 +1,10 @@
package com.ems.modbus;
public final class MainLauncher {
private MainLauncher() {
}
public static void main(String[] args) {
ModbusSimulatorApp.main(args);
}
}

View File

@ -0,0 +1,789 @@
package com.ems.modbus;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ComboBox;
import javafx.scene.control.Label;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.scene.control.SplitPane;
import javafx.scene.control.Tab;
import javafx.scene.control.TabPane;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextArea;
import javafx.scene.control.TextField;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import java.io.File;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ModbusSimulatorApp extends Application {
private static final int DEFAULT_PORT = 4567;
private static final int DEFAULT_REGISTER_COUNT = 200;
private static final int DEFAULT_BIT_COUNT = 256;
private final ObservableList<RowItem> tableRows = FXCollections.observableArrayList();
private RegisterStore registerStore;
private ModbusTcpSlaveServer server;
private ScheduledExecutorService refreshExecutor;
private ScheduledExecutorService scenarioExecutor;
private final FaultInjectionConfig faultConfig = new FaultInjectionConfig();
private volatile ScenarioConfig scenarioConfig = ScenarioConfig.defaultConfig();
private volatile double scenarioAngle = 0.0;
private volatile boolean lastAlarmState = false;
private Label statusLabel;
private Label scenarioLabel;
private TextArea logArea;
private TextArea hexArea;
private TextArea scriptArea;
private Button startButton;
private Button stopButton;
private Button startScenarioButton;
private Button stopScenarioButton;
private TextField portField;
private ComboBox<DataArea> viewAreaCombo;
private ComboBox<DataArea> writeAreaCombo;
private Spinner<Integer> writeAddressSpinner;
private Spinner<Integer> writeValueSpinner;
private Spinner<Integer> delaySpinner;
private Spinner<Integer> exceptionCodeSpinner;
private ComboBox<String> exceptionFunctionCombo;
private CheckBox disconnectCheck;
@Override
public void start(Stage stage) {
registerStore = new RegisterStore(DEFAULT_REGISTER_COUNT, DEFAULT_BIT_COUNT);
initDefaultData();
BorderPane root = new BorderPane();
root.setPadding(new Insets(12));
root.setTop(buildTopPanel(stage));
root.setCenter(buildCenterPanel());
Scene scene = new Scene(root, 1300, 780);
stage.setTitle("EMS Modbus 模拟器 (TCP 从站)");
stage.setScene(scene);
stage.show();
refreshTable();
startRefreshTask();
appendLog("应用已启动,端口固定 4567");
stage.setOnCloseRequest(event -> {
stopServer();
stopScenario();
stopRefreshTask();
});
}
private void initDefaultData() {
registerStore.writeHolding(10, 320);
registerStore.writeInput(0, 250);
registerStore.writeInput(1, 1013);
registerStore.writeHolding(0, 250);
registerStore.writeCoil(0, false);
registerStore.writeDiscreteInput(0, false);
}
private VBox buildTopPanel(Stage stage) {
VBox top = new VBox(8);
HBox line1 = new HBox(10);
line1.setAlignment(Pos.CENTER_LEFT);
Label portLabel = new Label("端口:");
portField = new TextField(String.valueOf(DEFAULT_PORT));
portField.setPrefWidth(100);
portField.setEditable(false);
startButton = new Button("启动从站");
stopButton = new Button("停止从站");
stopButton.setDisable(true);
Button importButton = new Button("导入点位(CSV/Excel)");
statusLabel = new Label("状态: 未启动");
startButton.setOnAction(e -> startServer());
stopButton.setOnAction(e -> stopServer());
importButton.setOnAction(e -> importPointConfig(stage));
line1.getChildren().addAll(portLabel, portField, startButton, stopButton, importButton, statusLabel);
HBox line2 = new HBox(10);
line2.setAlignment(Pos.CENTER_LEFT);
startScenarioButton = new Button("启动设备场景");
stopScenarioButton = new Button("停止设备场景");
stopScenarioButton.setDisable(true);
scenarioLabel = new Label("场景: 未启动");
startScenarioButton.setOnAction(e -> startScenario());
stopScenarioButton.setOnAction(e -> stopScenario());
line2.getChildren().addAll(startScenarioButton, stopScenarioButton, scenarioLabel);
top.getChildren().addAll(line1, line2);
return top;
}
private SplitPane buildCenterPanel() {
SplitPane splitPane = new SplitPane();
splitPane.setDividerPositions(0.62);
TabPane leftTabs = new TabPane();
leftTabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
Tab dataTab = new Tab("数据与注入", buildDataAndFaultPanel());
Tab scriptTab = new Tab("场景脚本", buildScenarioScriptPanel());
leftTabs.getTabs().addAll(dataTab, scriptTab);
VBox right = new VBox(8);
Label logLabel = new Label("通信日志");
logArea = new TextArea();
logArea.setEditable(false);
logArea.setWrapText(true);
Label hexLabel = new Label("报文 HEXRX/TX");
hexArea = new TextArea();
hexArea.setEditable(false);
hexArea.setWrapText(true);
VBox.setVgrow(logArea, Priority.ALWAYS);
VBox.setVgrow(hexArea, Priority.ALWAYS);
right.getChildren().addAll(logLabel, logArea, hexLabel, hexArea);
splitPane.getItems().addAll(leftTabs, right);
return splitPane;
}
private VBox buildDataAndFaultPanel() {
VBox root = new VBox(10);
root.setPadding(new Insets(8));
VBox tablePanel = buildTablePanel();
HBox manualPanel = buildManualWritePanel();
VBox faultPanel = buildFaultPanel();
root.getChildren().addAll(tablePanel, manualPanel, faultPanel);
VBox.setVgrow(tablePanel, Priority.ALWAYS);
return root;
}
private VBox buildTablePanel() {
VBox box = new VBox(8);
HBox toolbar = new HBox(8);
toolbar.setAlignment(Pos.CENTER_LEFT);
viewAreaCombo = new ComboBox<>();
viewAreaCombo.getItems().addAll(DataArea.values());
viewAreaCombo.setValue(DataArea.HOLDING_REGISTER);
viewAreaCombo.valueProperty().addListener((obs, oldV, newV) -> refreshTable());
toolbar.getChildren().addAll(new Label("查看数据区:"), viewAreaCombo);
TableView<RowItem> table = new TableView<>(tableRows);
TableColumn<RowItem, Number> addressCol = new TableColumn<>("地址");
addressCol.setCellValueFactory(data -> data.getValue().addressProperty());
addressCol.setPrefWidth(120);
TableColumn<RowItem, Number> valueCol = new TableColumn<>("");
valueCol.setCellValueFactory(data -> data.getValue().valueProperty());
valueCol.setPrefWidth(180);
table.getColumns().addAll(addressCol, valueCol);
table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY_ALL_COLUMNS);
box.getChildren().addAll(toolbar, table);
VBox.setVgrow(table, Priority.ALWAYS);
return box;
}
private HBox buildManualWritePanel() {
HBox box = new HBox(8);
box.setAlignment(Pos.CENTER_LEFT);
writeAreaCombo = new ComboBox<>();
writeAreaCombo.getItems().addAll(DataArea.HOLDING_REGISTER, DataArea.COIL, DataArea.INPUT_REGISTER, DataArea.DISCRETE_INPUT);
writeAreaCombo.setValue(DataArea.HOLDING_REGISTER);
writeAreaCombo.valueProperty().addListener((obs, oldV, newV) -> updateWriteValueRange());
writeAddressSpinner = new Spinner<>();
writeAddressSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, registerStore.registerSize() - 1, 0));
writeAddressSpinner.setEditable(true);
writeAddressSpinner.setPrefWidth(110);
writeValueSpinner = new Spinner<>();
writeValueSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, 65535, 0));
writeValueSpinner.setEditable(true);
writeValueSpinner.setPrefWidth(130);
Button writeButton = new Button("手动写入");
writeButton.setOnAction(e -> manualWrite());
box.getChildren().addAll(
new Label("写入数据区:"), writeAreaCombo,
new Label("地址:"), writeAddressSpinner,
new Label("值:"), writeValueSpinner,
writeButton
);
return box;
}
private VBox buildFaultPanel() {
VBox box = new VBox(8);
box.setPadding(new Insets(8));
box.setStyle("-fx-border-color: #d0d0d0; -fx-border-radius: 4; -fx-background-radius: 4;");
Label title = new Label("故障注入");
HBox line = new HBox(8);
line.setAlignment(Pos.CENTER_LEFT);
delaySpinner = new Spinner<>(0, 10000, 0, 50);
delaySpinner.setEditable(true);
delaySpinner.setPrefWidth(100);
exceptionFunctionCombo = new ComboBox<>();
exceptionFunctionCombo.getItems().addAll("全部", "01", "02", "03", "04", "05", "06", "15", "16");
exceptionFunctionCombo.setValue("全部");
exceptionFunctionCombo.setPrefWidth(90);
exceptionCodeSpinner = new Spinner<>(0, 11, 0);
exceptionCodeSpinner.setEditable(true);
exceptionCodeSpinner.setPrefWidth(80);
disconnectCheck = new CheckBox("收到请求后断开连接");
Button applyButton = new Button("应用注入");
applyButton.setOnAction(e -> applyFaultInjection());
Button clearButton = new Button("清空注入");
clearButton.setOnAction(e -> clearFaultInjection());
line.getChildren().addAll(
new Label("延迟(ms):"), delaySpinner,
new Label("功能码:"), exceptionFunctionCombo,
new Label("异常码(0禁用):"), exceptionCodeSpinner,
disconnectCheck,
applyButton,
clearButton
);
box.getChildren().addAll(title, line);
return box;
}
private VBox buildScenarioScriptPanel() {
VBox root = new VBox(8);
root.setPadding(new Insets(8));
Label label = new Label("场景脚本编辑器key=value一行一项");
scriptArea = new TextArea();
scriptArea.setWrapText(false);
scriptArea.setText(ScenarioConfig.defaultScriptText());
HBox actions = new HBox(8);
Button applyButton = new Button("应用脚本");
Button resetButton = new Button("恢复默认脚本");
applyButton.setOnAction(e -> applyScenarioScript());
resetButton.setOnAction(e -> scriptArea.setText(ScenarioConfig.defaultScriptText()));
actions.getChildren().addAll(applyButton, resetButton);
root.getChildren().addAll(label, scriptArea, actions);
VBox.setVgrow(scriptArea, Priority.ALWAYS);
return root;
}
private void updateWriteValueRange() {
DataArea area = writeAreaCombo.getValue();
int currentAddress = writeAddressSpinner.getValue() == null ? 0 : writeAddressSpinner.getValue();
int currentValue = writeValueSpinner.getValue() == null ? 0 : writeValueSpinner.getValue();
if (area == DataArea.COIL || area == DataArea.DISCRETE_INPUT) {
int address = Math.min(Math.max(currentAddress, 0), registerStore.bitSize() - 1);
writeAddressSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, registerStore.bitSize() - 1, address));
writeValueSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, 1, currentValue > 0 ? 1 : 0));
} else {
int address = Math.min(Math.max(currentAddress, 0), registerStore.registerSize() - 1);
int value = Math.min(Math.max(currentValue, 0), 65535);
writeAddressSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, registerStore.registerSize() - 1, address));
writeValueSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, 65535, value));
}
}
private void manualWrite() {
int address = writeAddressSpinner.getValue();
int value = writeValueSpinner.getValue();
DataArea area = writeAreaCombo.getValue();
try {
switch (area) {
case HOLDING_REGISTER -> registerStore.writeHolding(address, value);
case INPUT_REGISTER -> registerStore.writeInput(address, value);
case COIL -> registerStore.writeCoil(address, value != 0);
case DISCRETE_INPUT -> registerStore.writeDiscreteInput(address, value != 0);
}
appendLog("手动写入 " + area + ": address=" + address + ", value=" + value);
refreshTable();
} catch (Exception ex) {
appendLog("手动写入失败: " + ex.getMessage());
}
}
private void applyFaultInjection() {
try {
int delay = delaySpinner.getValue();
int code = exceptionCodeSpinner.getValue();
int function = parseFunction(exceptionFunctionCombo.getValue());
faultConfig.setResponseDelayMs(delay);
faultConfig.setForcedExceptionCode(code);
faultConfig.setForcedExceptionFunction(function);
faultConfig.setDisconnectAfterRequest(disconnectCheck.isSelected());
appendLog("故障注入已应用: delay=" + delay + "ms, function=" + exceptionFunctionCombo.getValue()
+ ", code=" + code + ", disconnect=" + disconnectCheck.isSelected());
} catch (Exception e) {
appendLog("应用故障注入失败: " + e.getMessage());
}
}
private void clearFaultInjection() {
delaySpinner.getValueFactory().setValue(0);
exceptionCodeSpinner.getValueFactory().setValue(0);
exceptionFunctionCombo.setValue("全部");
disconnectCheck.setSelected(false);
faultConfig.setResponseDelayMs(0);
faultConfig.setForcedExceptionCode(0);
faultConfig.setForcedExceptionFunction(0);
faultConfig.setDisconnectAfterRequest(false);
appendLog("故障注入已清空");
}
private int parseFunction(String text) {
if (text == null || "全部".equals(text)) {
return 0;
}
return Integer.parseInt(text);
}
private void importPointConfig(Stage stage) {
FileChooser chooser = new FileChooser();
chooser.setTitle("选择点位配置文件");
chooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("CSV 文件", "*.csv"),
new FileChooser.ExtensionFilter("Excel 文件", "*.xlsx")
);
File file = chooser.showOpenDialog(stage);
if (file == null) {
return;
}
try {
PointConfigImporter.ImportResult result = PointConfigImporter.importFile(file, registerStore);
refreshTable();
appendLog("点位导入完成: file=" + file.getName()
+ ", total=" + result.total()
+ ", success=" + result.success()
+ ", failed=" + result.failed());
} catch (Exception e) {
appendLog("点位导入失败: " + e.getMessage());
}
}
private void applyScenarioScript() {
try {
ScenarioConfig newConfig = ScenarioConfig.fromScript(scriptArea.getText());
validateScenarioConfig(newConfig);
this.scenarioConfig = newConfig;
appendLog("场景脚本已应用");
} catch (Exception e) {
appendLog("场景脚本解析失败: " + e.getMessage());
}
}
private void validateScenarioConfig(ScenarioConfig config) {
checkRegisterAddress(config.thresholdHoldingAddress);
checkRegisterAddress(config.tempInputAddress);
checkRegisterAddress(config.tempHoldingAddress);
checkRegisterAddress(config.pressureInputAddress);
checkBitAddress(config.pumpCoilAddress);
checkBitAddress(config.alarmDiscreteAddress);
}
private void checkRegisterAddress(int address) {
if (address < 0 || address >= registerStore.registerSize()) {
throw new IllegalArgumentException("寄存器地址越界: " + address);
}
}
private void checkBitAddress(int address) {
if (address < 0 || address >= registerStore.bitSize()) {
throw new IllegalArgumentException("位地址越界: " + address);
}
}
private void startServer() {
if (server != null && server.isRunning()) {
appendLog("从站已在运行");
return;
}
server = new ModbusTcpSlaveServer(DEFAULT_PORT, registerStore, new ModbusTcpSlaveServer.Listener() {
@Override
public void onLog(String message) {
appendLog(message);
}
@Override
public void onFrame(String direction, String hex) {
appendHex(direction, hex);
}
}, faultConfig);
try {
server.start();
statusLabel.setText("状态: 运行中 (端口 4567)");
startButton.setDisable(true);
stopButton.setDisable(false);
} catch (IOException e) {
appendLog("启动失败: " + e.getMessage());
statusLabel.setText("状态: 启动失败");
}
}
private void stopServer() {
if (server != null) {
server.stop();
}
statusLabel.setText("状态: 未启动");
startButton.setDisable(false);
stopButton.setDisable(true);
}
private void startScenario() {
if (scenarioExecutor != null && !scenarioExecutor.isShutdown()) {
appendLog("设备场景已在运行");
return;
}
scenarioExecutor = Executors.newSingleThreadScheduledExecutor();
scenarioExecutor.scheduleAtFixedRate(this::runScenarioStep, 0, 1, TimeUnit.SECONDS);
scenarioLabel.setText("场景: 运行中");
startScenarioButton.setDisable(true);
stopScenarioButton.setDisable(false);
appendLog("设备场景已启动");
}
private void stopScenario() {
if (scenarioExecutor != null) {
scenarioExecutor.shutdownNow();
scenarioExecutor = null;
}
scenarioLabel.setText("场景: 未启动");
startScenarioButton.setDisable(false);
stopScenarioButton.setDisable(true);
appendLog("设备场景已停止");
}
private void runScenarioStep() {
ScenarioConfig cfg = scenarioConfig;
scenarioAngle += cfg.tempStep;
double temp = cfg.tempBase + cfg.tempAmplitude * Math.sin(scenarioAngle);
int tempRaw = (int) Math.round(temp * 10);
int threshold = cfg.thresholdFixedValue != null
? cfg.thresholdFixedValue
: registerStore.readHoldingRange(cfg.thresholdHoldingAddress, 1)[0];
double pressureWave = Math.cos(scenarioAngle * cfg.pressureFactor);
int pressure = (int) Math.round(cfg.pressureBase + cfg.pressureAmplitude * pressureWave);
boolean alarm = tempRaw >= threshold;
boolean pumpOn = tempRaw >= (threshold - cfg.pumpMargin);
registerStore.writeInput(cfg.tempInputAddress, tempRaw);
registerStore.writeHolding(cfg.tempHoldingAddress, tempRaw);
registerStore.writeInput(cfg.pressureInputAddress, pressure);
registerStore.writeCoil(cfg.pumpCoilAddress, pumpOn);
registerStore.writeDiscreteInput(cfg.alarmDiscreteAddress, alarm);
if (alarm && !lastAlarmState) {
appendLog("告警触发: 温度=" + tempRaw + " (阈值=" + threshold + ")");
}
if (!alarm && lastAlarmState) {
appendLog("告警恢复: 温度=" + tempRaw + " (阈值=" + threshold + ")");
}
lastAlarmState = alarm;
}
private void startRefreshTask() {
refreshExecutor = Executors.newSingleThreadScheduledExecutor();
refreshExecutor.scheduleAtFixedRate(() -> Platform.runLater(this::refreshTable), 400, 400, TimeUnit.MILLISECONDS);
}
private void stopRefreshTask() {
if (refreshExecutor != null) {
refreshExecutor.shutdownNow();
}
}
private void refreshTable() {
DataArea area = viewAreaCombo == null ? DataArea.HOLDING_REGISTER : viewAreaCombo.getValue();
tableRows.clear();
switch (area) {
case HOLDING_REGISTER -> {
int[] values = registerStore.snapshotHolding();
int max = Math.min(values.length, 180);
for (int i = 0; i < max; i++) {
tableRows.add(new RowItem(i, values[i] & 0xFFFF));
}
}
case INPUT_REGISTER -> {
int[] values = registerStore.snapshotInput();
int max = Math.min(values.length, 180);
for (int i = 0; i < max; i++) {
tableRows.add(new RowItem(i, values[i] & 0xFFFF));
}
}
case COIL -> {
boolean[] values = registerStore.snapshotCoils();
int max = Math.min(values.length, 240);
for (int i = 0; i < max; i++) {
tableRows.add(new RowItem(i, values[i] ? 1 : 0));
}
}
case DISCRETE_INPUT -> {
boolean[] values = registerStore.snapshotDiscreteInputs();
int max = Math.min(values.length, 240);
for (int i = 0; i < max; i++) {
tableRows.add(new RowItem(i, values[i] ? 1 : 0));
}
}
}
}
private void appendLog(String message) {
Platform.runLater(() -> {
logArea.appendText(message + "\n");
logArea.positionCaret(logArea.getText().length());
});
}
private void appendHex(String direction, String hex) {
Platform.runLater(() -> {
String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss.SSS"));
hexArea.appendText("[" + time + "] " + direction + " " + hex + "\n");
hexArea.positionCaret(hexArea.getText().length());
});
}
public static void main(String[] args) {
launch(args);
}
public enum DataArea {
HOLDING_REGISTER("Holding Register"),
INPUT_REGISTER("Input Register"),
COIL("Coil"),
DISCRETE_INPUT("Discrete Input");
private final String label;
DataArea(String label) {
this.label = label;
}
@Override
public String toString() {
return label;
}
}
public static class RowItem {
private final IntegerProperty address = new SimpleIntegerProperty();
private final IntegerProperty value = new SimpleIntegerProperty();
public RowItem(int address, int value) {
this.address.set(address);
this.value.set(value);
}
public IntegerProperty addressProperty() {
return address;
}
public IntegerProperty valueProperty() {
return value;
}
}
public static class ScenarioConfig {
private double tempBase;
private double tempAmplitude;
private double tempStep;
private double pressureBase;
private double pressureAmplitude;
private double pressureFactor;
private int thresholdHoldingAddress;
private Integer thresholdFixedValue;
private int pumpMargin;
private int tempInputAddress;
private int tempHoldingAddress;
private int pressureInputAddress;
private int pumpCoilAddress;
private int alarmDiscreteAddress;
static ScenarioConfig defaultConfig() {
ScenarioConfig c = new ScenarioConfig();
c.tempBase = 25.0;
c.tempAmplitude = 12.0;
c.tempStep = 0.22;
c.pressureBase = 1000.0;
c.pressureAmplitude = 20.0;
c.pressureFactor = 0.8;
c.thresholdHoldingAddress = 10;
c.thresholdFixedValue = null;
c.pumpMargin = 20;
c.tempInputAddress = 0;
c.tempHoldingAddress = 0;
c.pressureInputAddress = 1;
c.pumpCoilAddress = 0;
c.alarmDiscreteAddress = 0;
return c;
}
static String defaultScriptText() {
return "# 温度波形\n"
+ "temp.base=25\n"
+ "temp.amplitude=12\n"
+ "temp.step=0.22\n"
+ "\n"
+ "# 压力波形\n"
+ "pressure.base=1000\n"
+ "pressure.amplitude=20\n"
+ "pressure.factor=0.8\n"
+ "\n"
+ "# 阈值规则: fixed 或 holding\n"
+ "threshold.mode=holding\n"
+ "threshold.holding.addr=10\n"
+ "# threshold.fixed.value=320\n"
+ "\n"
+ "# 联动规则\n"
+ "pump.margin=20\n"
+ "\n"
+ "# 输出映射\n"
+ "output.temp.input=0\n"
+ "output.temp.holding=0\n"
+ "output.pressure.input=1\n"
+ "output.pump.coil=0\n"
+ "output.alarm.discrete=0\n";
}
static ScenarioConfig fromScript(String script) {
ScenarioConfig cfg = defaultConfig();
Map<String, String> kv = parseScript(script);
cfg.tempBase = parseDouble(kv, "temp.base", cfg.tempBase);
cfg.tempAmplitude = parseDouble(kv, "temp.amplitude", cfg.tempAmplitude);
cfg.tempStep = parseDouble(kv, "temp.step", cfg.tempStep);
cfg.pressureBase = parseDouble(kv, "pressure.base", cfg.pressureBase);
cfg.pressureAmplitude = parseDouble(kv, "pressure.amplitude", cfg.pressureAmplitude);
cfg.pressureFactor = parseDouble(kv, "pressure.factor", cfg.pressureFactor);
String mode = kv.getOrDefault("threshold.mode", "holding").trim().toLowerCase();
if ("fixed".equals(mode)) {
cfg.thresholdFixedValue = parseInt(kv, "threshold.fixed.value", 320);
} else {
cfg.thresholdFixedValue = null;
cfg.thresholdHoldingAddress = parseInt(kv, "threshold.holding.addr", cfg.thresholdHoldingAddress);
}
cfg.pumpMargin = parseInt(kv, "pump.margin", cfg.pumpMargin);
cfg.tempInputAddress = parseInt(kv, "output.temp.input", cfg.tempInputAddress);
cfg.tempHoldingAddress = parseInt(kv, "output.temp.holding", cfg.tempHoldingAddress);
cfg.pressureInputAddress = parseInt(kv, "output.pressure.input", cfg.pressureInputAddress);
cfg.pumpCoilAddress = parseInt(kv, "output.pump.coil", cfg.pumpCoilAddress);
cfg.alarmDiscreteAddress = parseInt(kv, "output.alarm.discrete", cfg.alarmDiscreteAddress);
if (cfg.tempStep <= 0) {
throw new IllegalArgumentException("temp.step 必须大于 0");
}
if (cfg.pressureFactor <= 0) {
throw new IllegalArgumentException("pressure.factor 必须大于 0");
}
return cfg;
}
private static Map<String, String> parseScript(String script) {
Map<String, String> map = new HashMap<>();
String[] lines = script.split("\\r?\\n");
for (String line : lines) {
String t = line.trim();
if (t.isEmpty() || t.startsWith("#")) {
continue;
}
int idx = t.indexOf('=');
if (idx <= 0 || idx == t.length() - 1) {
continue;
}
String key = t.substring(0, idx).trim().toLowerCase();
String value = t.substring(idx + 1).trim();
map.put(key, value);
}
return map;
}
private static int parseInt(Map<String, String> map, String key, int defaultValue) {
String v = map.get(key);
return v == null ? defaultValue : Integer.parseInt(v.trim());
}
private static double parseDouble(Map<String, String> map, String key, double defaultValue) {
String v = map.get(key);
return v == null ? defaultValue : Double.parseDouble(v.trim());
}
}
}

View File

@ -0,0 +1,455 @@
package com.ems.modbus;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public class ModbusTcpSlaveServer {
public interface Listener {
void onLog(String message);
void onFrame(String direction, String hex);
}
private static final int MAX_READ_BITS = 2000;
private static final int MAX_READ_REGISTERS = 125;
private static final int MAX_WRITE_COILS = 1968;
private static final int MAX_WRITE_REGISTERS = 123;
private final int port;
private final RegisterStore registerStore;
private final Listener listener;
private final FaultInjectionConfig faultConfig;
private final AtomicBoolean running = new AtomicBoolean(false);
private ServerSocket serverSocket;
private ExecutorService acceptExecutor;
private ExecutorService clientExecutor;
public ModbusTcpSlaveServer(int port, RegisterStore registerStore, Listener listener, FaultInjectionConfig faultConfig) {
this.port = port;
this.registerStore = registerStore;
this.listener = listener;
this.faultConfig = faultConfig;
}
public synchronized void start() throws IOException {
if (running.get()) {
return;
}
serverSocket = new ServerSocket(port);
acceptExecutor = Executors.newSingleThreadExecutor();
clientExecutor = Executors.newCachedThreadPool();
running.set(true);
log("Modbus TCP 从站已启动,监听端口: " + port);
acceptExecutor.submit(() -> {
while (running.get()) {
try {
Socket client = serverSocket.accept();
client.setTcpNoDelay(true);
String remote = client.getRemoteSocketAddress().toString();
log("客户端已连接: " + remote);
clientExecutor.submit(() -> handleClient(client));
} catch (SocketException e) {
if (running.get()) {
log("Accept 异常: " + e.getMessage());
}
} catch (IOException e) {
if (running.get()) {
log("Accept IO 异常: " + e.getMessage());
}
}
}
});
}
public synchronized void stop() {
if (!running.get()) {
return;
}
running.set(false);
if (serverSocket != null) {
try {
serverSocket.close();
} catch (IOException ignored) {
}
}
if (acceptExecutor != null) {
acceptExecutor.shutdownNow();
}
if (clientExecutor != null) {
clientExecutor.shutdownNow();
}
log("Modbus TCP 从站已停止");
}
public boolean isRunning() {
return running.get();
}
private void handleClient(Socket client) {
try (Socket socket = client;
InputStream in = new BufferedInputStream(socket.getInputStream());
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
byte[] mbap = new byte[7];
while (running.get() && !socket.isClosed()) {
if (!readFully(in, mbap, 0, mbap.length)) {
break;
}
int transactionId = toUInt16(mbap[0], mbap[1]);
int protocolId = toUInt16(mbap[2], mbap[3]);
int length = toUInt16(mbap[4], mbap[5]);
int unitId = mbap[6] & 0xFF;
if (protocolId != 0 || length <= 1 || length > 260) {
log("收到非法 MBAP 报文,已忽略");
skipFully(in, Math.max(0, length - 1));
continue;
}
int pduLen = length - 1;
byte[] pdu = new byte[pduLen];
if (!readFully(in, pdu, 0, pduLen)) {
break;
}
byte[] requestAdu = new byte[7 + pdu.length];
System.arraycopy(mbap, 0, requestAdu, 0, 7);
System.arraycopy(pdu, 0, requestAdu, 7, pdu.length);
frame("RX", toHex(requestAdu));
if (faultConfig != null && faultConfig.isDisconnectAfterRequest()) {
log("故障注入: 收到请求后断开连接");
frame("DROP", "连接已主动断开");
break;
}
byte[] responsePdu = processPdu(pdu);
if (responsePdu == null) {
continue;
}
int delayMs = faultConfig == null ? 0 : faultConfig.getResponseDelayMs();
if (delayMs > 0) {
sleepDelay(delayMs);
}
byte[] response = new byte[7 + responsePdu.length];
response[0] = (byte) (transactionId >> 8);
response[1] = (byte) transactionId;
response[2] = 0;
response[3] = 0;
int responseLen = responsePdu.length + 1;
response[4] = (byte) (responseLen >> 8);
response[5] = (byte) responseLen;
response[6] = (byte) unitId;
System.arraycopy(responsePdu, 0, response, 7, responsePdu.length);
out.write(response);
out.flush();
frame("TX", toHex(response));
}
} catch (IOException e) {
if (running.get()) {
log("客户端处理异常: " + e.getMessage());
}
}
}
private byte[] processPdu(byte[] pdu) {
if (pdu.length == 0) {
return null;
}
int function = pdu[0] & 0xFF;
if (faultConfig != null) {
int forcedCode = faultConfig.getForcedExceptionCode();
int forcedFunction = faultConfig.getForcedExceptionFunction();
if (forcedCode > 0 && (forcedFunction <= 0 || forcedFunction == function)) {
log("故障注入: 强制异常响应 function=" + function + ", code=" + forcedCode);
return exceptionResponse(function, forcedCode);
}
}
try {
return switch (function) {
case 1 -> handleReadCoils(pdu);
case 2 -> handleReadDiscreteInputs(pdu);
case 3 -> handleReadHoldingRegisters(pdu);
case 4 -> handleReadInputRegisters(pdu);
case 5 -> handleWriteSingleCoil(pdu);
case 6 -> handleWriteSingleRegister(pdu);
case 15 -> handleWriteMultipleCoils(pdu);
case 16 -> handleWriteMultipleRegisters(pdu);
default -> exceptionResponse(function, 1);
};
} catch (IllegalArgumentException e) {
return exceptionResponse(function, 2);
} catch (Exception e) {
return exceptionResponse(function, 4);
}
}
private byte[] handleReadCoils(byte[] pdu) {
if (pdu.length != 5) {
return exceptionResponse(1, 3);
}
int start = toUInt16(pdu[1], pdu[2]);
int quantity = toUInt16(pdu[3], pdu[4]);
if (quantity <= 0 || quantity > MAX_READ_BITS) {
return exceptionResponse(1, 3);
}
boolean[] values = registerStore.readCoilsRange(start, quantity);
byte[] packed = packBits(values);
byte[] response = new byte[2 + packed.length];
response[0] = 1;
response[1] = (byte) packed.length;
System.arraycopy(packed, 0, response, 2, packed.length);
log("读取 Coil: start=" + start + ", quantity=" + quantity);
return response;
}
private byte[] handleReadDiscreteInputs(byte[] pdu) {
if (pdu.length != 5) {
return exceptionResponse(2, 3);
}
int start = toUInt16(pdu[1], pdu[2]);
int quantity = toUInt16(pdu[3], pdu[4]);
if (quantity <= 0 || quantity > MAX_READ_BITS) {
return exceptionResponse(2, 3);
}
boolean[] values = registerStore.readDiscreteInputsRange(start, quantity);
byte[] packed = packBits(values);
byte[] response = new byte[2 + packed.length];
response[0] = 2;
response[1] = (byte) packed.length;
System.arraycopy(packed, 0, response, 2, packed.length);
log("读取 Discrete Input: start=" + start + ", quantity=" + quantity);
return response;
}
private byte[] handleReadHoldingRegisters(byte[] pdu) {
if (pdu.length != 5) {
return exceptionResponse(3, 3);
}
int start = toUInt16(pdu[1], pdu[2]);
int quantity = toUInt16(pdu[3], pdu[4]);
if (quantity <= 0 || quantity > MAX_READ_REGISTERS) {
return exceptionResponse(3, 3);
}
int[] values = registerStore.readHoldingRange(start, quantity);
byte[] response = buildRegisterReadResponse(3, values);
log("读取 Holding Register: start=" + start + ", quantity=" + quantity);
return response;
}
private byte[] handleReadInputRegisters(byte[] pdu) {
if (pdu.length != 5) {
return exceptionResponse(4, 3);
}
int start = toUInt16(pdu[1], pdu[2]);
int quantity = toUInt16(pdu[3], pdu[4]);
if (quantity <= 0 || quantity > MAX_READ_REGISTERS) {
return exceptionResponse(4, 3);
}
int[] values = registerStore.readInputRange(start, quantity);
byte[] response = buildRegisterReadResponse(4, values);
log("读取 Input Register: start=" + start + ", quantity=" + quantity);
return response;
}
private byte[] handleWriteSingleCoil(byte[] pdu) {
if (pdu.length != 5) {
return exceptionResponse(5, 3);
}
int address = toUInt16(pdu[1], pdu[2]);
int raw = toUInt16(pdu[3], pdu[4]);
boolean value;
if (raw == 0xFF00) {
value = true;
} else if (raw == 0x0000) {
value = false;
} else {
return exceptionResponse(5, 3);
}
registerStore.writeCoil(address, value);
log("写单个 Coil: address=" + address + ", value=" + (value ? 1 : 0));
byte[] response = new byte[5];
System.arraycopy(pdu, 0, response, 0, 5);
return response;
}
private byte[] handleWriteSingleRegister(byte[] pdu) {
if (pdu.length != 5) {
return exceptionResponse(6, 3);
}
int address = toUInt16(pdu[1], pdu[2]);
int value = toUInt16(pdu[3], pdu[4]);
registerStore.writeHolding(address, value);
log("写单个 Holding Register: address=" + address + ", value=" + value);
byte[] response = new byte[5];
System.arraycopy(pdu, 0, response, 0, 5);
return response;
}
private byte[] handleWriteMultipleCoils(byte[] pdu) {
if (pdu.length < 6) {
return exceptionResponse(15, 3);
}
int start = toUInt16(pdu[1], pdu[2]);
int quantity = toUInt16(pdu[3], pdu[4]);
int byteCount = pdu[5] & 0xFF;
int expectedByteCount = (quantity + 7) / 8;
if (quantity <= 0 || quantity > MAX_WRITE_COILS || byteCount != expectedByteCount || pdu.length != 6 + byteCount) {
return exceptionResponse(15, 3);
}
boolean[] values = unpackBits(pdu, 6, quantity);
registerStore.writeCoilsRange(start, values);
log("写多个 Coil: start=" + start + ", quantity=" + quantity);
return new byte[]{15, pdu[1], pdu[2], pdu[3], pdu[4]};
}
private byte[] handleWriteMultipleRegisters(byte[] pdu) {
if (pdu.length < 6) {
return exceptionResponse(16, 3);
}
int start = toUInt16(pdu[1], pdu[2]);
int quantity = toUInt16(pdu[3], pdu[4]);
int byteCount = pdu[5] & 0xFF;
if (quantity <= 0 || quantity > MAX_WRITE_REGISTERS || byteCount != quantity * 2 || pdu.length != 6 + byteCount) {
return exceptionResponse(16, 3);
}
int[] values = new int[quantity];
for (int i = 0; i < quantity; i++) {
values[i] = toUInt16(pdu[6 + i * 2], pdu[7 + i * 2]);
}
registerStore.writeHoldingRange(start, values);
log("写多个 Holding Register: start=" + start + ", quantity=" + quantity);
return new byte[]{16, pdu[1], pdu[2], pdu[3], pdu[4]};
}
private byte[] buildRegisterReadResponse(int function, int[] values) {
byte[] response = new byte[2 + values.length * 2];
response[0] = (byte) function;
response[1] = (byte) (values.length * 2);
for (int i = 0; i < values.length; i++) {
int value = values[i] & 0xFFFF;
response[2 + i * 2] = (byte) (value >> 8);
response[3 + i * 2] = (byte) value;
}
return response;
}
private byte[] packBits(boolean[] values) {
int byteCount = (values.length + 7) / 8;
byte[] data = new byte[byteCount];
for (int i = 0; i < values.length; i++) {
if (values[i]) {
data[i / 8] |= (byte) (1 << (i % 8));
}
}
return data;
}
private boolean[] unpackBits(byte[] source, int offset, int bitCount) {
boolean[] values = new boolean[bitCount];
for (int i = 0; i < bitCount; i++) {
int b = source[offset + i / 8] & 0xFF;
values[i] = ((b >> (i % 8)) & 1) == 1;
}
return values;
}
private byte[] exceptionResponse(int function, int code) {
return new byte[]{(byte) (function | 0x80), (byte) code};
}
private boolean readFully(InputStream in, byte[] buffer, int offset, int length) throws IOException {
int read = 0;
while (read < length) {
int n = in.read(buffer, offset + read, length - read);
if (n == -1) {
return false;
}
read += n;
}
return true;
}
private void skipFully(InputStream in, int length) throws IOException {
long remaining = length;
while (remaining > 0) {
long skipped = in.skip(remaining);
if (skipped <= 0) {
if (in.read() == -1) {
break;
}
skipped = 1;
}
remaining -= skipped;
}
}
private int toUInt16(byte hi, byte lo) {
return ((hi & 0xFF) << 8) | (lo & 0xFF);
}
private String toHex(byte[] data) {
StringBuilder sb = new StringBuilder(data.length * 3);
for (int i = 0; i < data.length; i++) {
if (i > 0) {
sb.append(' ');
}
sb.append(String.format("%02X", data[i] & 0xFF));
}
return sb.toString();
}
private void sleepDelay(int delayMs) {
try {
Thread.sleep(delayMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
private void log(String message) {
if (listener != null) {
String time = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
listener.onLog("[" + time + "] " + message);
}
}
private void frame(String direction, String hex) {
if (listener != null) {
listener.onFrame(direction, hex);
}
}
}

View File

@ -0,0 +1,246 @@
package com.ems.modbus;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileReader;
import java.io.IOException;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
public class PointConfigImporter {
public record ImportResult(int total, int success, int failed) {
}
public static ImportResult importFile(File file, RegisterStore registerStore) throws IOException {
String name = file.getName().toLowerCase(Locale.ROOT);
if (name.endsWith(".csv")) {
return importCsv(file, registerStore);
}
if (name.endsWith(".xlsx")) {
return importXlsx(file, registerStore);
}
throw new IllegalArgumentException("仅支持 .csv 或 .xlsx 文件");
}
private static ImportResult importCsv(File file, RegisterStore registerStore) throws IOException {
int total = 0;
int success = 0;
int failed = 0;
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
String firstLine = reader.readLine();
if (firstLine == null) {
return new ImportResult(0, 0, 0);
}
String[] firstParts = splitCsvLine(firstLine);
boolean hasHeader = containsHeaderToken(firstParts);
Map<String, Integer> headerMap = hasHeader ? buildHeaderMap(firstParts) : null;
if (!hasHeader) {
total++;
if (applyRow(firstParts, headerMap, registerStore)) {
success++;
} else {
failed++;
}
}
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
continue;
}
total++;
String[] parts = splitCsvLine(line);
if (applyRow(parts, headerMap, registerStore)) {
success++;
} else {
failed++;
}
}
}
return new ImportResult(total, success, failed);
}
private static ImportResult importXlsx(File file, RegisterStore registerStore) throws IOException {
int total = 0;
int success = 0;
int failed = 0;
try (FileInputStream in = new FileInputStream(file); Workbook wb = new XSSFWorkbook(in)) {
Sheet sheet = wb.getNumberOfSheets() > 0 ? wb.getSheetAt(0) : null;
if (sheet == null) {
return new ImportResult(0, 0, 0);
}
DataFormatter formatter = new DataFormatter();
Row headerRow = sheet.getRow(sheet.getFirstRowNum());
if (headerRow == null) {
return new ImportResult(0, 0, 0);
}
String[] headerParts = rowToArray(headerRow, formatter);
boolean hasHeader = containsHeaderToken(headerParts);
Map<String, Integer> headerMap = hasHeader ? buildHeaderMap(headerParts) : null;
int startRow = hasHeader ? sheet.getFirstRowNum() + 1 : sheet.getFirstRowNum();
for (int i = startRow; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (row == null) {
continue;
}
String[] parts = rowToArray(row, formatter);
if (parts.length == 0 || joinTrim(parts).isEmpty()) {
continue;
}
total++;
if (applyRow(parts, headerMap, registerStore)) {
success++;
} else {
failed++;
}
}
}
return new ImportResult(total, success, failed);
}
private static boolean applyRow(String[] parts, Map<String, Integer> headerMap, RegisterStore registerStore) {
try {
String area;
String address;
String value;
if (headerMap == null) {
if (parts.length < 3) {
return false;
}
area = parts[0];
address = parts[1];
value = parts[2];
} else {
Integer areaIdx = headerMap.get("area");
Integer addrIdx = headerMap.get("address");
Integer valueIdx = headerMap.get("value");
if (areaIdx == null || addrIdx == null || valueIdx == null) {
return false;
}
area = getPart(parts, areaIdx);
address = getPart(parts, addrIdx);
value = getPart(parts, valueIdx);
}
writeValue(registerStore, area, parseInt(address), parseInt(value));
return true;
} catch (Exception e) {
return false;
}
}
private static void writeValue(RegisterStore store, String areaRaw, int address, int value) {
String area = areaRaw.trim().toLowerCase(Locale.ROOT)
.replace(" ", "")
.replace("_", "")
.replace("-", "");
switch (area) {
case "holding", "holdingregister", "hr" -> store.writeHolding(address, value);
case "input", "inputregister", "ir" -> store.writeInput(address, value);
case "coil", "coils" -> store.writeCoil(address, value != 0);
case "discrete", "discreteinput", "di" -> store.writeDiscreteInput(address, value != 0);
default -> throw new IllegalArgumentException("未知 area: " + areaRaw);
}
}
private static int parseInt(String text) {
return Integer.parseInt(text.trim());
}
private static String[] splitCsvLine(String line) {
String[] raw = line.split(",");
String[] out = new String[raw.length];
for (int i = 0; i < raw.length; i++) {
out[i] = unquote(raw[i].trim());
}
return out;
}
private static String[] rowToArray(Row row, DataFormatter formatter) {
int last = row.getLastCellNum();
if (last <= 0) {
return new String[0];
}
String[] values = new String[last];
for (int i = 0; i < last; i++) {
Cell cell = row.getCell(i);
values[i] = cell == null ? "" : formatter.formatCellValue(cell).trim();
}
return values;
}
private static boolean containsHeaderToken(String[] parts) {
for (String part : parts) {
String n = normalizeHeader(part);
if ("area".equals(n) || "address".equals(n) || "value".equals(n)) {
return true;
}
}
return false;
}
private static Map<String, Integer> buildHeaderMap(String[] parts) {
Map<String, Integer> map = new HashMap<>();
for (int i = 0; i < parts.length; i++) {
String n = normalizeHeader(parts[i]);
if ("area".equals(n)) {
map.put("area", i);
} else if ("address".equals(n)) {
map.put("address", i);
} else if ("value".equals(n)) {
map.put("value", i);
}
}
return map;
}
private static String normalizeHeader(String text) {
return text == null ? "" : text.trim().toLowerCase(Locale.ROOT)
.replace(" ", "")
.replace("_", "")
.replace("-", "");
}
private static String getPart(String[] parts, int index) {
if (index < 0 || index >= parts.length) {
throw new IllegalArgumentException("列索引越界: " + index);
}
return parts[index];
}
private static String unquote(String text) {
if (text.length() >= 2 && text.startsWith("\"") && text.endsWith("\"")) {
return text.substring(1, text.length() - 1);
}
return text;
}
private static String joinTrim(String[] parts) {
StringBuilder sb = new StringBuilder();
for (String part : parts) {
sb.append(part.trim());
}
return sb.toString();
}
}

View File

@ -0,0 +1,141 @@
package com.ems.modbus;
public class RegisterStore {
private final int[] holdingRegisters;
private final int[] inputRegisters;
private final boolean[] coils;
private final boolean[] discreteInputs;
public RegisterStore(int registerSize, int bitSize) {
if (registerSize <= 0 || registerSize > 65535) {
throw new IllegalArgumentException("寄存器数量必须在 1-65535 之间");
}
if (bitSize <= 0 || bitSize > 65535) {
throw new IllegalArgumentException("位数量必须在 1-65535 之间");
}
this.holdingRegisters = new int[registerSize];
this.inputRegisters = new int[registerSize];
this.coils = new boolean[bitSize];
this.discreteInputs = new boolean[bitSize];
}
public synchronized int registerSize() {
return holdingRegisters.length;
}
public synchronized int bitSize() {
return coils.length;
}
public synchronized int[] readHoldingRange(int start, int quantity) {
checkRegisterRange(start, quantity);
int[] result = new int[quantity];
for (int i = 0; i < quantity; i++) {
result[i] = holdingRegisters[start + i] & 0xFFFF;
}
return result;
}
public synchronized int[] readInputRange(int start, int quantity) {
checkRegisterRange(start, quantity);
int[] result = new int[quantity];
for (int i = 0; i < quantity; i++) {
result[i] = inputRegisters[start + i] & 0xFFFF;
}
return result;
}
public synchronized void writeHolding(int address, int value) {
checkRegisterAddress(address);
holdingRegisters[address] = value & 0xFFFF;
}
public synchronized void writeHoldingRange(int start, int[] values) {
checkRegisterRange(start, values.length);
for (int i = 0; i < values.length; i++) {
holdingRegisters[start + i] = values[i] & 0xFFFF;
}
}
public synchronized void writeInput(int address, int value) {
checkRegisterAddress(address);
inputRegisters[address] = value & 0xFFFF;
}
public synchronized boolean[] readCoilsRange(int start, int quantity) {
checkBitRange(start, quantity);
boolean[] result = new boolean[quantity];
System.arraycopy(coils, start, result, 0, quantity);
return result;
}
public synchronized boolean[] readDiscreteInputsRange(int start, int quantity) {
checkBitRange(start, quantity);
boolean[] result = new boolean[quantity];
System.arraycopy(discreteInputs, start, result, 0, quantity);
return result;
}
public synchronized void writeCoil(int address, boolean value) {
checkBitAddress(address);
coils[address] = value;
}
public synchronized void writeCoilsRange(int start, boolean[] values) {
checkBitRange(start, values.length);
System.arraycopy(values, 0, coils, start, values.length);
}
public synchronized void writeDiscreteInput(int address, boolean value) {
checkBitAddress(address);
discreteInputs[address] = value;
}
public synchronized int[] snapshotHolding() {
int[] copy = new int[holdingRegisters.length];
System.arraycopy(holdingRegisters, 0, copy, 0, holdingRegisters.length);
return copy;
}
public synchronized int[] snapshotInput() {
int[] copy = new int[inputRegisters.length];
System.arraycopy(inputRegisters, 0, copy, 0, inputRegisters.length);
return copy;
}
public synchronized boolean[] snapshotCoils() {
boolean[] copy = new boolean[coils.length];
System.arraycopy(coils, 0, copy, 0, coils.length);
return copy;
}
public synchronized boolean[] snapshotDiscreteInputs() {
boolean[] copy = new boolean[discreteInputs.length];
System.arraycopy(discreteInputs, 0, copy, 0, discreteInputs.length);
return copy;
}
private void checkRegisterAddress(int address) {
if (address < 0 || address >= holdingRegisters.length) {
throw new IllegalArgumentException("寄存器地址越界: " + address);
}
}
private void checkRegisterRange(int start, int quantity) {
if (quantity <= 0 || start < 0 || start + quantity > holdingRegisters.length) {
throw new IllegalArgumentException("寄存器范围越界: start=" + start + ", quantity=" + quantity);
}
}
private void checkBitAddress(int address) {
if (address < 0 || address >= coils.length) {
throw new IllegalArgumentException("位地址越界: " + address);
}
}
private void checkBitRange(int start, int quantity) {
if (quantity <= 0 || start < 0 || start + quantity > coils.length) {
throw new IllegalArgumentException("位范围越界: start=" + start + ", quantity=" + quantity);
}
}
}