当前位置: 首页 > news >正文

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

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


一、核心概念

1.1 Modbus TCP 基础

概念说明
主站 / 从站后端程序是Modbus 主站(Master),PLC 是从站(Slave)。一个主站可轮询多台从站。
从站地址 slaveIdPLC 的站号(如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
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(只读)
// 控制寄存器 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的关键参数:
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)。

// 有效性检查:仅判断“是否 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 周期性采集 + 重连冷却(核心定时任务)

// 读取重连冷却时间(单位:秒),配置项 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; }
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); }
} catch (Exception e) { System.out.println(" 通信失败: 网络或端口不可达 - " + e.getMessage()); // 记录失败时间,进入 30s 重连冷却(冷却期内该设备将被跳过,不再尝试建连) lastReconnectFail.put(deviceKey, System.currentTimeMillis()); modbusMasterPool.destroyMaster(deviceKey); // 销毁无效连接 }

3.3 后端状态判定(离线)

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

// 从 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并重试一次,确保用户点按钮时能即时重建连接(不受采集冷却影响):

public Boolean writeToDevice(...) { ModbusMaster master = getOrCreateMaster(device); if (!checkMasterValid(master)) { master = rebuildMaster(device); // 写操作即时重建,无需等待冷却 } try { // 读-改-写 置位对应 bit ... } catch (Exception e) { // 失败再 rebuild 重试一次 } }

四、API 说明

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

方法功能异常
getMaster(ip, port)创建并 init 一个 TCP 长连接 masterModbusInitException
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.2ModbusMasterPool(连接池)

方法说明
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

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
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)。

<el-tag :type=" (device.status === '运行' || device.status === '运行中') ? 'success' : device.status === '故障' ? 'danger' : (device.status === '离线' || device.status === '停止') ? 'info' : 'warning' " >{{ device.status }}</el-tag>
<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 客户端 + 重连冷却 + 定时读取骨架。

importcom.serotonin.modbus4j.*;importcom.serotonin.modbus4j.ip.IpParameters;importcom.serotonin.modbus4j.locator.BaseLocator;publicclassModbusTcpClient{privatefinalStringhost;privatefinalintport;privatefinalintslaveId;privatefinallongreconnectIntervalMs;// 重连冷却(毫秒)privateModbusMastermaster;privatelonglastConnectFailAt=0;publicModbusTcpClient(Stringhost,intport,intslaveId,longreconnectIntervalMs){this.host=host;this.port=port;this.slaveId=slaveId;this.reconnectIntervalMs=reconnectIntervalMs;}/** 确保已连:冷却期内直接返回 false,避免频繁建连 */publicsynchronizedbooleanensureConnected(){if(master!=null&&master.isInitialized())returntrue;longnow=System.currentTimeMillis();if(now-lastConnectFailAt<reconnectIntervalMs)returnfalse;try{IpParametersp=newIpParameters();p.setHost(host);p.setPort(port);master=newModbusFactory().createTcpMaster(p,true);master.setTimeout(2000);master.setRetries(1);master.init();lastConnectFailAt=0;// 连接成功,清除冷却returntrue;}catch(Exceptione){lastConnectFailAt=now;// 进入冷却master=null;returnfalse;}}/** 安全读单个 16 位保持寄存器(4x,offset 从 0 起) */publicIntegerreadHoldingU16(intoffset){try{if(!ensureConnected())returnnull;BaseLocator<Number>loc=BaseLocator.holdingRegister(slaveId,offset,com.serotonin.modbus4j.code.DataType.TWO_BYTE_INT_UNSIGNED);Numbern=master.getValue(loc);returnn==null?null:n.intValue();}catch(Exceptione){drop();// 读失败,丢弃连接,下次冷却后才重建returnnull;}}/** 写单个线圈(bool),返回是否成功 */publicbooleanwriteCoil(intoffset,booleanvalue){try{if(!ensureConnected())returnfalse;WriteCoilRequestreq=newWriteCoilRequest(slaveId,offset,value);WriteCoilResponseres=(WriteCoilResponse)master.send(req);return!res.isException();}catch(Exceptione){drop();returnfalse;}}/** 丢弃连接(仅置空,下次 ensureConnected 再建) */publicsynchronizedvoiddrop(){try{if(master!=null)master.destroy();}catch(Exceptionignored){}master=null;}}
// 定时读取调度:每 READ_PERIOD 读一次;离线设备每 RECONNECT_INTERVAL 才重连一次importjava.util.concurrent.*;publicclassPollingDemo{staticfinallongREAD_PERIOD_MS=3000;// 读取频率:3 秒staticfinallongRECONNECT_INTERVAL_MS=30000;// 重连冷却:30 秒publicstaticvoidmain(String[]args){ModbusTcpClientclient=newModbusTcpClient("192.168.1.10",502,1,RECONNECT_INTERVAL_MS);ScheduledExecutorServicesch=Executors.newSingleThreadScheduledExecutor();sch.scheduleAtFixedRate(()->{Integerv=client.readHoldingU16(0);// 读控制/状态寄存器if(v==null){System.out.println("本次未读取(设备离线或处于重连冷却)");}else{System.out.println("寄存器0 = "+v);}},0,READ_PERIOD_MS,TimeUnit.MILLISECONDS);}}

6.3 与业务无关的通用前端示例(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 === '离线'
http://www.cnnetsun.cn/news/3523377.html

相关文章:

  • ECS-Network-Racing-Sample UI系统设计:如何在DOTS架构下构建响应式用户界面
  • TMS320F2838x McBSP中断机制与多通道模式配置详解
  • 【湿法-萃取工艺6】---2#萃取(萃铜锰)---使用P204萃取剂后-全流程解析
  • 终极指南:asdf-python自动化配置与默认Python包一键安装技巧
  • 多模型协同的稳定性设计:主备切换不是加一个 if-else
  • 基于 ThinkPHP 与 Workerman 的高并发聚合支付系统架构设计与实践
  • 【湿法-萃取工艺8】---4# P507全萃钴、P204深萃钴 全流程解析
  • 深度解析Electron+Vue技术栈的磁力搜索应用架构设计
  • 治愈系微文案的数据驱动优化:从直觉写作到埋点验证的界面文案迭代
  • Clarity社区贡献指南:从问题报告到代码提交的完整流程
  • 紧急修复!Kimi搜索突然返回空结果的4种底层原因(DNS劫持/SSL证书链异常/Referer策略变更实测对比)
  • 开源项目的性能回馈机制:用户侧性能数据的采集与问题复现方法
  • 联邦学习 + 区块链:去中心化 AI 训练的隐私保护与激励设计
  • Kimera-Semantics 实战:在Euroc数据集上运行语义重建的完整流程
  • GHelper深度评测:华硕笔记本性能优化的轻量级革命
  • 3步掌握LDDC:让每首歌都有完美歌词的终极指南
  • HarmonyOS7 购物车计数器页实战:Counter/步进器 不只是会用,还要用得顺手
  • VLC for Android:打破格式限制,你的移动娱乐中心
  • 如何使用node-jsonc-parser实现JSON Schema验证与智能提示
  • rtsp-stream性能对比:与传统RTSP代理服务器的优劣分析
  • 鸿蒙原生开发手记:徒步迹 - 扫码功能:Scan Kit 集成
  • 把代码库探索交给 Claude Code Subagent,主会话才不会被文件读取拖垮
  • Spy Extension vs 普通扩展:核心差异与安全风险对比
  • 如何高效处理文本数据:Python字符串匹配实战指南
  • TI C2000 ERAD模块实战:硬件级调试与性能分析全解析
  • Docker 容器化部署的实用技巧:镜像优化、多阶段构建与日志
  • 文件读写全套易错笔记
  • Web Application Basics
  • SQL 数据库创建学生表+SQL 完整创建数据库代码(主文件+次文件+日志文件)
  • Ansible for Kubernetes多环境管理:从开发到生产的完整CI/CD流水线设计