SpringBoot+Vue通过ModbusTCP协议实现PLC 设备连接、重连实时控制

SpringBoot+Vue通过ModbusTCP协议实现PLC 设备连接、重连实时控制


一、核心概念

1.1 Modbus TCP 基础

概念 说明
主站 / 从站 后端程序是 Modbus 主站(Master) ,PLC 是 从站(Slave)。一个主站可轮询多台从站。
从站地址 slaveId PLC 的站号(如 1)。读写 API 必传。
功能码 01 读线圈、02 读离散输入、03 读保持寄存器、04 读输入寄存器05/06 写单线圈/单寄存器、15/16 写多。
寄存器类型 4x 保持寄存器(Holding Register) 可读可写;3x 输入寄存器只读。本文业务控制全部走 4x。
地址偏移换算 标准 Modbus 地址 40001 ⇒ modbus4j 偏移量 0;其余 = 地址 − 40001。例如 40003 ⇒ 偏移 2

1.2 寄存器与 bit 位映射(对接表约定)

  • 布尔量(启停、摇摆、故障位)不是单独寄存器,而是分布在一个 16 位寄存器内的不同 bit
  • 模拟量/参数多为 32 位(Real / Dint) ,占用连续 2 个保持寄存器(高字 + 低字)。
  • 写控制位必须采用 读-改-写(read-modify-write),否则会覆盖同一寄存器内其它 bit。
  • 本项目常量集中在 PlcOffsetConstants.java
17:26:ruoyi-common/src/main/java/com/ruoyi/common/constant/PlcOffsetConstants.java 复制代码
public class PlcOffsetConstants {
    public static final int REG_CONTROL = 0;   // 控制寄存器 40001(读/写)
    public static final int REG_STATUS  = 1;   // 状态/限位 40002(只读)
    public static final int REG_FAULT   = 2;   // 故障 40003(只读)
62:78:ruoyi-common/src/main/java/com/ruoyi/common/constant/PlcOffsetConstants.java 复制代码
    // 控制寄存器 40001 的 bit(V4000/V4001,读/写)
    public static final int BIT_REMOTE_START_STOP = 0; // V4000.0 远程启停
    public static final int BIT_REMOTE_SWING     = 1; // V4000.1 远程摇摆
    // ... BIT_REMOTE_LOCATE / JOG_LEFT / JOG_RIGHT / JOG_UP / JOG_DOWN / FAULT_RESET / SERVO_HOMING

1.3 长连接 vs 按需连接(关键坑)

  • modbus4j 的 createTcpMaster(params, true) 第二个参数 true = 启用长连接(keep-alive)
  • master.init() 只是初始化对象,并不真正建立 TCP 连接 ;真正的 connect 发生在第一次 send(读/写)时。
  • 因此 master.isInitialized() 只表示"是否调用过 init",不代表 TCP 链路仍然存活 。断网后旧 master 仍可能 isInitialized()==true,要等到实际读写抛异常才会被发现。
  • 本项目 getMaster 的关键参数:
57:70:ruoyi-common/src/main/java/com/ruoyi/common/utils/Modbus4jUtils.java 复制代码
public static ModbusMaster getMaster(String ip,int port) throws ModbusInitException {
    IpParameters params = new IpParameters();
    params.setHost(ip); params.setPort(port);
    ModbusMaster master = modbusFactory.createTcpMaster(params, true); // TCP 协议 + 长连接
    master.setTimeout(2000);   // 超时 2s
    master.setRetries(1);      // 失败重试 1 次
    master.init();
    return master;
}

注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

二、架构设计

复制代码
┌────────────┐   定时(3s)    ┌──────────────────────────┐
│  Quartz任务 │─────────────▶│  GetModbusTCPDataTask     │
│ getModbus   │              │  · 取每台设备连接(连接池)  │
│ TCPDataTask │              │  · 有效性检查/重建         │
└────────────┘              │  · 读保持寄存器(实时数据)  │
                            │  · 断线→记录冷却→跳过重连  │
                            └───────────┬──────────────┘
                                        │ 写入
                                        ▼
                            ┌──────────────────────────┐
                            │  Redis (key=plc:xxx)      │  实时数据缓存
                            └───────────┬──────────────┘
                                        │ 读取
┌────────────┐   HTTP GET   ┌───────────▼──────────────┐
│  前端 Vue   │◀────────────│  BusDeviceServiceImpl     │
│ 设备卡片/   │ listByShed  │  listByShedIdWithStatus   │
│ 状态/禁用   │ WithStatus  │  · 拼装设备+Redis实时状态  │
└────────────┘              │  · 无实时数据→状态"离线"   │
                            └──────────────────────────┘

2.1 关键设计决策

  1. 连接池 ModbusMasterPool :按 plcAddress 缓存 master,避免每次操作都新建连接;提供 getOrCreateMaster / checkMasterValid / rebuildMaster / destroyMaster
  2. 连接与读写为解耦getMaster 只建连;读用 readHoldingRegisterU16Safe(吞异常,单点失败不影响整体轮询);写用 writeToDevice(写前先 checkMasterValid,失效则重建)。
  3. 重连冷却(本文重点) :采集任务每 3 秒跑一次,但离线设备不会每 3 秒都去建连------一旦失败,记录失败时间,sys_config.plc.reconnect.interval(秒,默认 30)内直接跳过该设备的读取与重连,冷却期满再试一次。在线设备读取频率完全不受影响
  4. 实时数据走 Redis:采集结果入 Redis,接口查询时再拼装,前端无需直连 PLC。
  5. "离线"由后端判定listByShedIdWithStatus 在 Redis 中查不到实时数据即置 status="离线",前端据此显示并禁用操作。

三、后端实现流程

3.1 发起连接(连接池)

ModbusMasterPool.getOrCreateMaster(device):池里有且 isInitialized() 就用,否则 createNewMaster(调用 Modbus4jUtils.getMaster)。

46:50:ruoyi-common/src/main/java/com/ruoyi/common/component/ModbusMasterPool.java 复制代码
// 有效性检查:仅判断"是否 init 过",不代表链路存活(见 1.3)
public boolean checkMasterValid(ModbusMaster master) {
    if (master.isInitialized()) return true;
    try { master.init(); return true; } catch (Exception e) { return false; }
}

3.2 周期性采集 + 重连冷却(核心定时任务)

91:107:ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/GetModbusTCPDataTask.java 复制代码
// 读取重连冷却时间(单位:秒),配置项 plc.reconnect.interval,默认 30 秒
final long reconnectIntervalMs;
{
    long sec = RECONNECT_INTERVAL_SECONDS_DEFAULT; // 30
    String intervalStr = sysConfigService.selectConfigByKey("plc.reconnect.interval");
    if (intervalStr != null && !intervalStr.trim().isEmpty()) {
        try { sec = Long.parseLong(intervalStr.trim()); }
        catch (NumberFormatException e) { sec = RECONNECT_INTERVAL_SECONDS_DEFAULT; }
    }
    if (sec < 1) sec = RECONNECT_INTERVAL_SECONDS_DEFAULT;
    reconnectIntervalMs = sec * 1000L;
}
109:119:ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/GetModbusTCPDataTask.java 复制代码
deviceList.parallelStream().forEach(device  -> {
    String deviceKey = device.getPlcAddress();
    // 重连限频:离线设备进入冷却,期间不读不重连
    Long lastReconnectFailAt = lastReconnectFail.get(deviceKey);
    if (lastReconnectFailAt != null && (System.currentTimeMillis() - lastReconnectFailAt) < reconnectIntervalMs) {
        return;
    }
    ModbusMaster master = null;
    try {
        master = modbusMasterPool.getOrCreateMaster(device);
        if (!modbusMasterPool.checkMasterValid(master)) {
            master = modbusMasterPool.rebuildMaster(device);
        }
298:310:ruoyi-quartz/src/main/java/com/ruoyi/quartz/task/GetModbusTCPDataTask.java 复制代码
    } catch (Exception e) {
        System.out.println(" 通信失败: 网络或端口不可达 - " + e.getMessage());
        // 记录失败时间,进入 30s 重连冷却(冷却期内该设备将被跳过,不再尝试建连)
        lastReconnectFail.put(deviceKey, System.currentTimeMillis());
        modbusMasterPool.destroyMaster(deviceKey); // 销毁无效连接
    }

3.3 后端状态判定(离线)

BusDeviceServiceImpl.listByShedIdWithStatus:从 Redis 取实时数据,取不到 ⇒ status="离线"

148:165:ruoyi-system/src/main/java/com/ruoyi/system/service/impl/BusDeviceServiceImpl.java 复制代码
// 从 Redis 取实时数据
RealTimeDevice realTimeDevice = redisCache.getPlcDeviceListFromRedis()
        .stream().filter(...).findFirst().orElse(null);
BusDeviceDetailStatuslVo vo = new BusDeviceDetailStatuslVo();
// ...
if (realTimeDevice == null) {
    device.setStatus("离线");      // PLC 无实时数据 → 离线
} else {
    device.setStatus(PlcStatusEnum.getInfo(realTimeDevice.getWorkStatus())); // 运行/停止
}

3.4 写入(控制下发)

writeToDevice 写前先 checkMasterValid,失效则 rebuildMaster 并重试一次,确保用户点按钮时能即时重建连接(不受采集冷却影响):

61:78:ruoyi-common/src/main/java/com/ruoyi/common/component/ModbusMasterPool.java 复制代码
public Boolean writeToDevice(...) {
    ModbusMaster master = getOrCreateMaster(device);
    if (!checkMasterValid(master)) {
        master = rebuildMaster(device); // 写操作即时重建,无需等待冷却
    }
    try {
        // 读-改-写 置位对应 bit ...
    } catch (Exception e) {
        // 失败再 rebuild 重试一次
    }
}

四、API 说明

4.1 Modbus4jUtils(读/写工具,见 Modbus4jUtils.java

方法 功能 异常
getMaster(ip, port) 创建并 init 一个 TCP 长连接 master ModbusInitException
readCoilStatus(m, slaveId, offset) 读 01 线圈(bool) Transport / ErrorResponse
readInputStatus(m, slaveId, offset) 读 02 离散输入(bool) 同上
readHoldingRegister(m, slaveId, offset, dataType) 读 03 保持寄存器(Number) 同上
readInputRegisters(m, slaveId, offset, dataType) 读 04 输入寄存器 同上
readHoldingRegisterU16(m, slaveId, offset) 读单个无符号 16 位(4x) 同上
readHoldingRegister32(m, slaveId, offset, dataType) 读 32 位(Dint/Real) 同上
writeCoil(m, slaveId, offset, value) 写 01 线圈(bool) Transport(含异常码)
writeHoldingRegister(m, slaveId, offset, value) 写 06 单寄存器(short) 同上
writeHoldingRegisterBit(m, slaveId, regOff, bitIdx, value) 读-改-写 置位 4x 寄存器某 bit 同上
writeHoldingRegisterInt32(m, slaveId, offset, value) 写 32 位整数(高/低字) 同上
readHoldingRegisterU16Safe(...) / ...32Safe(...) 安全读(吞异常返回 null),用于轮询 不抛

4.2 ModbusMasterPool(连接池)

方法 说明
getOrCreateMaster(device) 取池中连接,无则新建(失败抛异常)
checkMasterValid(master) isInitialized() 为真即返回 true(不保证链路存活)
rebuildMaster(device) 销毁旧连接并从池移除,重新 createNewMaster
destroyMaster(deviceKey) 从池移除无效连接(仅移除,不立即重建)
writeToDevice(...) 写前校验/重建并重试,供控制下发

4.3 后端 REST 接口(前端调用)

接口 方法/URL 说明
带状态设备列表 GET /system/device/listByShedIdWithStatus 返回设备 + Redis 实时状态(含 status="运行/停止/离线"
设备列表 GET /system/device/listByShedId 不含实时状态
视频流代理等 见其它文档 ---

前端调用封装见 ruoyi-ui/src/api/system/device.js

13:19:ruoyi-ui/src/api/system/device.js 复制代码
export function listByShedIdWithStatus(query) {
  return request({
    url: '/system/device/listByShedIdWithStatus',
    method: 'get',
    params: query
  })
}

五、配置说明

重连冷却间隔由 sys_config 配置项 plc.reconnect.interval 控制,单位:秒,默认 30

  • 不配置 / 值非法 / <1 ⇒ 代码兜底默认 30 秒
  • 在「系统管理 → 参数设置」改为 60,PLC 离线重连频率即变为 60 秒一次(在线设备仍 3 秒读取)。
  • 默认值已写入 sql/全量sql备份/yingyan20260716.sql
772:772:sql/全量sql备份/yingyan20260716.sql 复制代码
INSERT INTO `sys_config` VALUES (102, 'PLC重连冷却间隔(秒)', 'plc.reconnect.interval', '30', 'Y', 'admin', '2026-07-19 00:00:00', '', NULL, 'PLC离线后重连冷却时长,单位:秒,默认30秒。冷却期内不读取、不重连');

六、前后端代码示例

6.1 本项目实际示例

前端设备卡片:状态标签 + 离线禁用操作控件(ruoyi-ui/src/views/index.vue)。

456:462:ruoyi-ui/src/views/index.vue 复制代码
<el-tag
  :type="
    (device.status === '运行' || device.status === '运行中')
      ? 'success'
      : device.status === '故障'
      ? 'danger'
      : (device.status === '离线' || device.status === '停止') ? 'info' : 'warning'
  "
>{{ device.status }}</el-tag>
504:510:ruoyi-ui/src/views/index.vue 复制代码
<el-switch
  v-model="device.sprayStatus"
  :disabled="device.status === '故障' || device.status === '离线'"
  size="mini"
  @change="handleSprayChange(device)"
></el-switch>
<span :style="{ fontSize:'15px', fontWeight:'700', color: device.sprayStatus ? '#13ce66' : '#ff7875' }">
  {{ device.sprayStatus ? '开启' : '关闭' }}
</span>

6.2 与业务无关的通用后端示例(可复用)

一个极简、无业务字段的 Modbus TCP 客户端 + 重连冷却 + 定时读取骨架。

java 复制代码
import com.serotonin.modbus4j.*;
import com.serotonin.modbus4j.ip.IpParameters;
import com.serotonin.modbus4j.locator.BaseLocator;

public class ModbusTcpClient {
    private final String host;
    private final int port;
    private final int slaveId;
    private final long reconnectIntervalMs; // 重连冷却(毫秒)

    private ModbusMaster master;
    private long lastConnectFailAt = 0;

    public ModbusTcpClient(String host, int port, int slaveId, long reconnectIntervalMs) {
        this.host = host; this.port = port; this.slaveId = slaveId;
        this.reconnectIntervalMs = reconnectIntervalMs;
    }

    /** 确保已连:冷却期内直接返回 false,避免频繁建连 */
    public synchronized boolean ensureConnected() {
        if (master != null && master.isInitialized()) return true;
        long now = System.currentTimeMillis();
        if (now - lastConnectFailAt < reconnectIntervalMs) return false;
        try {
            IpParameters p = new IpParameters();
            p.setHost(host); p.setPort(port);
            master = new ModbusFactory().createTcpMaster(p, true);
            master.setTimeout(2000);
            master.setRetries(1);
            master.init();
            lastConnectFailAt = 0; // 连接成功,清除冷却
            return true;
        } catch (Exception e) {
            lastConnectFailAt = now; // 进入冷却
            master = null;
            return false;
        }
    }

    /** 安全读单个 16 位保持寄存器(4x,offset 从 0 起) */
    public Integer readHoldingU16(int offset) {
        try {
            if (!ensureConnected()) return null;
            BaseLocator<Number> loc = BaseLocator.holdingRegister(
                    slaveId, offset, com.serotonin.modbus4j.code.DataType.TWO_BYTE_INT_UNSIGNED);
            Number n = master.getValue(loc);
            return n == null ? null : n.intValue();
        } catch (Exception e) {
            drop(); // 读失败,丢弃连接,下次冷却后才重建
            return null;
        }
    }

    /** 写单个线圈(bool),返回是否成功 */
    public boolean writeCoil(int offset, boolean value) {
        try {
            if (!ensureConnected()) return false;
            WriteCoilRequest req = new WriteCoilRequest(slaveId, offset, value);
            WriteCoilResponse res = (WriteCoilResponse) master.send(req);
            return !res.isException();
        } catch (Exception e) {
            drop();
            return false;
        }
    }

    /** 丢弃连接(仅置空,下次 ensureConnected 再建) */
    public synchronized void drop() {
        try { if (master != null) master.destroy(); } catch (Exception ignored) {}
        master = null;
    }
}
java 复制代码
// 定时读取调度:每 READ_PERIOD 读一次;离线设备每 RECONNECT_INTERVAL 才重连一次
import java.util.concurrent.*;

public class PollingDemo {
    static final long READ_PERIOD_MS = 3000;       // 读取频率:3 秒
    static final long RECONNECT_INTERVAL_MS = 30000; // 重连冷却:30 秒

    public static void main(String[] args) {
        ModbusTcpClient client = new ModbusTcpClient("192.168.1.10", 502, 1, RECONNECT_INTERVAL_MS);
        ScheduledExecutorService sch = Executors.newSingleThreadScheduledExecutor();
        sch.scheduleAtFixedRate(() -> {
            Integer v = client.readHoldingU16(0);   // 读控制/状态寄存器
            if (v == null) {
                System.out.println("本次未读取(设备离线或处于重连冷却)");
            } else {
                System.out.println("寄存器0 = " + v);
            }
        }, 0, READ_PERIOD_MS, TimeUnit.MILLISECONDS);
    }
}

6.3 与业务无关的通用前端示例(Vue,状态展示 + 离线禁用)

vue 复制代码
<template>
  <div class="device-card">
    <el-tag :type="statusType">{{ device.status }}</el-tag>
    <el-switch
      v-model="device.on"
      :disabled="!online"
      @change="onControl"
    ></el-switch>
    <span :style="{ color: device.on ? '#13ce66' : '#ff7875' }">
      {{ device.on ? '开启' : '关闭' }}
    </span>
  </div>
</template>

<script>
export default {
  props: { device: Object },
  computed: {
    // 在线 = 状态为 online;其它(offline/fault/stop)按业务定义
    online() { return this.device.status === 'online'; },
    statusType() {
      if (this.online) return 'success';
      if (this.device.status === 'fault') return 'danger';
      return 'info'; // offline / stop
    }
  },
  methods: {
    onControl(val) {
      // 调后端控制接口(通用:传设备标识 + 开关值)
      this.$api.control({ id: this.device.id, on: val });
    }
  }
};
</script>

通用约定:后端状态枚举建议用 online/offline/fault(或 running/stopped/offline),前端只认这几个字符串即可,与具体 PLC 点位无关。本项目用的是 运行/停止/离线/故障,前端做兼容即可。


七、排障与常见问题

现象 原因 解决
读取偶发黑屏/数据为 null 但过会儿恢复 isInitialized() 误判链路存活,实际已断 读写抛异常后 destroyMaster,下一周期重建
离线设备疯狂重连(日志刷屏) 每 3 秒采集都尝试建连 开启重连冷却 (本文方案),plc.reconnect.interval 调大
改了 plc.reconnect.interval 不生效 运行库无该配置行 sys_config 插入该 key,或依赖代码 30 秒兜底
写入控制位把其它功能弄乱 直接写整个寄存器覆盖 bit 改用 读-改-写writeHoldingRegisterBit
前端状态不显示"离线" 后端无实时数据时应返回"离线" 检查 Redis 是否有该设备实时数据
离线时操作按钮仍可用 未禁用 :disabled 加上 status === '离线'
相关推荐
SimonKing18 小时前
阿里要求全员卸载 Claude Code:事件始末与深层逻辑
java·后端·程序员
武子康19 小时前
FDE 到底是什么:为什么 AI 时代重新需要前线部署工程师(4 个标准 + 8 类风险 + 10 个问题)
人工智能·后端·openai
咖啡无伴侣19 小时前
Vue3 DOM 异步更新与性能优化全套学习笔记
前端·vue.js
xianjixiance_19 小时前
鸿蒙原生开发手记:徒步迹 - 轨迹记录:暂停/继续/停止
后端
猫猫不是喵喵.19 小时前
SpringBoot自动装配原理
java·spring boot·后端
用户7138742290019 小时前
Claude Code Skills 深度解析:参数传递与上下文预注入
后端
薛定猫AI19 小时前
【技术干货】大模型能力评测实战:Python构建可复现的模型选型流水线
人工智能·后端
用户7138742290019 小时前
Claude Agent Skills 的四种设计模式;从渐进式披露到最小权限
后端
sugar__salt19 小时前
Vue3 基础知识点整理 —— 从入门到响应式核心完全指南
vue.js·笔记·vue·vue3