first commit
This commit is contained in:
101
README.md
Normal file
101
README.md
Normal 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
142
pom.xml
Normal 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
29
src/assembly/windows.xml
Normal 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
5
src/main/dist/windows/README.txt
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
Windows 使用说明
|
||||
|
||||
1. 保持本目录中的文件结构不变。
|
||||
2. 双击 run-ems-modbus-simulator.bat 启动程序。
|
||||
3. 如果无法启动,请先安装 64 位 Java 17 或更高版本,再重新运行。
|
||||
13
src/main/dist/windows/run-ems-modbus-simulator.bat
vendored
Normal file
13
src/main/dist/windows/run-ems-modbus-simulator.bat
vendored
Normal 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
|
||||
)
|
||||
43
src/main/java/com/ems/modbus/FaultInjectionConfig.java
Normal file
43
src/main/java/com/ems/modbus/FaultInjectionConfig.java
Normal 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;
|
||||
}
|
||||
}
|
||||
10
src/main/java/com/ems/modbus/MainLauncher.java
Normal file
10
src/main/java/com/ems/modbus/MainLauncher.java
Normal file
@ -0,0 +1,10 @@
|
||||
package com.ems.modbus;
|
||||
|
||||
public final class MainLauncher {
|
||||
private MainLauncher() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
ModbusSimulatorApp.main(args);
|
||||
}
|
||||
}
|
||||
789
src/main/java/com/ems/modbus/ModbusSimulatorApp.java
Normal file
789
src/main/java/com/ems/modbus/ModbusSimulatorApp.java
Normal 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("报文 HEX(RX/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());
|
||||
}
|
||||
}
|
||||
}
|
||||
455
src/main/java/com/ems/modbus/ModbusTcpSlaveServer.java
Normal file
455
src/main/java/com/ems/modbus/ModbusTcpSlaveServer.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
246
src/main/java/com/ems/modbus/PointConfigImporter.java
Normal file
246
src/main/java/com/ems/modbus/PointConfigImporter.java
Normal 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();
|
||||
}
|
||||
}
|
||||
141
src/main/java/com/ems/modbus/RegisterStore.java
Normal file
141
src/main/java/com/ems/modbus/RegisterStore.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user