From 2f28447a168634bb66505cda1b65d47ed4319a69 Mon Sep 17 00:00:00 2001 From: dashixiong Date: Fri, 3 Apr 2026 13:26:45 +0800 Subject: [PATCH] first commit --- README.md | 101 +++ pom.xml | 142 ++++ src/assembly/windows.xml | 29 + src/main/dist/windows/README.txt | 5 + .../dist/windows/run-ems-modbus-simulator.bat | 13 + .../com/ems/modbus/FaultInjectionConfig.java | 43 + .../java/com/ems/modbus/MainLauncher.java | 10 + .../com/ems/modbus/ModbusSimulatorApp.java | 789 ++++++++++++++++++ .../com/ems/modbus/ModbusTcpSlaveServer.java | 455 ++++++++++ .../com/ems/modbus/PointConfigImporter.java | 246 ++++++ .../java/com/ems/modbus/RegisterStore.java | 141 ++++ 11 files changed, 1974 insertions(+) create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/assembly/windows.xml create mode 100644 src/main/dist/windows/README.txt create mode 100644 src/main/dist/windows/run-ems-modbus-simulator.bat create mode 100644 src/main/java/com/ems/modbus/FaultInjectionConfig.java create mode 100644 src/main/java/com/ems/modbus/MainLauncher.java create mode 100644 src/main/java/com/ems/modbus/ModbusSimulatorApp.java create mode 100644 src/main/java/com/ems/modbus/ModbusTcpSlaveServer.java create mode 100644 src/main/java/com/ems/modbus/PointConfigImporter.java create mode 100644 src/main/java/com/ems/modbus/RegisterStore.java diff --git a/README.md b/README.md new file mode 100644 index 0000000..92498ff --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..84066ee --- /dev/null +++ b/pom.xml @@ -0,0 +1,142 @@ + + 4.0.0 + + com.ems + ems-modbus-simulator + 1.0.0 + EMS Modbus Simulator + + + 17 + 17 + UTF-8 + 21.0.2 + mac-aarch64 + + + + + org.openjfx + javafx-base + ${javafx.version} + ${javafx.platform} + + + org.openjfx + javafx-graphics + ${javafx.version} + ${javafx.platform} + + + org.openjfx + javafx-controls + ${javafx.version} + ${javafx.platform} + + + org.openjfx + javafx-base + + + org.openjfx + javafx-graphics + + + + + org.apache.poi + poi-ooxml + 5.2.5 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + + + + org.openjfx + javafx-maven-plugin + 0.0.8 + + com.ems.modbus.ModbusSimulatorApp + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + com.ems.modbus.MainLauncher + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.2 + + + package + + shade + + + false + + + com.ems.modbus.MainLauncher + + + + + + + + + + + + windows-dist + + win + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.7.1 + + + build-windows-zip + package + + single + + + + src/assembly/windows.xml + + ${project.artifactId}-${project.version}-windows + false + + + + + + + + + diff --git a/src/assembly/windows.xml b/src/assembly/windows.xml new file mode 100644 index 0000000..6b73bcc --- /dev/null +++ b/src/assembly/windows.xml @@ -0,0 +1,29 @@ + + windows + + zip + + true + ${project.artifactId}-${project.version}-windows + + + + ${project.build.directory}/${project.build.finalName}.jar + / + ${project.build.finalName}.jar + + + + + + src/main/dist/windows + / + + *.bat + README.txt + + + + diff --git a/src/main/dist/windows/README.txt b/src/main/dist/windows/README.txt new file mode 100644 index 0000000..1e9413b --- /dev/null +++ b/src/main/dist/windows/README.txt @@ -0,0 +1,5 @@ +Windows 使用说明 + +1. 保持本目录中的文件结构不变。 +2. 双击 run-ems-modbus-simulator.bat 启动程序。 +3. 如果无法启动,请先安装 64 位 Java 17 或更高版本,再重新运行。 diff --git a/src/main/dist/windows/run-ems-modbus-simulator.bat b/src/main/dist/windows/run-ems-modbus-simulator.bat new file mode 100644 index 0000000..4e3d22a --- /dev/null +++ b/src/main/dist/windows/run-ems-modbus-simulator.bat @@ -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 +) diff --git a/src/main/java/com/ems/modbus/FaultInjectionConfig.java b/src/main/java/com/ems/modbus/FaultInjectionConfig.java new file mode 100644 index 0000000..521d680 --- /dev/null +++ b/src/main/java/com/ems/modbus/FaultInjectionConfig.java @@ -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; + } +} diff --git a/src/main/java/com/ems/modbus/MainLauncher.java b/src/main/java/com/ems/modbus/MainLauncher.java new file mode 100644 index 0000000..a56ee87 --- /dev/null +++ b/src/main/java/com/ems/modbus/MainLauncher.java @@ -0,0 +1,10 @@ +package com.ems.modbus; + +public final class MainLauncher { + private MainLauncher() { + } + + public static void main(String[] args) { + ModbusSimulatorApp.main(args); + } +} diff --git a/src/main/java/com/ems/modbus/ModbusSimulatorApp.java b/src/main/java/com/ems/modbus/ModbusSimulatorApp.java new file mode 100644 index 0000000..a724fb7 --- /dev/null +++ b/src/main/java/com/ems/modbus/ModbusSimulatorApp.java @@ -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 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 viewAreaCombo; + private ComboBox writeAreaCombo; + private Spinner writeAddressSpinner; + private Spinner writeValueSpinner; + + private Spinner delaySpinner; + private Spinner exceptionCodeSpinner; + private ComboBox 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 table = new TableView<>(tableRows); + + TableColumn addressCol = new TableColumn<>("地址"); + addressCol.setCellValueFactory(data -> data.getValue().addressProperty()); + addressCol.setPrefWidth(120); + + TableColumn 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 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 parseScript(String script) { + Map 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 map, String key, int defaultValue) { + String v = map.get(key); + return v == null ? defaultValue : Integer.parseInt(v.trim()); + } + + private static double parseDouble(Map map, String key, double defaultValue) { + String v = map.get(key); + return v == null ? defaultValue : Double.parseDouble(v.trim()); + } + } +} diff --git a/src/main/java/com/ems/modbus/ModbusTcpSlaveServer.java b/src/main/java/com/ems/modbus/ModbusTcpSlaveServer.java new file mode 100644 index 0000000..0f4fec0 --- /dev/null +++ b/src/main/java/com/ems/modbus/ModbusTcpSlaveServer.java @@ -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); + } + } +} diff --git a/src/main/java/com/ems/modbus/PointConfigImporter.java b/src/main/java/com/ems/modbus/PointConfigImporter.java new file mode 100644 index 0000000..364f745 --- /dev/null +++ b/src/main/java/com/ems/modbus/PointConfigImporter.java @@ -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 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 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 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 buildHeaderMap(String[] parts) { + Map 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(); + } +} diff --git a/src/main/java/com/ems/modbus/RegisterStore.java b/src/main/java/com/ems/modbus/RegisterStore.java new file mode 100644 index 0000000..33f5fb0 --- /dev/null +++ b/src/main/java/com/ems/modbus/RegisterStore.java @@ -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); + } + } +}