mirror of
https://gitee.com/zhijiantianya/ruoyi-vue-pro.git
synced 2026-03-22 05:07:17 +08:00
feat(iot):1)重构 modbus tcp 连接的实现为 modbus-tcp-master;2)新增 modbus-tcp-slave【初步实现,代码准备优化】
This commit is contained in:
@@ -33,6 +33,14 @@ public class IotDeviceModbusConfigRespVO {
|
||||
@Schema(description = "重试间隔(毫秒)", example = "1000")
|
||||
private Integer retryInterval;
|
||||
|
||||
// TODO @AI:不要【:1-云端轮询 2-主动上报】
|
||||
@Schema(description = "模式:1-云端轮询 2-主动上报", example = "1")
|
||||
private Integer mode;
|
||||
|
||||
// TODO @AI:还是换成 int,然后写注释;不要【:modbus_tcp / modbus_rtu】
|
||||
@Schema(description = "数据帧格式:modbus_tcp / modbus_rtu", example = "modbus_tcp")
|
||||
private String frameFormat;
|
||||
|
||||
@Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
|
||||
private Integer status;
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@ public class IotDeviceModbusConfigSaveReqVO {
|
||||
@Schema(description = "重试间隔(毫秒)", example = "1000")
|
||||
private Integer retryInterval;
|
||||
|
||||
// TODO @AI:不要【:1-云端轮询 2-主动上报】
|
||||
@Schema(description = "模式:1-云端轮询 2-主动上报", example = "1")
|
||||
private Integer mode;
|
||||
|
||||
// TODO @AI:不要【:1-云端轮询 2-主动上报】
|
||||
@Schema(description = "数据帧格式:modbus_tcp / modbus_rtu", example = "modbus_tcp")
|
||||
private String frameFormat;
|
||||
|
||||
@Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
|
||||
@NotNull(message = "状态不能为空")
|
||||
private Integer status;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.iocoder.yudao.module.iot.dal.dataobject.device;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
@@ -24,7 +25,12 @@ public class IotDeviceModbusConfigDO extends TenantBaseDO {
|
||||
*/
|
||||
@TableId
|
||||
private Long id;
|
||||
// TODO @AI:增加 productId;
|
||||
/**
|
||||
* 产品编号
|
||||
*
|
||||
* 关联 {@link IotProductDO#getId()}
|
||||
*/
|
||||
private Long productId;
|
||||
/**
|
||||
* 设备编号
|
||||
*
|
||||
@@ -52,6 +58,18 @@ public class IotDeviceModbusConfigDO extends TenantBaseDO {
|
||||
* 重试间隔,单位:毫秒
|
||||
*/
|
||||
private Integer retryInterval;
|
||||
/**
|
||||
* 模式
|
||||
*
|
||||
* @see cn.iocoder.yudao.module.iot.core.enums.IotModbusModeEnum
|
||||
*/
|
||||
private Integer mode;
|
||||
/**
|
||||
* 数据帧格式
|
||||
*
|
||||
* @see cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum
|
||||
*/
|
||||
private String frameFormat;
|
||||
/**
|
||||
* 状态
|
||||
*
|
||||
|
||||
@@ -47,8 +47,16 @@ public class IotModbusDeviceConfigRespDTO {
|
||||
* 重试间隔,单位:毫秒
|
||||
*/
|
||||
private Integer retryInterval;
|
||||
/**
|
||||
* 模式
|
||||
*/
|
||||
private Integer mode;
|
||||
/**
|
||||
* 数据帧格式
|
||||
*/
|
||||
private String frameFormat;
|
||||
|
||||
// ========== 点位配置 ==========
|
||||
// ========== Modbus 点位配置 ==========
|
||||
|
||||
/**
|
||||
* 点位列表
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.iocoder.yudao.module.iot.core.enums;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* IoT Modbus 数据帧格式枚举
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum IotModbusFrameFormatEnum implements ArrayValuable<String> {
|
||||
|
||||
MODBUS_TCP("modbus_tcp", "Modbus TCP"),
|
||||
MODBUS_RTU("modbus_rtu", "Modbus RTU");
|
||||
|
||||
public static final String[] ARRAYS = Arrays.stream(values())
|
||||
.map(IotModbusFrameFormatEnum::getFormat)
|
||||
.toArray(String[]::new);
|
||||
|
||||
/**
|
||||
* 格式
|
||||
*/
|
||||
private final String format;
|
||||
/**
|
||||
* 名称
|
||||
*/
|
||||
private final String name;
|
||||
|
||||
@Override
|
||||
public String[] array() {
|
||||
return ARRAYS;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.iocoder.yudao.module.iot.core.enums;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* IoT Modbus 模式枚举
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum IotModbusModeEnum implements ArrayValuable<Integer> {
|
||||
|
||||
POLLING(1, "云端轮询"),
|
||||
ACTIVE_REPORT(2, "主动上报");
|
||||
|
||||
public static final Integer[] ARRAYS = Arrays.stream(values())
|
||||
.map(IotModbusModeEnum::getMode)
|
||||
.toArray(Integer[]::new);
|
||||
|
||||
/**
|
||||
* 模式
|
||||
*/
|
||||
private final Integer mode;
|
||||
/**
|
||||
* 名称
|
||||
*/
|
||||
private final String name;
|
||||
|
||||
@Override
|
||||
public Integer[] array() {
|
||||
return ARRAYS;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,7 +25,8 @@ public enum IotProtocolTypeEnum implements ArrayValuable<String> {
|
||||
MQTT("mqtt"),
|
||||
EMQX("emqx"),
|
||||
COAP("coap"),
|
||||
MODBUS_TCP("modbus_tcp");
|
||||
MODBUS_TCP_MASTER("modbus_tcp_master"),
|
||||
MODBUS_TCP_SLAVE("modbus_tcp_slave");
|
||||
|
||||
public static final String[] ARRAYS = Arrays.stream(values()).map(IotProtocolTypeEnum::getType).toArray(String[]::new);
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.IotModbusTcpConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.IotModbusTcpMasterConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.IotModbusTcpSlaveConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpConfig;
|
||||
@@ -168,10 +169,16 @@ public class IotGatewayProperties {
|
||||
private IotEmqxConfig emqx;
|
||||
|
||||
/**
|
||||
* Modbus TCP 协议配置
|
||||
* Modbus TCP Master 协议配置
|
||||
*/
|
||||
@Valid
|
||||
private IotModbusTcpConfig modbusTcp;
|
||||
private IotModbusTcpMasterConfig modbusTcpMaster;
|
||||
|
||||
/**
|
||||
* Modbus TCP Slave 协议配置
|
||||
*/
|
||||
@Valid
|
||||
private IotModbusTcpSlaveConfig modbusTcpSlave;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.IotModbusTcpProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.IotModbusTcpMasterProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.IotModbusTcpSlaveProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpProtocol;
|
||||
@@ -113,8 +114,10 @@ public class IotProtocolManager implements SmartLifecycle {
|
||||
return createMqttProtocol(config);
|
||||
case EMQX:
|
||||
return createEmqxProtocol(config);
|
||||
case MODBUS_TCP:
|
||||
return createModbusTcpProtocol(config);
|
||||
case MODBUS_TCP_MASTER:
|
||||
return createModbusTcpMasterProtocol(config);
|
||||
case MODBUS_TCP_SLAVE:
|
||||
return createModbusTcpSlaveProtocol(config);
|
||||
default:
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"[createProtocol][协议实例 %s 的协议类型 %s 暂不支持]", config.getId(), protocolType));
|
||||
@@ -192,13 +195,23 @@ public class IotProtocolManager implements SmartLifecycle {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Modbus TCP 协议实例
|
||||
* 创建 Modbus TCP Master 协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return Modbus TCP 协议实例
|
||||
* @return Modbus TCP Master 协议实例
|
||||
*/
|
||||
private IotModbusTcpProtocol createModbusTcpProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotModbusTcpProtocol(config);
|
||||
private IotModbusTcpMasterProtocol createModbusTcpMasterProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotModbusTcpMasterProtocol(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Modbus TCP Slave 协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return Modbus TCP Slave 协议实例
|
||||
*/
|
||||
private IotModbusTcpSlaveProtocol createModbusTcpSlaveProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotModbusTcpSlaveProtocol(config);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.codec;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common;
|
||||
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
@@ -1,16 +1,16 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP 协议配置
|
||||
* IoT Modbus TCP Master 协议配置
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
public class IotModbusTcpConfig {
|
||||
public class IotModbusTcpMasterConfig {
|
||||
|
||||
/**
|
||||
* 配置刷新间隔(秒)
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
@@ -9,14 +9,14 @@ import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.client.IotModbusTcpClient;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.codec.IotModbusDataConverter;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.handler.downstream.IotModbusTcpDownstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.handler.downstream.IotModbusTcpDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.handler.upstream.IotModbusTcpUpstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.manager.IotModbusTcpConfigCacheService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.manager.IotModbusTcpConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.manager.IotModbusTcpPollScheduler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.IotModbusDataConverter;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.client.IotModbusTcpClient;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.handler.downstream.IotModbusTcpDownstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.handler.downstream.IotModbusTcpDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.handler.upstream.IotModbusTcpUpstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.manager.IotModbusTcpConfigCacheService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.manager.IotModbusTcpConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.manager.IotModbusTcpPollScheduler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.core.Vertx;
|
||||
import lombok.Getter;
|
||||
@@ -27,12 +27,12 @@ import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* IoT 网关 Modbus TCP 协议:主动轮询 Modbus 从站设备数据
|
||||
* IoT 网关 Modbus TCP Master 协议:主动轮询 Modbus 从站设备数据
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusTcpProtocol implements IotProtocol {
|
||||
public class IotModbusTcpMasterProtocol implements IotProtocol {
|
||||
|
||||
/**
|
||||
* 协议配置
|
||||
@@ -71,9 +71,9 @@ public class IotModbusTcpProtocol implements IotProtocol {
|
||||
private final IotModbusTcpConfigCacheService configCacheService;
|
||||
private final IotModbusTcpPollScheduler pollScheduler;
|
||||
|
||||
public IotModbusTcpProtocol(ProtocolProperties properties) {
|
||||
IotModbusTcpConfig modbusTcpConfig = properties.getModbusTcp();
|
||||
Assert.notNull(modbusTcpConfig, "Modbus TCP 协议配置(modbusTcp)不能为空");
|
||||
public IotModbusTcpMasterProtocol(ProtocolProperties properties) {
|
||||
IotModbusTcpMasterConfig modbusTcpMasterConfig = properties.getModbusTcpMaster();
|
||||
Assert.notNull(modbusTcpMasterConfig, "Modbus TCP Master 协议配置(modbusTcpMaster)不能为空");
|
||||
this.properties = properties;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort());
|
||||
|
||||
@@ -109,13 +109,13 @@ public class IotModbusTcpProtocol implements IotProtocol {
|
||||
|
||||
@Override
|
||||
public IotProtocolTypeEnum getType() {
|
||||
return IotProtocolTypeEnum.MODBUS_TCP;
|
||||
return IotProtocolTypeEnum.MODBUS_TCP_MASTER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (running) {
|
||||
log.warn("[start][IoT Modbus TCP 协议 {} 已经在运行中]", getId());
|
||||
log.warn("[start][IoT Modbus TCP Master 协议 {} 已经在运行中]", getId());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,18 +123,18 @@ public class IotModbusTcpProtocol implements IotProtocol {
|
||||
// 1.1 首次加载配置
|
||||
refreshConfig();
|
||||
// 1.2 启动配置刷新定时器
|
||||
int refreshInterval = properties.getModbusTcp().getConfigRefreshInterval();
|
||||
int refreshInterval = properties.getModbusTcpMaster().getConfigRefreshInterval();
|
||||
configRefreshTimerId = vertx.setPeriodic(
|
||||
TimeUnit.SECONDS.toMillis(refreshInterval),
|
||||
id -> refreshConfig()
|
||||
);
|
||||
running = true;
|
||||
log.info("[start][IoT Modbus TCP 协议 {} 启动成功,serverId={}]", getId(), serverId);
|
||||
log.info("[start][IoT Modbus TCP Master 协议 {} 启动成功,serverId={}]", getId(), serverId);
|
||||
|
||||
// 2. 启动下行消息订阅者
|
||||
this.downstreamSubscriber.start();
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT Modbus TCP 协议 {} 启动失败]", getId(), e);
|
||||
log.error("[start][IoT Modbus TCP Master 协议 {} 启动失败]", getId(), e);
|
||||
// 启动失败时关闭资源
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
@@ -151,9 +151,9 @@ public class IotModbusTcpProtocol implements IotProtocol {
|
||||
// 1. 停止下行消息订阅者
|
||||
try {
|
||||
downstreamSubscriber.stop();
|
||||
log.info("[stop][IoT Modbus TCP 协议 {} 下行消息订阅者已停止]", getId());
|
||||
log.info("[stop][IoT Modbus TCP Master 协议 {} 下行消息订阅者已停止]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT Modbus TCP 协议 {} 下行消息订阅者停止失败]", getId(), e);
|
||||
log.error("[stop][IoT Modbus TCP Master 协议 {} 下行消息订阅者停止失败]", getId(), e);
|
||||
}
|
||||
|
||||
// 2.1 取消配置刷新定时器
|
||||
@@ -163,22 +163,22 @@ public class IotModbusTcpProtocol implements IotProtocol {
|
||||
}
|
||||
// 2.2 停止轮询调度器
|
||||
pollScheduler.stopAll();
|
||||
log.info("[stop][IoT Modbus TCP 协议 {} 轮询调度器已停止]", getId());
|
||||
log.info("[stop][IoT Modbus TCP Master 协议 {} 轮询调度器已停止]", getId());
|
||||
// 2.3 关闭所有连接
|
||||
connectionManager.closeAll();
|
||||
log.info("[stop][IoT Modbus TCP 协议 {} 连接管理器已关闭]", getId());
|
||||
log.info("[stop][IoT Modbus TCP Master 协议 {} 连接管理器已关闭]", getId());
|
||||
|
||||
// 3. 关闭 Vert.x 实例
|
||||
if (vertx != null) {
|
||||
try {
|
||||
vertx.close().result();
|
||||
log.info("[stop][IoT Modbus TCP 协议 {} Vertx 已关闭]", getId());
|
||||
log.info("[stop][IoT Modbus TCP Master 协议 {} Vertx 已关闭]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT Modbus TCP 协议 {} Vertx 关闭失败]", getId(), e);
|
||||
log.error("[stop][IoT Modbus TCP Master 协议 {} Vertx 关闭失败]", getId(), e);
|
||||
}
|
||||
}
|
||||
running = false;
|
||||
log.info("[stop][IoT Modbus TCP 协议 {} 已停止]", getId());
|
||||
log.info("[stop][IoT Modbus TCP Master 协议 {} 已停止]", getId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1,8 +1,8 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.client;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.client;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFunctionCodeEnum;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.manager.IotModbusTcpConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.manager.IotModbusTcpConnectionManager;
|
||||
import com.ghgande.j2mod.modbus.io.ModbusTCPTransaction;
|
||||
import com.ghgande.j2mod.modbus.msg.*;
|
||||
import com.ghgande.j2mod.modbus.procimg.InputRegister;
|
||||
@@ -12,6 +12,7 @@ import com.ghgande.j2mod.modbus.util.BitVector;
|
||||
import io.vertx.core.Future;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
// TODO @AI:感觉它更像一个工具类;但是名字叫 client 很奇怪;
|
||||
/**
|
||||
* IoT Modbus TCP 客户端
|
||||
* <p>
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.handler.downstream;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.handler.downstream;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
@@ -6,10 +6,10 @@ import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFunctionCodeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.client.IotModbusTcpClient;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.codec.IotModbusDataConverter;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.manager.IotModbusTcpConfigCacheService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.manager.IotModbusTcpConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.IotModbusDataConverter;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.client.IotModbusTcpClient;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.manager.IotModbusTcpConfigCacheService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.manager.IotModbusTcpConnectionManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.handler.downstream;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.handler.downstream;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.IotModbusTcpProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.IotModbusTcpMasterProtocol;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
// TODO @AI:是不是可以继承 /Users/yunai/Java/ruoyi-vue-pro-jdk25/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolDownstreamSubscriber.java
|
||||
/**
|
||||
* IoT Modbus TCP 下行消息订阅器:订阅消息总线的下行消息并转发给处理器
|
||||
*
|
||||
@@ -17,7 +18,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Slf4j
|
||||
public class IotModbusTcpDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> {
|
||||
|
||||
private final IotModbusTcpProtocol protocol;
|
||||
private final IotModbusTcpMasterProtocol protocol;
|
||||
private final IotModbusTcpDownstreamHandler downstreamHandler;
|
||||
private final IotMessageBus messageBus;
|
||||
|
||||
@@ -26,7 +27,7 @@ public class IotModbusTcpDownstreamSubscriber implements IotMessageSubscriber<Io
|
||||
*/
|
||||
public void start() {
|
||||
messageBus.register(this);
|
||||
log.info("[start][Modbus TCP 下行消息订阅器已启动, topic={}]", getTopic());
|
||||
log.info("[start][Modbus TCP Master 下行消息订阅器已启动, topic={}]", getTopic());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +35,7 @@ public class IotModbusTcpDownstreamSubscriber implements IotMessageSubscriber<Io
|
||||
*/
|
||||
public void stop() {
|
||||
messageBus.unregister(this);
|
||||
log.info("[stop][Modbus TCP 下行消息订阅器已停止]");
|
||||
log.info("[stop][Modbus TCP Master 下行消息订阅器已停止]");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1,11 +1,11 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.handler.upstream;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.handler.upstream;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.codec.IotModbusDataConverter;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.IotModbusDataConverter;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.manager;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.manager;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.manager;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.manager;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import com.ghgande.j2mod.modbus.net.TCPMasterConnection;
|
||||
@@ -1,10 +1,10 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.manager;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.manager;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.client.IotModbusTcpClient;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp.handler.upstream.IotModbusTcpUpstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.client.IotModbusTcpClient;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster.handler.upstream.IotModbusTcpUpstreamHandler;
|
||||
import io.vertx.core.Vertx;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
@@ -16,6 +16,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
|
||||
|
||||
// TODO @AI:类的命名上,要体现上 master。其它类似 /Users/yunai/Java/ruoyi-vue-pro-jdk25/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpmaster 也要!
|
||||
/**
|
||||
* IoT Modbus TCP 轮询调度器:管理点位的轮询定时器,调度读取任务并上报结果
|
||||
*
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Modbus TCP 协议实现包
|
||||
* Modbus TCP Master 协议实现包
|
||||
* <p>
|
||||
* 提供基于 j2mod 的 Modbus TCP 主站(Master)功能,支持:
|
||||
* 1. 定时轮询 Modbus 从站设备数据
|
||||
* 2. 下发属性设置命令到从站设备
|
||||
* 3. 数据格式转换(寄存器值 ↔ 物模型属性值)
|
||||
*/
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster;
|
||||
@@ -0,0 +1,43 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP Slave 协议配置
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
public class IotModbusTcpSlaveConfig {
|
||||
|
||||
/**
|
||||
* 配置刷新间隔(秒)
|
||||
*/
|
||||
@NotNull(message = "配置刷新间隔不能为空")
|
||||
@Min(value = 1, message = "配置刷新间隔不能小于 1 秒")
|
||||
private Integer configRefreshInterval = 30;
|
||||
|
||||
/**
|
||||
* 自定义功能码(用于认证等扩展交互)
|
||||
* Modbus 协议保留 65-72 给用户自定义,默认 65
|
||||
*/
|
||||
@NotNull(message = "自定义功能码不能为空")
|
||||
@Min(value = 65, message = "自定义功能码不能小于 65")
|
||||
// TODO @AI:搞个范围;
|
||||
private Integer customFunctionCode = 65;
|
||||
|
||||
/**
|
||||
* Pending Request 超时时间(毫秒)
|
||||
*/
|
||||
@NotNull(message = "请求超时时间不能为空")
|
||||
private Integer requestTimeout = 5000;
|
||||
|
||||
/**
|
||||
* Pending Request 清理间隔(毫秒)
|
||||
*/
|
||||
@NotNull(message = "请求清理间隔不能为空")
|
||||
private Integer requestCleanupInterval = 10000;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusModeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.IotModbusDataConverter;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrame;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrameCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusRecordParserFactory;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.handler.downstream.IotModbusTcpSlaveDownstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.handler.downstream.IotModbusTcpSlaveDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.handler.upstream.IotModbusTcpSlaveUpstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlaveConfigCacheService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlaveConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlaveConnectionManager.ConnectionInfo;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlavePendingRequestManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlavePollScheduler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.net.NetServer;
|
||||
import io.vertx.core.net.NetServerOptions;
|
||||
import io.vertx.core.net.NetSocket;
|
||||
import io.vertx.core.parsetools.RecordParser;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
// TODO @AI:不用主动上报!
|
||||
/**
|
||||
* IoT 网关 Modbus TCP Slave 协议
|
||||
* <p>
|
||||
* 作为 TCP Server 接收设备主动连接:
|
||||
* 1. 设备通过自定义功能码(FC 65)发送认证请求
|
||||
* 2. 认证成功后,根据设备配置的 mode 决定工作模式:
|
||||
* - mode=1(云端轮询):网关主动发送 Modbus 读请求,设备响应
|
||||
* - mode=2(主动上报):设备主动上报数据,网关透传
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusTcpSlaveProtocol implements IotProtocol {
|
||||
|
||||
/**
|
||||
* 协议配置
|
||||
*/
|
||||
private final ProtocolProperties properties;
|
||||
/**
|
||||
* 服务器 ID(用于消息追踪,全局唯一)
|
||||
*/
|
||||
@Getter
|
||||
private final String serverId;
|
||||
|
||||
/**
|
||||
* 运行状态
|
||||
*/
|
||||
@Getter
|
||||
private volatile boolean running = false;
|
||||
|
||||
/**
|
||||
* Vert.x 实例
|
||||
*/
|
||||
private final Vertx vertx;
|
||||
/**
|
||||
* TCP Server
|
||||
*/
|
||||
private NetServer netServer;
|
||||
/**
|
||||
* 配置刷新定时器 ID
|
||||
*/
|
||||
private Long configRefreshTimerId;
|
||||
/**
|
||||
* Pending Request 清理定时器 ID
|
||||
*/
|
||||
private Long requestCleanupTimerId;
|
||||
|
||||
/**
|
||||
* 未认证连接的帧格式缓存:socket → 检测到的帧格式
|
||||
*/
|
||||
private final Map<NetSocket, IotModbusFrameFormatEnum> pendingFrameFormats = new ConcurrentHashMap<>();
|
||||
|
||||
// ========== 各组件 ==========
|
||||
|
||||
private final IotModbusTcpSlaveConfig slaveConfig;
|
||||
private final IotModbusFrameCodec frameCodec;
|
||||
private final IotModbusTcpSlaveConnectionManager connectionManager;
|
||||
private final IotModbusTcpSlaveConfigCacheService configCacheService;
|
||||
private final IotModbusTcpSlavePendingRequestManager pendingRequestManager;
|
||||
private final IotModbusTcpSlaveUpstreamHandler upstreamHandler;
|
||||
private final IotModbusTcpSlaveDownstreamHandler downstreamHandler;
|
||||
private final IotModbusTcpSlaveDownstreamSubscriber downstreamSubscriber;
|
||||
private final IotModbusTcpSlavePollScheduler pollScheduler;
|
||||
|
||||
public IotModbusTcpSlaveProtocol(ProtocolProperties properties) {
|
||||
this.slaveConfig = properties.getModbusTcpSlave();
|
||||
Assert.notNull(slaveConfig, "Modbus TCP Slave 协议配置(modbusTcpSlave)不能为空");
|
||||
this.properties = properties;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort());
|
||||
|
||||
// 初始化 Vertx
|
||||
this.vertx = Vertx.vertx();
|
||||
|
||||
// 初始化 Manager
|
||||
IotDeviceCommonApi deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
this.connectionManager = new IotModbusTcpSlaveConnectionManager();
|
||||
this.configCacheService = new IotModbusTcpSlaveConfigCacheService(deviceApi);
|
||||
this.pendingRequestManager = new IotModbusTcpSlavePendingRequestManager();
|
||||
|
||||
// 初始化帧编解码器
|
||||
this.frameCodec = new IotModbusFrameCodec(slaveConfig.getCustomFunctionCode());
|
||||
|
||||
// 初始化 Handler
|
||||
IotModbusDataConverter dataConverter = new IotModbusDataConverter();
|
||||
IotDeviceMessageService messageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
this.upstreamHandler = new IotModbusTcpSlaveUpstreamHandler(
|
||||
deviceApi, messageService, dataConverter, frameCodec,
|
||||
connectionManager, configCacheService, pendingRequestManager, serverId);
|
||||
this.downstreamHandler = new IotModbusTcpSlaveDownstreamHandler(
|
||||
connectionManager, configCacheService, dataConverter, frameCodec);
|
||||
|
||||
// 初始化轮询调度器
|
||||
this.pollScheduler = new IotModbusTcpSlavePollScheduler(
|
||||
vertx, connectionManager, frameCodec, pendingRequestManager,
|
||||
slaveConfig.getRequestTimeout());
|
||||
|
||||
// 设置认证成功回调:启动轮询
|
||||
// TODO @AI:感觉直接去调用,不用注册回调了(更简洁)
|
||||
this.upstreamHandler.setOnAuthSuccess((deviceId, config) -> {
|
||||
if (config.getMode() != null
|
||||
&& config.getMode().equals(IotModbusModeEnum.POLLING.getMode())) {
|
||||
pollScheduler.updatePolling(config);
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化下行消息订阅者
|
||||
IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class);
|
||||
this.downstreamSubscriber = new IotModbusTcpSlaveDownstreamSubscriber(
|
||||
this, downstreamHandler, messageBus);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return properties.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IotProtocolTypeEnum getType() {
|
||||
return IotProtocolTypeEnum.MODBUS_TCP_SLAVE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (running) {
|
||||
log.warn("[start][IoT Modbus TCP Slave 协议 {} 已经在运行中]", getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1.1 首次加载配置
|
||||
refreshConfig();
|
||||
// 1.2 启动配置刷新定时器
|
||||
int refreshInterval = slaveConfig.getConfigRefreshInterval();
|
||||
configRefreshTimerId = vertx.setPeriodic(
|
||||
TimeUnit.SECONDS.toMillis(refreshInterval),
|
||||
id -> refreshConfig());
|
||||
|
||||
// 2.1 启动 TCP Server
|
||||
startTcpServer();
|
||||
|
||||
// 2.2 启动 PendingRequest 清理定时器
|
||||
requestCleanupTimerId = vertx.setPeriodic(
|
||||
slaveConfig.getRequestCleanupInterval(),
|
||||
id -> pendingRequestManager.cleanupExpired());
|
||||
running = true;
|
||||
log.info("[start][IoT Modbus TCP Slave 协议 {} 启动成功, serverId={}, port={}]",
|
||||
getId(), serverId, properties.getPort());
|
||||
|
||||
// 3. 启动下行消息订阅
|
||||
downstreamSubscriber.start();
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT Modbus TCP Slave 协议 {} 启动失败]", getId(), e);
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
}
|
||||
// TODO @AI:其它相关的 close;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 停止下行消息订阅
|
||||
try {
|
||||
downstreamSubscriber.stop();
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][下行消息订阅器停止失败]", e);
|
||||
}
|
||||
|
||||
// 2.1 取消定时器
|
||||
if (configRefreshTimerId != null) {
|
||||
vertx.cancelTimer(configRefreshTimerId);
|
||||
configRefreshTimerId = null;
|
||||
}
|
||||
if (requestCleanupTimerId != null) {
|
||||
vertx.cancelTimer(requestCleanupTimerId);
|
||||
requestCleanupTimerId = null;
|
||||
}
|
||||
// 2.2 停止轮询
|
||||
pollScheduler.stopAll();
|
||||
// 2.3 清理 PendingRequest
|
||||
pendingRequestManager.clear();
|
||||
// 2.3 关闭所有连接
|
||||
connectionManager.closeAll();
|
||||
// 2.4 关闭 TCP Server
|
||||
if (netServer != null) {
|
||||
try {
|
||||
netServer.close().result();
|
||||
log.info("[stop][TCP Server 已关闭]");
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][TCP Server 关闭失败]", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 关闭 Vertx
|
||||
if (vertx != null) {
|
||||
try {
|
||||
vertx.close().result();
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][Vertx 关闭失败]", e);
|
||||
}
|
||||
}
|
||||
running = false;
|
||||
log.info("[stop][IoT Modbus TCP Slave 协议 {} 已停止]", getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 TCP Server
|
||||
*/
|
||||
private void startTcpServer() {
|
||||
// TODO @AI:host 一定要设置么?
|
||||
// 1. 创建 TCP Server
|
||||
NetServerOptions options = new NetServerOptions()
|
||||
.setPort(properties.getPort())
|
||||
.setHost("0.0.0.0");
|
||||
netServer = vertx.createNetServer(options);
|
||||
|
||||
// 2. 设置连接处理器
|
||||
netServer.connectHandler(this::handleConnection);
|
||||
// TODO @AI:是不是 sync 就好,不用 onSuccess/onFailure 了?感觉更简洁。失败,肯定就要抛出异常,结束初始化了!
|
||||
netServer.listen()
|
||||
.onSuccess(server -> log.info("[startTcpServer][TCP Server 启动成功, port={}]",
|
||||
server.actualPort()))
|
||||
.onFailure(e -> log.error("[startTcpServer][TCP Server 启动失败]", e));
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新连接
|
||||
*/
|
||||
private void handleConnection(NetSocket socket) {
|
||||
log.info("[handleConnection][新连接, remoteAddress={}]", socket.remoteAddress());
|
||||
|
||||
// 1.1 创建带帧格式检测的 RecordParser
|
||||
// TODO @AI:看看怎么从这个类里面,拿出去;让这个类的职责更单一;
|
||||
RecordParser parser = IotModbusRecordParserFactory.create(
|
||||
slaveConfig.getCustomFunctionCode(),
|
||||
// 完整帧回调
|
||||
// TODO @AI:感觉搞个独立的类,稍微好点?!
|
||||
frameBuffer -> {
|
||||
byte[] frameBytes = frameBuffer.getBytes();
|
||||
// 获取该连接的帧格式
|
||||
ConnectionInfo connInfo = connectionManager.getConnectionInfo(socket);
|
||||
IotModbusFrameFormatEnum frameFormat = connInfo != null ? connInfo.getFrameFormat() : null;
|
||||
if (frameFormat == null) {
|
||||
// 未认证的连接,使用首帧检测到的帧格式
|
||||
frameFormat = pendingFrameFormats.get(socket);
|
||||
}
|
||||
if (frameFormat == null) {
|
||||
log.warn("[handleConnection][帧格式未检测到, remoteAddress={}]", socket.remoteAddress());
|
||||
return;
|
||||
}
|
||||
|
||||
// 解码帧
|
||||
IotModbusFrame frame = frameCodec.decodeResponse(frameBytes, frameFormat);
|
||||
// 交给 UpstreamHandler 处理
|
||||
upstreamHandler.handleFrame(socket, frame, frameFormat);
|
||||
},
|
||||
// 帧格式检测回调:保存到未认证缓存
|
||||
detectedFormat -> {
|
||||
// TODO @AI:是不是不用缓存,每次都探测;因为一般 auth 首包后,基本也没探测的诉求了!
|
||||
pendingFrameFormats.put(socket, detectedFormat);
|
||||
// 如果连接已注册(不太可能在检测阶段),也更新
|
||||
// TODO @AI:是否非必须?!
|
||||
connectionManager.setFrameFormat(socket, detectedFormat);
|
||||
log.debug("[handleConnection][帧格式检测: {}, remoteAddress={}]",
|
||||
detectedFormat, socket.remoteAddress());
|
||||
}
|
||||
);
|
||||
// 1.2 设置数据处理器
|
||||
socket.handler(parser);
|
||||
|
||||
// 2.1 连接关闭处理
|
||||
socket.closeHandler(v -> {
|
||||
pendingFrameFormats.remove(socket);
|
||||
ConnectionInfo info = connectionManager.removeConnection(socket);
|
||||
// TODO @AI:if return 简化下;
|
||||
if (info != null && info.getDeviceId() != null) {
|
||||
pollScheduler.stopPolling(info.getDeviceId());
|
||||
pendingRequestManager.removeDevice(info.getDeviceId());
|
||||
log.info("[handleConnection][连接关闭, deviceId={}, remoteAddress={}]",
|
||||
info.getDeviceId(), socket.remoteAddress());
|
||||
} else {
|
||||
log.info("[handleConnection][未认证连接关闭, remoteAddress={}]", socket.remoteAddress());
|
||||
}
|
||||
});
|
||||
// 2.2 异常处理
|
||||
socket.exceptionHandler(e -> {
|
||||
log.error("[handleConnection][连接异常, remoteAddress={}]", socket.remoteAddress(), e);
|
||||
socket.close();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新配置
|
||||
*/
|
||||
private synchronized void refreshConfig() {
|
||||
try {
|
||||
// 1. 从 biz 拉取最新配置
|
||||
List<IotModbusDeviceConfigRespDTO> configs = configCacheService.refreshConfig();
|
||||
log.debug("[refreshConfig][获取到 {} 个 Modbus 设备配置]", configs.size());
|
||||
|
||||
// 2. 更新已连接设备的轮询任务(仅 mode=1)
|
||||
for (IotModbusDeviceConfigRespDTO config : configs) {
|
||||
try {
|
||||
if (config.getMode() != null
|
||||
&& config.getMode().equals(IotModbusModeEnum.POLLING.getMode())) {
|
||||
// 只有已连接的设备才启动轮询
|
||||
ConnectionInfo connInfo = connectionManager.getConnectionInfoByDeviceId(config.getDeviceId());
|
||||
if (connInfo != null) {
|
||||
pollScheduler.updatePolling(config);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[refreshConfig][处理设备配置失败, deviceId={}]", config.getDeviceId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 清理已删除设备的资源
|
||||
configCacheService.cleanupRemovedDevices(configs, deviceId -> {
|
||||
pollScheduler.stopPolling(deviceId);
|
||||
pendingRequestManager.removeDevice(deviceId);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("[refreshConfig][刷新配置失败]", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/**
|
||||
* IoT Modbus 统一帧数据模型(TCP/RTU 公用)
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class IotModbusFrame {
|
||||
|
||||
/**
|
||||
* 从站地址
|
||||
*/
|
||||
private int slaveId;
|
||||
/**
|
||||
* 功能码
|
||||
*/
|
||||
private int functionCode;
|
||||
/**
|
||||
* PDU 数据(不含 slaveId)
|
||||
*/
|
||||
private byte[] pdu;
|
||||
/**
|
||||
* 事务标识符(TCP 模式特有)
|
||||
*
|
||||
* // TODO @AI:最好是 @某个类型独有;
|
||||
*/
|
||||
private Integer transactionId;
|
||||
|
||||
/**
|
||||
* 是否异常响应
|
||||
*/
|
||||
private boolean exception;
|
||||
// TODO @AI:是不是要枚举一些异常;另外,是不是覆盖掉 exception;因为只要判断有异常码是不是就可以了;
|
||||
/**
|
||||
* 异常码(当 exception=true 时有效)
|
||||
*/
|
||||
private Integer exceptionCode;
|
||||
|
||||
/**
|
||||
* 自定义功能码时的 JSON 字符串
|
||||
*/
|
||||
private String customData;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* IoT Modbus 帧编解码器
|
||||
* <p>
|
||||
* 纯 Modbus 协议编解码,不处理 TCP 粘包(由 RecordParser 处理)。
|
||||
* 支持 MODBUS_TCP(MBAP)和 MODBUS_RTU(CRC16)两种帧格式,以及自定义功能码扩展。
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusFrameCodec {
|
||||
|
||||
private final int customFunctionCode;
|
||||
|
||||
public IotModbusFrameCodec(int customFunctionCode) {
|
||||
this.customFunctionCode = customFunctionCode;
|
||||
}
|
||||
|
||||
// ==================== 解码 ====================
|
||||
|
||||
/**
|
||||
* 解码响应帧(拆包后的完整帧 byte[])
|
||||
*
|
||||
* @param data 完整帧字节数组
|
||||
* @param format 帧格式
|
||||
* @return 解码后的 IotModbusFrame
|
||||
*/
|
||||
public IotModbusFrame decodeResponse(byte[] data, IotModbusFrameFormatEnum format) {
|
||||
if (format == IotModbusFrameFormatEnum.MODBUS_TCP) {
|
||||
return decodeTcpResponse(data);
|
||||
} else {
|
||||
return decodeRtuResponse(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 MODBUS_TCP 响应
|
||||
* 格式:[TransactionId(2)] [ProtocolId(2)] [Length(2)] [UnitId(1)] [FC(1)] [Data...]
|
||||
*/
|
||||
private IotModbusFrame decodeTcpResponse(byte[] data) {
|
||||
if (data.length < 8) {
|
||||
log.warn("[decodeTcpResponse][数据长度不足: {}]", data.length);
|
||||
return null;
|
||||
}
|
||||
ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN);
|
||||
int transactionId = buf.getShort() & 0xFFFF;
|
||||
buf.getShort(); // protocolId(跳过)// TODO @AI:跳过原因,最好写下;
|
||||
buf.getShort(); // length(跳过)// TODO @AI:跳过原因,最好写下;
|
||||
int slaveId = buf.get() & 0xFF;
|
||||
int functionCode = buf.get() & 0xFF;
|
||||
// 提取 PDU 数据(从 functionCode 之后到末尾)
|
||||
byte[] pdu = new byte[data.length - 8];
|
||||
System.arraycopy(data, 8, pdu, 0, pdu.length);
|
||||
|
||||
// 构建 IotModbusFrame
|
||||
return buildFrame(slaveId, functionCode, pdu, transactionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 MODBUS_RTU 响应
|
||||
* 格式:[SlaveId(1)] [FC(1)] [Data...] [CRC(2)]
|
||||
*/
|
||||
private IotModbusFrame decodeRtuResponse(byte[] data) {
|
||||
if (data.length < 4) {
|
||||
log.warn("[decodeRtuResponse][数据长度不足: {}]", data.length);
|
||||
return null;
|
||||
}
|
||||
// 校验 CRC
|
||||
if (!verifyCrc16(data)) {
|
||||
log.warn("[decodeRtuResponse][CRC 校验失败]");
|
||||
return null;
|
||||
}
|
||||
int slaveId = data[0] & 0xFF;
|
||||
int functionCode = data[1] & 0xFF;
|
||||
// PDU 数据(不含 slaveId、functionCode、CRC)
|
||||
byte[] pdu = new byte[data.length - 4];
|
||||
System.arraycopy(data, 2, pdu, 0, pdu.length);
|
||||
|
||||
// 构建 IotModbusFrame
|
||||
return buildFrame(slaveId, functionCode, pdu, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 IotModbusFrame
|
||||
*/
|
||||
private IotModbusFrame buildFrame(int slaveId, int functionCode, byte[] pdu, Integer transactionId) {
|
||||
IotModbusFrame frame = new IotModbusFrame()
|
||||
.setSlaveId(slaveId)
|
||||
.setFunctionCode(functionCode)
|
||||
.setPdu(pdu)
|
||||
.setTransactionId(transactionId);
|
||||
|
||||
// 异常响应
|
||||
// TODO @AI:0x80 看看是不是要枚举;
|
||||
if ((functionCode & 0x80) != 0) {
|
||||
frame.setException(true);
|
||||
// TODO @AI:0x7f 看看是不是要枚举;
|
||||
frame.setFunctionCode(functionCode & 0x7F);
|
||||
if (pdu.length >= 1) {
|
||||
frame.setExceptionCode(pdu[0] & 0xFF);
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
// 自定义功能码
|
||||
if (functionCode == customFunctionCode) {
|
||||
// data 区格式:[byteCount(1)] [JSON data(N)]
|
||||
if (pdu.length >= 1) {
|
||||
int byteCount = pdu[0] & 0xFF;
|
||||
if (pdu.length >= 1 + byteCount) {
|
||||
frame.setCustomData(new String(pdu, 1, byteCount, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
// ==================== 编码 ====================
|
||||
|
||||
/**
|
||||
* 编码读请求
|
||||
*
|
||||
* @param slaveId 从站地址
|
||||
* @param functionCode 功能码
|
||||
* @param startAddress 起始寄存器地址
|
||||
* @param quantity 寄存器数量
|
||||
* @param format 帧格式
|
||||
* @param transactionId 事务 ID(TCP 模式下使用)
|
||||
* @return 编码后的字节数组
|
||||
*/
|
||||
public byte[] encodeReadRequest(int slaveId, int functionCode, int startAddress, int quantity,
|
||||
IotModbusFrameFormatEnum format, int transactionId) {
|
||||
// PDU: [FC(1)] [StartAddress(2)] [Quantity(2)]
|
||||
byte[] pdu = new byte[5];
|
||||
pdu[0] = (byte) functionCode;
|
||||
pdu[1] = (byte) ((startAddress >> 8) & 0xFF);
|
||||
pdu[2] = (byte) (startAddress & 0xFF);
|
||||
pdu[3] = (byte) ((quantity >> 8) & 0xFF);
|
||||
pdu[4] = (byte) (quantity & 0xFF);
|
||||
return wrapFrame(slaveId, pdu, format, transactionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码写请求(单个寄存器 FC06 / 单个线圈 FC05)
|
||||
*
|
||||
* @param slaveId 从站地址
|
||||
* @param functionCode 功能码
|
||||
* @param address 寄存器地址
|
||||
* @param value 值
|
||||
* @param format 帧格式
|
||||
* @param transactionId 事务 ID
|
||||
* @return 编码后的字节数组
|
||||
*/
|
||||
public byte[] encodeWriteSingleRequest(int slaveId, int functionCode, int address, int value,
|
||||
IotModbusFrameFormatEnum format, int transactionId) {
|
||||
// PDU: [FC(1)] [Address(2)] [Value(2)]
|
||||
byte[] pdu = new byte[5];
|
||||
pdu[0] = (byte) functionCode;
|
||||
pdu[1] = (byte) ((address >> 8) & 0xFF);
|
||||
pdu[2] = (byte) (address & 0xFF);
|
||||
pdu[3] = (byte) ((value >> 8) & 0xFF);
|
||||
pdu[4] = (byte) (value & 0xFF);
|
||||
return wrapFrame(slaveId, pdu, format, transactionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码写多个寄存器请求(FC16)
|
||||
*
|
||||
* @param slaveId 从站地址
|
||||
* @param address 起始地址
|
||||
* @param values 值数组
|
||||
* @param format 帧格式
|
||||
* @param transactionId 事务 ID
|
||||
* @return 编码后的字节数组
|
||||
*/
|
||||
public byte[] encodeWriteMultipleRegistersRequest(int slaveId, int address, int[] values,
|
||||
IotModbusFrameFormatEnum format, int transactionId) {
|
||||
// PDU: [FC(1)] [Address(2)] [Quantity(2)] [ByteCount(1)] [Values(N*2)]
|
||||
int quantity = values.length;
|
||||
int byteCount = quantity * 2;
|
||||
byte[] pdu = new byte[6 + byteCount];
|
||||
pdu[0] = (byte) 16; // FC16
|
||||
pdu[1] = (byte) ((address >> 8) & 0xFF);
|
||||
pdu[2] = (byte) (address & 0xFF);
|
||||
pdu[3] = (byte) ((quantity >> 8) & 0xFF);
|
||||
pdu[4] = (byte) (quantity & 0xFF);
|
||||
pdu[5] = (byte) byteCount;
|
||||
for (int i = 0; i < quantity; i++) {
|
||||
pdu[6 + i * 2] = (byte) ((values[i] >> 8) & 0xFF);
|
||||
pdu[6 + i * 2 + 1] = (byte) (values[i] & 0xFF);
|
||||
}
|
||||
return wrapFrame(slaveId, pdu, format, transactionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码自定义功能码帧(认证响应等)
|
||||
*
|
||||
* @param slaveId 从站地址
|
||||
* @param jsonData JSON 数据
|
||||
* @param format 帧格式
|
||||
* @param transactionId 事务 ID
|
||||
* @return 编码后的字节数组
|
||||
*/
|
||||
public byte[] encodeCustomFrame(int slaveId, String jsonData,
|
||||
IotModbusFrameFormatEnum format, int transactionId) {
|
||||
byte[] jsonBytes = jsonData.getBytes(StandardCharsets.UTF_8);
|
||||
// PDU: [FC(1)] [ByteCount(1)] [JSON data(N)]
|
||||
byte[] pdu = new byte[2 + jsonBytes.length];
|
||||
pdu[0] = (byte) customFunctionCode;
|
||||
pdu[1] = (byte) jsonBytes.length;
|
||||
System.arraycopy(jsonBytes, 0, pdu, 2, jsonBytes.length);
|
||||
return wrapFrame(slaveId, pdu, format, transactionId);
|
||||
}
|
||||
|
||||
// ==================== 帧封装 ====================
|
||||
|
||||
/**
|
||||
* 将 PDU 封装为完整帧
|
||||
*
|
||||
* @param slaveId 从站地址
|
||||
* @param pdu PDU 数据(含 functionCode)
|
||||
* @param format 帧格式
|
||||
* @param transactionId 事务 ID(TCP 模式下使用)
|
||||
* @return 完整帧字节数组
|
||||
*/
|
||||
private byte[] wrapFrame(int slaveId, byte[] pdu, IotModbusFrameFormatEnum format, int transactionId) {
|
||||
if (format == IotModbusFrameFormatEnum.MODBUS_TCP) {
|
||||
return wrapTcpFrame(slaveId, pdu, transactionId);
|
||||
} else {
|
||||
return wrapRtuFrame(slaveId, pdu);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装 MODBUS_TCP 帧
|
||||
* [TransactionId(2)] [ProtocolId(2,=0x0000)] [Length(2)] [UnitId(1)] [PDU...]
|
||||
*/
|
||||
private byte[] wrapTcpFrame(int slaveId, byte[] pdu, int transactionId) {
|
||||
int length = 1 + pdu.length; // UnitId + PDU
|
||||
byte[] frame = new byte[6 + length]; // MBAP(6) + UnitId(1) + PDU
|
||||
// MBAP Header
|
||||
frame[0] = (byte) ((transactionId >> 8) & 0xFF);
|
||||
frame[1] = (byte) (transactionId & 0xFF);
|
||||
frame[2] = 0; // Protocol ID high
|
||||
frame[3] = 0; // Protocol ID low
|
||||
frame[4] = (byte) ((length >> 8) & 0xFF);
|
||||
frame[5] = (byte) (length & 0xFF);
|
||||
// Unit ID
|
||||
frame[6] = (byte) slaveId;
|
||||
// PDU
|
||||
System.arraycopy(pdu, 0, frame, 7, pdu.length);
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* 封装 MODBUS_RTU 帧
|
||||
* [SlaveId(1)] [PDU...] [CRC(2)]
|
||||
*/
|
||||
private byte[] wrapRtuFrame(int slaveId, byte[] pdu) {
|
||||
byte[] frame = new byte[1 + pdu.length + 2]; // SlaveId + PDU + CRC
|
||||
frame[0] = (byte) slaveId;
|
||||
System.arraycopy(pdu, 0, frame, 1, pdu.length);
|
||||
// 计算并追加 CRC16
|
||||
int crc = calculateCrc16(frame, frame.length - 2);
|
||||
frame[frame.length - 2] = (byte) (crc & 0xFF); // CRC Low
|
||||
frame[frame.length - 1] = (byte) ((crc >> 8) & 0xFF); // CRC High
|
||||
return frame;
|
||||
}
|
||||
|
||||
// ==================== CRC16 工具 ====================
|
||||
|
||||
// TODO @AI:hutool 等,有没工具类可以用
|
||||
/**
|
||||
* 计算 CRC-16/MODBUS
|
||||
*
|
||||
* @param data 数据
|
||||
* @param length 计算长度
|
||||
* @return CRC16 值
|
||||
*/
|
||||
public static int calculateCrc16(byte[] data, int length) {
|
||||
int crc = 0xFFFF;
|
||||
for (int i = 0; i < length; i++) {
|
||||
crc ^= (data[i] & 0xFF);
|
||||
for (int j = 0; j < 8; j++) {
|
||||
if ((crc & 0x0001) != 0) {
|
||||
crc >>= 1;
|
||||
crc ^= 0xA001;
|
||||
} else {
|
||||
crc >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
// TODO @AI:hutool 等,有没工具类可以用
|
||||
/**
|
||||
* 校验 CRC16
|
||||
*/
|
||||
private boolean verifyCrc16(byte[] data) {
|
||||
if (data.length < 3) {
|
||||
return false;
|
||||
}
|
||||
int computed = calculateCrc16(data, data.length - 2);
|
||||
int received = (data[data.length - 2] & 0xFF) | ((data[data.length - 1] & 0xFF) << 8);
|
||||
return computed == received;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
|
||||
import io.vertx.core.Handler;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.parsetools.RecordParser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
// TODO @AI:看看是不是不要搞成 factory,而是直接 new;(可以一起讨论下)
|
||||
/**
|
||||
* IoT Modbus RecordParser 工厂
|
||||
* <p>
|
||||
* 创建带自动帧格式检测的 RecordParser:
|
||||
* 1. 首帧检测:读前 6 字节,判断 MODBUS_TCP(ProtocolId==0x0000 且 Length 合理)或 MODBUS_RTU
|
||||
* 2. 检测后自动切换到对应的拆包模式
|
||||
* - MODBUS_TCP:两阶段 RecordParser(MBAP length 字段驱动)
|
||||
* - MODBUS_RTU:功能码驱动的状态机
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusRecordParserFactory {
|
||||
|
||||
/**
|
||||
* 创建带自动帧格式检测的 RecordParser
|
||||
*
|
||||
* @param customFunctionCode 自定义功能码
|
||||
* @param frameHandler 完整帧回调
|
||||
* @param onFormatDetected 帧格式检测回调
|
||||
* @return RecordParser 实例
|
||||
*/
|
||||
public static RecordParser create(int customFunctionCode,
|
||||
Handler<Buffer> frameHandler,
|
||||
Consumer<IotModbusFrameFormatEnum> onFormatDetected) {
|
||||
// 先创建一个 RecordParser,使用 fixedSizeMode(6) 读取首帧前 6 字节进行帧格式检测
|
||||
// TODO @AI:最小需要 6 个字节么?有可能更小的情况下,就探测出来?!
|
||||
RecordParser parser = RecordParser.newFixed(6);
|
||||
parser.handler(new DetectPhaseHandler(parser, customFunctionCode, frameHandler, onFormatDetected));
|
||||
return parser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 帧格式检测阶段 Handler
|
||||
*/
|
||||
@SuppressWarnings("ClassCanBeRecord")
|
||||
private static class DetectPhaseHandler implements Handler<Buffer> {
|
||||
|
||||
private final RecordParser parser;
|
||||
private final int customFunctionCode;
|
||||
private final Handler<Buffer> frameHandler;
|
||||
private final Consumer<IotModbusFrameFormatEnum> onFormatDetected;
|
||||
|
||||
// TODO @AI:简化构造方法,使用 lombok;
|
||||
DetectPhaseHandler(RecordParser parser, int customFunctionCode,
|
||||
Handler<Buffer> frameHandler,
|
||||
Consumer<IotModbusFrameFormatEnum> onFormatDetected) {
|
||||
this.parser = parser;
|
||||
this.customFunctionCode = customFunctionCode;
|
||||
this.frameHandler = frameHandler;
|
||||
this.onFormatDetected = onFormatDetected;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(Buffer buffer) {
|
||||
byte[] header = buffer.getBytes();
|
||||
// 检测:byte[2]==0x00 && byte[3]==0x00 && 1<=length<=253
|
||||
int protocolId = ((header[2] & 0xFF) << 8) | (header[3] & 0xFF);
|
||||
int length = ((header[4] & 0xFF) << 8) | (header[5] & 0xFF);
|
||||
|
||||
if (protocolId == 0x0000 && length >= 1 && length <= 253) {
|
||||
// MODBUS_TCP
|
||||
log.debug("[DetectPhaseHandler][检测到 MODBUS_TCP 帧格式]");
|
||||
onFormatDetected.accept(IotModbusFrameFormatEnum.MODBUS_TCP);
|
||||
// 切换到 TCP 拆包模式,处理当前首帧
|
||||
TcpFrameHandler tcpHandler = new TcpFrameHandler(parser, frameHandler);
|
||||
parser.handler(tcpHandler);
|
||||
// 当前 header 是 MBAP 的前 6 字节,需要继续读 length 字节
|
||||
tcpHandler.handleMbapHeader(header, length);
|
||||
} else {
|
||||
// MODBUS_RTU
|
||||
log.debug("[DetectPhaseHandler][检测到 MODBUS_RTU 帧格式]");
|
||||
onFormatDetected.accept(IotModbusFrameFormatEnum.MODBUS_RTU);
|
||||
// 切换到 RTU 拆包模式,处理当前首帧
|
||||
RtuFrameHandler rtuHandler = new RtuFrameHandler(parser, customFunctionCode, frameHandler);
|
||||
parser.handler(rtuHandler);
|
||||
// 当前 header 包含前 6 字节(slaveId + FC + 部分数据),需要拼接处理
|
||||
rtuHandler.handleInitialBytes(header);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MODBUS_TCP 拆包 Handler(两阶段 RecordParser)
|
||||
* Phase 1: fixedSizeMode(6) → 读 MBAP 前 6 字节,提取 length
|
||||
* Phase 2: fixedSizeMode(length) → 读 unitId + PDU
|
||||
*/
|
||||
private static class TcpFrameHandler implements Handler<Buffer> {
|
||||
|
||||
private final RecordParser parser;
|
||||
private final Handler<Buffer> frameHandler;
|
||||
private byte[] mbapHeader;
|
||||
private boolean waitingForBody = false;
|
||||
|
||||
// TODO @AI:lombok
|
||||
TcpFrameHandler(RecordParser parser, Handler<Buffer> frameHandler) {
|
||||
this.parser = parser;
|
||||
this.frameHandler = frameHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理首帧的 MBAP 头
|
||||
*/
|
||||
void handleMbapHeader(byte[] header, int length) {
|
||||
this.mbapHeader = header;
|
||||
this.waitingForBody = true;
|
||||
parser.fixedSizeMode(length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(Buffer buffer) {
|
||||
if (waitingForBody) {
|
||||
// Phase 2: 收到 body(unitId + PDU)
|
||||
byte[] body = buffer.getBytes();
|
||||
// 拼接完整帧:MBAP(6) + body
|
||||
Buffer frame = Buffer.buffer(mbapHeader.length + body.length);
|
||||
frame.appendBytes(mbapHeader);
|
||||
frame.appendBytes(body);
|
||||
frameHandler.handle(frame);
|
||||
// 切回 Phase 1
|
||||
waitingForBody = false;
|
||||
mbapHeader = null;
|
||||
parser.fixedSizeMode(6);
|
||||
} else {
|
||||
// Phase 1: 收到 MBAP 头 6 字节
|
||||
byte[] header = buffer.getBytes();
|
||||
int length = ((header[4] & 0xFF) << 8) | (header[5] & 0xFF);
|
||||
if (length < 1 || length > 253) {
|
||||
log.warn("[TcpFrameHandler][MBAP Length 异常: {}]", length);
|
||||
parser.fixedSizeMode(6);
|
||||
return;
|
||||
}
|
||||
this.mbapHeader = header;
|
||||
this.waitingForBody = true;
|
||||
parser.fixedSizeMode(length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MODBUS_RTU 拆包 Handler(功能码驱动的状态机)
|
||||
* <p>
|
||||
* 状态机流程:
|
||||
* Phase 1: fixedSizeMode(2) → 读 slaveId + functionCode
|
||||
* Phase 2: 根据 functionCode 确定剩余长度:
|
||||
* - 异常响应 (FC & 0x80):fixedSizeMode(3) → exceptionCode(1) + CRC(2)
|
||||
* - 自定义 FC / FC01-04 响应:fixedSizeMode(1) → 读 byteCount → fixedSizeMode(byteCount + 2)
|
||||
* - FC05/06 响应:fixedSizeMode(6) → addr(2) + value(2) + CRC(2)
|
||||
* - FC15/16 响应:fixedSizeMode(6) → addr(2) + quantity(2) + CRC(2)
|
||||
*/
|
||||
private static class RtuFrameHandler implements Handler<Buffer> {
|
||||
|
||||
private static final int STATE_HEADER = 0;
|
||||
private static final int STATE_EXCEPTION_BODY = 1;
|
||||
private static final int STATE_READ_BYTE_COUNT = 2;
|
||||
private static final int STATE_READ_DATA = 3;
|
||||
private static final int STATE_WRITE_BODY = 4;
|
||||
|
||||
private final RecordParser parser;
|
||||
private final int customFunctionCode;
|
||||
private final Handler<Buffer> frameHandler;
|
||||
|
||||
private int state = STATE_HEADER;
|
||||
private byte slaveId;
|
||||
private byte functionCode;
|
||||
private byte byteCount;
|
||||
|
||||
// TODO @AI:lombok
|
||||
RtuFrameHandler(RecordParser parser, int customFunctionCode, Handler<Buffer> frameHandler) {
|
||||
this.parser = parser;
|
||||
this.customFunctionCode = customFunctionCode;
|
||||
this.frameHandler = frameHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理首帧检测阶段传来的初始 6 字节
|
||||
* 由于 RTU 首帧跳过了格式检测,我们需要拼接处理
|
||||
*/
|
||||
void handleInitialBytes(byte[] initialBytes) {
|
||||
// initialBytes 包含 6 字节:[slaveId][FC][...4 bytes...]
|
||||
this.slaveId = initialBytes[0];
|
||||
this.functionCode = initialBytes[1];
|
||||
int fc = functionCode & 0xFF;
|
||||
|
||||
// 根据功能码,确定还需要多少字节
|
||||
if ((fc & 0x80) != 0) {
|
||||
// 异常响应:还需要 exceptionCode(1) + CRC(2) = 3 字节
|
||||
// 我们已经有 4 字节剩余(initialBytes[2..5]),足够
|
||||
// 拼接完整帧并交付
|
||||
// 完整帧 = slaveId(1) + FC(1) + exceptionCode(1) + CRC(2) = 5
|
||||
Buffer frame = Buffer.buffer(5);
|
||||
frame.appendByte(slaveId);
|
||||
frame.appendByte(functionCode);
|
||||
frame.appendBytes(initialBytes, 2, 3); // exceptionCode + CRC
|
||||
frameHandler.handle(frame);
|
||||
// 剩余 1 字节需要留给下一帧,但 RecordParser 不支持回推
|
||||
// 简化处理:重置状态,开始读下一帧
|
||||
resetToHeader();
|
||||
} else if (isReadResponse(fc) || fc == customFunctionCode) {
|
||||
// 读响应或自定义 FC:initialBytes[2] = byteCount
|
||||
this.byteCount = initialBytes[2];
|
||||
int bc = byteCount & 0xFF;
|
||||
// 已有数据:initialBytes[3..5] = 3 字节
|
||||
// 还需:byteCount + CRC(2) - 3 字节已有
|
||||
int remaining = bc + 2 - 3;
|
||||
if (remaining <= 0) {
|
||||
// 数据已足够,组装完整帧
|
||||
int totalLen = 2 + 1 + bc + 2; // slaveId + FC + byteCount + data + CRC
|
||||
Buffer frame = Buffer.buffer(totalLen);
|
||||
frame.appendByte(slaveId);
|
||||
frame.appendByte(functionCode);
|
||||
frame.appendByte(byteCount);
|
||||
frame.appendBytes(initialBytes, 3, bc + 2); // data + CRC
|
||||
frameHandler.handle(frame);
|
||||
resetToHeader();
|
||||
} else {
|
||||
// 需要继续读
|
||||
state = STATE_READ_DATA;
|
||||
// 保存已有数据片段
|
||||
parser.fixedSizeMode(remaining);
|
||||
// 在 handle() 中需要拼接 initialBytes[3..5] + 新读取的数据
|
||||
// 为了简化,我们用一个 Buffer 暂存
|
||||
this.pendingData = Buffer.buffer();
|
||||
this.pendingData.appendBytes(initialBytes, 3, 3);
|
||||
this.expectedDataLen = bc + 2; // byteCount 个数据 + 2 CRC
|
||||
}
|
||||
} else if (isWriteResponse(fc)) {
|
||||
// 写响应:FC05/06/15/16,总长 = slaveId(1) + FC(1) + addr(2) + value/qty(2) + CRC(2) = 8
|
||||
// 已有 6 字节,还需 2 字节
|
||||
state = STATE_WRITE_BODY;
|
||||
this.pendingData = Buffer.buffer();
|
||||
this.pendingData.appendBytes(initialBytes, 2, 4); // 4 bytes already
|
||||
parser.fixedSizeMode(2); // need 2 more bytes (CRC)
|
||||
} else {
|
||||
log.warn("[RtuFrameHandler][未知功能码: 0x{}]", Integer.toHexString(fc));
|
||||
resetToHeader();
|
||||
}
|
||||
}
|
||||
|
||||
private Buffer pendingData;
|
||||
private int expectedDataLen;
|
||||
|
||||
@Override
|
||||
public void handle(Buffer buffer) {
|
||||
switch (state) {
|
||||
case STATE_HEADER:
|
||||
handleHeader(buffer);
|
||||
break;
|
||||
case STATE_EXCEPTION_BODY:
|
||||
handleExceptionBody(buffer);
|
||||
break;
|
||||
case STATE_READ_BYTE_COUNT:
|
||||
handleReadByteCount(buffer);
|
||||
break;
|
||||
case STATE_READ_DATA:
|
||||
handleReadData(buffer);
|
||||
break;
|
||||
case STATE_WRITE_BODY:
|
||||
handleWriteBody(buffer);
|
||||
break;
|
||||
default:
|
||||
resetToHeader();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleHeader(Buffer buffer) {
|
||||
byte[] header = buffer.getBytes();
|
||||
this.slaveId = header[0];
|
||||
this.functionCode = header[1];
|
||||
int fc = functionCode & 0xFF;
|
||||
|
||||
if ((fc & 0x80) != 0) {
|
||||
// 异常响应
|
||||
state = STATE_EXCEPTION_BODY;
|
||||
parser.fixedSizeMode(3); // exceptionCode(1) + CRC(2)
|
||||
} else if (isReadResponse(fc) || fc == customFunctionCode) {
|
||||
// 读响应或自定义 FC
|
||||
state = STATE_READ_BYTE_COUNT;
|
||||
parser.fixedSizeMode(1); // byteCount
|
||||
} else if (isWriteResponse(fc)) {
|
||||
// 写响应
|
||||
state = STATE_WRITE_BODY;
|
||||
pendingData = Buffer.buffer();
|
||||
parser.fixedSizeMode(6); // addr(2) + value(2) + CRC(2)
|
||||
} else {
|
||||
log.warn("[RtuFrameHandler][未知功能码: 0x{}]", Integer.toHexString(fc));
|
||||
resetToHeader();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleExceptionBody(Buffer buffer) {
|
||||
// buffer = exceptionCode(1) + CRC(2)
|
||||
Buffer frame = Buffer.buffer();
|
||||
frame.appendByte(slaveId);
|
||||
frame.appendByte(functionCode);
|
||||
frame.appendBuffer(buffer);
|
||||
frameHandler.handle(frame);
|
||||
resetToHeader();
|
||||
}
|
||||
|
||||
private void handleReadByteCount(Buffer buffer) {
|
||||
this.byteCount = buffer.getByte(0);
|
||||
int bc = byteCount & 0xFF;
|
||||
state = STATE_READ_DATA;
|
||||
pendingData = Buffer.buffer();
|
||||
expectedDataLen = bc + 2; // data(bc) + CRC(2)
|
||||
parser.fixedSizeMode(expectedDataLen);
|
||||
}
|
||||
|
||||
private void handleReadData(Buffer buffer) {
|
||||
pendingData.appendBuffer(buffer);
|
||||
if (pendingData.length() >= expectedDataLen) {
|
||||
// 组装完整帧
|
||||
Buffer frame = Buffer.buffer();
|
||||
frame.appendByte(slaveId);
|
||||
frame.appendByte(functionCode);
|
||||
frame.appendByte(byteCount);
|
||||
frame.appendBuffer(pendingData);
|
||||
frameHandler.handle(frame);
|
||||
resetToHeader();
|
||||
}
|
||||
// 否则继续等待(不应该发生,因为我们精确设置了 fixedSizeMode)
|
||||
}
|
||||
|
||||
private void handleWriteBody(Buffer buffer) {
|
||||
pendingData.appendBuffer(buffer);
|
||||
// 完整帧
|
||||
Buffer frame = Buffer.buffer();
|
||||
frame.appendByte(slaveId);
|
||||
frame.appendByte(functionCode);
|
||||
frame.appendBuffer(pendingData);
|
||||
frameHandler.handle(frame);
|
||||
resetToHeader();
|
||||
}
|
||||
|
||||
private void resetToHeader() {
|
||||
state = STATE_HEADER;
|
||||
pendingData = null;
|
||||
parser.fixedSizeMode(2); // slaveId + FC
|
||||
}
|
||||
|
||||
private boolean isReadResponse(int fc) {
|
||||
return fc >= 1 && fc <= 4;
|
||||
}
|
||||
|
||||
private boolean isWriteResponse(int fc) {
|
||||
return fc == 5 || fc == 6 || fc == 15 || fc == 16;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT Modbus 响应值提取器
|
||||
* <p>
|
||||
* 从解码后的 IotModbusFrame 中提取寄存器值,用于后续的点位翻译。
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusResponseParser {
|
||||
|
||||
/**
|
||||
* 从帧中提取寄存器值(FC01-04 读响应)
|
||||
*
|
||||
* @param frame 解码后的 Modbus 帧
|
||||
* @return 寄存器值数组(int[]),失败返回 null
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
public static int[] extractValues(IotModbusFrame frame) {
|
||||
if (frame == null || frame.isException()) {
|
||||
return null;
|
||||
}
|
||||
byte[] pdu = frame.getPdu();
|
||||
if (pdu == null || pdu.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO @AI:jmodbus 看看有没可以复用的枚举类
|
||||
int functionCode = frame.getFunctionCode();
|
||||
switch (functionCode) {
|
||||
case 1: // Read Coils
|
||||
case 2: // Read Discrete Inputs
|
||||
return extractCoilValues(pdu);
|
||||
case 3: // Read Holding Registers
|
||||
case 4: // Read Input Registers
|
||||
return extractRegisterValues(pdu);
|
||||
default:
|
||||
log.warn("[extractValues][不支持的功能码: {}]", functionCode);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取线圈/离散输入值
|
||||
* PDU 格式(FC01/02 响应):[ByteCount(1)] [CoilStatus(N)]
|
||||
*/
|
||||
private static int[] extractCoilValues(byte[] pdu) {
|
||||
if (pdu.length < 2) {
|
||||
return null;
|
||||
}
|
||||
int byteCount = pdu[0] & 0xFF;
|
||||
int bitCount = byteCount * 8;
|
||||
int[] values = new int[bitCount];
|
||||
for (int i = 0; i < bitCount && (1 + i / 8) < pdu.length; i++) {
|
||||
values[i] = ((pdu[1 + i / 8] >> (i % 8)) & 0x01);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取寄存器值
|
||||
* PDU 格式(FC03/04 响应):[ByteCount(1)] [RegisterData(N*2)]
|
||||
*/
|
||||
private static int[] extractRegisterValues(byte[] pdu) {
|
||||
if (pdu.length < 2) {
|
||||
return null;
|
||||
}
|
||||
int byteCount = pdu[0] & 0xFF;
|
||||
int registerCount = byteCount / 2;
|
||||
int[] values = new int[registerCount];
|
||||
for (int i = 0; i < registerCount && (1 + i * 2 + 1) < pdu.length; i++) {
|
||||
values[i] = ((pdu[1 + i * 2] & 0xFF) << 8) | (pdu[1 + i * 2 + 1] & 0xFF);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.handler.downstream;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFunctionCodeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.IotModbusDataConverter;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrameCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlaveConfigCacheService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlaveConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlaveConnectionManager.ConnectionInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
// TODO @AI:看看能不能和 /Users/yunai/Java/ruoyi-vue-pro-jdk25/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpmaster/handler/downstream/IotModbusTcpDownstreamHandler.java 有一些复用逻辑;
|
||||
/**
|
||||
* IoT Modbus TCP Slave 下行消息处理器
|
||||
* <p>
|
||||
* 负责:
|
||||
* 1. 处理下行消息(如属性设置 thing.service.property.set)
|
||||
* 2. 将属性值转换为 Modbus 写指令,通过 TCP 连接发送给设备
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IotModbusTcpSlaveDownstreamHandler {
|
||||
|
||||
private final IotModbusTcpSlaveConnectionManager connectionManager;
|
||||
private final IotModbusTcpSlaveConfigCacheService configCacheService;
|
||||
private final IotModbusDataConverter dataConverter;
|
||||
private final IotModbusFrameCodec frameCodec;
|
||||
|
||||
/**
|
||||
* TCP 事务 ID 自增器
|
||||
*/
|
||||
private final AtomicInteger transactionIdCounter = new AtomicInteger(0);
|
||||
|
||||
/**
|
||||
* 处理下行消息
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public void handle(IotDeviceMessage message) {
|
||||
// 1.1 检查是否是属性设置消息
|
||||
if (!IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod().equals(message.getMethod())) {
|
||||
log.debug("[handle][忽略非属性设置消息: {}]", message.getMethod());
|
||||
return;
|
||||
}
|
||||
// 1.2 获取设备配置
|
||||
IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(message.getDeviceId());
|
||||
if (config == null) {
|
||||
log.warn("[handle][设备 {} 没有 Modbus 配置]", message.getDeviceId());
|
||||
return;
|
||||
}
|
||||
// 1.3 获取连接信息
|
||||
ConnectionInfo connInfo = connectionManager.getConnectionInfoByDeviceId(message.getDeviceId());
|
||||
if (connInfo == null) {
|
||||
log.warn("[handle][设备 {} 没有连接]", message.getDeviceId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 解析属性值并写入
|
||||
Object params = message.getParams();
|
||||
if (!(params instanceof Map)) {
|
||||
log.warn("[handle][params 不是 Map 类型: {}]", params);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> propertyMap = (Map<String, Object>) params;
|
||||
for (Map.Entry<String, Object> entry : propertyMap.entrySet()) {
|
||||
String identifier = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
// 2.1 查找对应的点位配置
|
||||
IotModbusPointRespDTO point = findPoint(config, identifier);
|
||||
if (point == null) {
|
||||
log.warn("[handle][设备 {} 没有点位配置: {}]", message.getDeviceId(), identifier);
|
||||
continue;
|
||||
}
|
||||
// 2.2 检查是否支持写操作
|
||||
if (!isWritable(point.getFunctionCode())) {
|
||||
log.warn("[handle][点位 {} 不支持写操作, 功能码={}]", identifier, point.getFunctionCode());
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2.3 执行写入
|
||||
writeProperty(config.getDeviceId(), connInfo, point, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入属性值
|
||||
*/
|
||||
private void writeProperty(Long deviceId, ConnectionInfo connInfo,
|
||||
IotModbusPointRespDTO point, Object value) {
|
||||
// 1. 转换属性值为原始值
|
||||
int[] rawValues = dataConverter.convertToRawValues(value, point);
|
||||
|
||||
// 2. 确定帧格式和事务 ID
|
||||
IotModbusFrameFormatEnum frameFormat = connInfo.getFrameFormat();
|
||||
if (frameFormat == null) {
|
||||
frameFormat = IotModbusFrameFormatEnum.MODBUS_TCP;
|
||||
}
|
||||
int transactionId = transactionIdCounter.incrementAndGet() & 0xFFFF;
|
||||
int slaveId = connInfo.getSlaveId() != null ? connInfo.getSlaveId() : 1;
|
||||
|
||||
// 3. 编码写请求
|
||||
byte[] data;
|
||||
IotModbusFunctionCodeEnum fcEnum = IotModbusFunctionCodeEnum.valueOf(point.getFunctionCode());
|
||||
if (fcEnum == null) {
|
||||
log.warn("[writeProperty][未知功能码: {}]", point.getFunctionCode());
|
||||
return;
|
||||
}
|
||||
if (rawValues.length == 1 && fcEnum.getWriteSingleCode() != null) {
|
||||
// 单个值:使用单写功能码(FC05/FC06)
|
||||
data = frameCodec.encodeWriteSingleRequest(slaveId, fcEnum.getWriteSingleCode(),
|
||||
point.getRegisterAddress(), rawValues[0], frameFormat, transactionId);
|
||||
} else if (fcEnum.getWriteMultipleCode() != null) {
|
||||
// 多个值:使用多写功能码(FC15/FC16)
|
||||
data = frameCodec.encodeWriteMultipleRegistersRequest(slaveId,
|
||||
point.getRegisterAddress(), rawValues, frameFormat, transactionId);
|
||||
} else {
|
||||
log.warn("[writeProperty][点位 {} 不支持写操作]", point.getIdentifier());
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 发送
|
||||
connectionManager.sendToDevice(deviceId, data);
|
||||
log.info("[writeProperty][写入成功, deviceId={}, identifier={}, value={}]",
|
||||
deviceId, point.getIdentifier(), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找点位配置
|
||||
*/
|
||||
private IotModbusPointRespDTO findPoint(IotModbusDeviceConfigRespDTO config, String identifier) {
|
||||
return CollUtil.findOne(config.getPoints(), p -> identifier.equals(p.getIdentifier()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查功能码是否支持写操作
|
||||
*/
|
||||
private boolean isWritable(Integer functionCode) {
|
||||
IotModbusFunctionCodeEnum functionCodeEnum = IotModbusFunctionCodeEnum.valueOf(functionCode);
|
||||
return functionCodeEnum != null && Boolean.TRUE.equals(functionCodeEnum.getWritable());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.handler.downstream;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.IotModbusTcpSlaveProtocol;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
// TODO @AI:是不是可以继承 /Users/yunai/Java/ruoyi-vue-pro-jdk25/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolDownstreamSubscriber.java
|
||||
/**
|
||||
* IoT Modbus TCP Slave 下行消息订阅器:订阅消息总线的下行消息并转发给处理器
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IotModbusTcpSlaveDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> {
|
||||
|
||||
private final IotModbusTcpSlaveProtocol protocol;
|
||||
private final IotModbusTcpSlaveDownstreamHandler downstreamHandler;
|
||||
private final IotMessageBus messageBus;
|
||||
|
||||
/**
|
||||
* 启动订阅
|
||||
*/
|
||||
public void start() {
|
||||
messageBus.register(this);
|
||||
log.info("[start][Modbus TCP Slave 下行消息订阅器已启动, topic={}]", getTopic());
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止订阅
|
||||
*/
|
||||
public void stop() {
|
||||
messageBus.unregister(this);
|
||||
log.info("[stop][Modbus TCP Slave 下行消息订阅器已停止]");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTopic() {
|
||||
return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGroup() {
|
||||
return getTopic(); // 点对点消费
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(IotDeviceMessage message) {
|
||||
log.debug("[onMessage][收到下行消息: {}]", message);
|
||||
try {
|
||||
downstreamHandler.handle(message);
|
||||
} catch (Exception e) {
|
||||
log.error("[onMessage][处理下行消息失败]", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.handler.upstream;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusModeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.IotModbusDataConverter;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrame;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrameCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusResponseParser;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlaveConfigCacheService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlaveConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlaveConnectionManager.ConnectionInfo;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlavePendingRequestManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlavePendingRequestManager.PendingRequest;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.core.net.NetSocket;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
// TODO @AI:逻辑有点多,看看是不是分区域!
|
||||
/**
|
||||
* IoT Modbus TCP Slave 上行数据处理器
|
||||
* <p>
|
||||
* 处理:
|
||||
* 1. 自定义 FC 认证
|
||||
* 2. 轮询响应(mode=1)→ 点位翻译 → thing.property.post
|
||||
* 3. 主动上报(mode=2)→ 透传 property.report TODO @AI:这种模式,应该不用支持;因为主动上报,都走标准的 tcp 即可;
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusTcpSlaveUpstreamHandler {
|
||||
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
private final IotDeviceMessageService messageService;
|
||||
private final IotModbusDataConverter dataConverter;
|
||||
private final IotModbusFrameCodec frameCodec;
|
||||
private final IotModbusTcpSlaveConnectionManager connectionManager;
|
||||
private final IotModbusTcpSlaveConfigCacheService configCacheService;
|
||||
private final IotModbusTcpSlavePendingRequestManager pendingRequestManager;
|
||||
private final String serverId;
|
||||
|
||||
/**
|
||||
* 认证成功回调:(deviceId, config) → 启动轮询等
|
||||
*/
|
||||
@Setter
|
||||
private BiConsumer<Long, IotModbusDeviceConfigRespDTO> onAuthSuccess;
|
||||
|
||||
public IotModbusTcpSlaveUpstreamHandler(IotDeviceCommonApi deviceApi,
|
||||
IotDeviceMessageService messageService,
|
||||
IotModbusDataConverter dataConverter,
|
||||
IotModbusFrameCodec frameCodec,
|
||||
IotModbusTcpSlaveConnectionManager connectionManager,
|
||||
IotModbusTcpSlaveConfigCacheService configCacheService,
|
||||
IotModbusTcpSlavePendingRequestManager pendingRequestManager,
|
||||
String serverId) {
|
||||
this.deviceApi = deviceApi;
|
||||
this.messageService = messageService;
|
||||
this.dataConverter = dataConverter;
|
||||
this.frameCodec = frameCodec;
|
||||
this.connectionManager = connectionManager;
|
||||
this.configCacheService = configCacheService;
|
||||
this.pendingRequestManager = pendingRequestManager;
|
||||
this.serverId = serverId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理帧
|
||||
*/
|
||||
public void handleFrame(NetSocket socket, IotModbusFrame frame, IotModbusFrameFormatEnum frameFormat) {
|
||||
if (frame == null) {
|
||||
return;
|
||||
}
|
||||
// 1.1 自定义功能码(认证等扩展)
|
||||
if (StrUtil.isNotEmpty(frame.getCustomData())) {
|
||||
handleCustomFrame(socket, frame, frameFormat);
|
||||
return;
|
||||
}
|
||||
// 1.2 异常响应
|
||||
if (frame.isException()) {
|
||||
// TODO @AI:这种需要返回一个结果给 modbus client?
|
||||
log.warn("[handleFrame][设备异常响应, slaveId={}, FC={}, exceptionCode={}]",
|
||||
frame.getSlaveId(), frame.getFunctionCode(), frame.getExceptionCode());
|
||||
return;
|
||||
}
|
||||
// 1.3 未认证连接,丢弃
|
||||
if (!connectionManager.isAuthenticated(socket)) {
|
||||
// TODO @AI:这种需要返回一个结果给 modbus client?
|
||||
log.warn("[handleFrame][未认证连接, 丢弃数据, remoteAddress={}]", socket.remoteAddress());
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO @AI:获取不到,看看要不要也打个告警;然后
|
||||
// 2. 标准 Modbus 响应
|
||||
ConnectionInfo info = connectionManager.getConnectionInfo(socket);
|
||||
if (info == null) {
|
||||
return;
|
||||
}
|
||||
// TODO @AI:可以断言下,必须是云端轮询;
|
||||
if (info.getMode() != null && info.getMode().equals(IotModbusModeEnum.ACTIVE_REPORT.getMode())) {
|
||||
// mode=2:主动上报,透传
|
||||
handleActiveReport(info, frame);
|
||||
} else {
|
||||
// mode=1:云端轮询,匹配 PendingRequest
|
||||
handlePollingResponse(info, frame, frameFormat);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自定义功能码帧
|
||||
*/
|
||||
private void handleCustomFrame(NetSocket socket, IotModbusFrame frame, IotModbusFrameFormatEnum frameFormat) {
|
||||
try {
|
||||
// TODO @AI:直接使用 JsonUtils 去解析出 IotDeviceMessage
|
||||
JSONObject json = JSONUtil.parseObj(frame.getCustomData());
|
||||
String method = json.getStr("method");
|
||||
// TODO @AI: method 枚举下;
|
||||
if ("auth".equals(method)) {
|
||||
handleAuth(socket, frame, json, frameFormat);
|
||||
return;
|
||||
}
|
||||
// TODO @AI:把 frame 都打印下;
|
||||
log.warn("[handleCustomFrame][未知 method: {}]", method);
|
||||
} catch (Exception e) {
|
||||
// TODO @AI:各种情况的翻译;看看怎么弄比较合适;是不是要用 fc 自定义的 callback 下?
|
||||
log.error("[handleCustomFrame][解析自定义 FC 数据失败]", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理认证请求
|
||||
*/
|
||||
private void handleAuth(NetSocket socket, IotModbusFrame frame, JSONObject json,
|
||||
IotModbusFrameFormatEnum frameFormat) {
|
||||
// TODO @AI:参数为空的校验;
|
||||
JSONObject params = json.getJSONObject("params");
|
||||
if (params == null) {
|
||||
sendAuthResponse(socket, frame, frameFormat, 1, "params 为空");
|
||||
return;
|
||||
}
|
||||
// TODO @AI:参数判空;
|
||||
String clientId = params.getStr("clientId");
|
||||
String username = params.getStr("username");
|
||||
String password = params.getStr("password");
|
||||
|
||||
try {
|
||||
// 1. 调用认证 API
|
||||
IotDeviceAuthReqDTO authReq = new IotDeviceAuthReqDTO()
|
||||
.setClientId(clientId).setUsername(username).setPassword(password);
|
||||
CommonResult<Boolean> authResult = deviceApi.authDevice(authReq);
|
||||
// TODO @AI:应该不用 close 吧?!
|
||||
// TODO @AI:BooleanUtils.isFalse
|
||||
if (authResult == null || !authResult.isSuccess() || !Boolean.TRUE.equals(authResult.getData())) {
|
||||
log.warn("[handleAuth][认证失败, clientId={}, username={}]", clientId, username);
|
||||
sendAuthResponse(socket, frame, frameFormat, 1, "认证失败");
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 认证成功,查找设备配置(通过 username 作为 deviceName 查找)
|
||||
// TODO 根据实际的认证模型优化查找逻辑
|
||||
// TODO @AI:通过 device
|
||||
IotModbusDeviceConfigRespDTO config = configCacheService.findConfigByAuth(clientId, username, password);
|
||||
if (config == null) {
|
||||
// 退而求其次,遍历缓存查找
|
||||
log.info("[handleAuth][认证成功但未找到设备配置, clientId={}, username={}]", clientId, username);
|
||||
}
|
||||
// 2.2 解析设备信息
|
||||
IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(username);
|
||||
Assert.notNull(deviceInfo, "解析设备信息失败");
|
||||
// 2.3 获取设备信息
|
||||
// TODO @AI:这里要优化下,不要通过 spring 这样注入;
|
||||
IotDeviceService deviceService = SpringUtil.getBean(IotDeviceService.class);
|
||||
IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), deviceInfo.getDeviceName());
|
||||
Assert.notNull(device, "设备不存在");
|
||||
// TODO @AI:校验 frameFormat 是否一致;不一致,连接也失败;
|
||||
|
||||
// 3. 注册连接
|
||||
ConnectionInfo connectionInfo = new ConnectionInfo()
|
||||
.setDeviceId(device.getId())
|
||||
.setSlaveId(frame.getSlaveId())
|
||||
.setFrameFormat(frameFormat)
|
||||
.setMode(config != null ? config.getMode() : IotModbusModeEnum.POLLING.getMode());
|
||||
|
||||
if (config != null) {
|
||||
connectionInfo.setDeviceId(config.getDeviceId())
|
||||
.setProductKey(config.getProductKey())
|
||||
.setDeviceName(config.getDeviceName());
|
||||
}
|
||||
connectionManager.registerConnection(socket, connectionInfo);
|
||||
|
||||
// 4. 发送认证成功响应
|
||||
sendAuthResponse(socket, frame, frameFormat, 0, "success");
|
||||
log.info("[handleAuth][认证成功, clientId={}, deviceId={}]", clientId,
|
||||
config != null ? config.getDeviceId() : "unknown");
|
||||
|
||||
// 5. 回调:启动轮询等
|
||||
// TODO @AI:是不是不要 callback,而是主动调用!
|
||||
if (onAuthSuccess != null && config != null) {
|
||||
onAuthSuccess.accept(config.getDeviceId(), config);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[handleAuth][认证异常]", e);
|
||||
sendAuthResponse(socket, frame, frameFormat, 1, "认证异常");
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送认证响应
|
||||
*/
|
||||
private void sendAuthResponse(NetSocket socket, IotModbusFrame frame,
|
||||
IotModbusFrameFormatEnum frameFormat,
|
||||
int code, String message) {
|
||||
// TODO @AI:不一定用 auth response;而是 custom?
|
||||
JSONObject resp = new JSONObject();
|
||||
resp.set("method", "auth");
|
||||
resp.set("code", code);
|
||||
resp.set("message", message);
|
||||
byte[] data = frameCodec.encodeCustomFrame(frame.getSlaveId(), resp.toString(),
|
||||
frameFormat, frame.getTransactionId() != null ? frame.getTransactionId() : 0);
|
||||
connectionManager.sendToSocket(socket, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理轮询响应(mode=1)
|
||||
*/
|
||||
private void handlePollingResponse(ConnectionInfo info, IotModbusFrame frame,
|
||||
IotModbusFrameFormatEnum frameFormat) {
|
||||
// 1.1 匹配 PendingRequest
|
||||
PendingRequest request = pendingRequestManager.matchResponse(
|
||||
info.getDeviceId(), frame, frameFormat);
|
||||
if (request == null) {
|
||||
log.debug("[handlePollingResponse][未匹配到 PendingRequest, deviceId={}, FC={}]",
|
||||
info.getDeviceId(), frame.getFunctionCode());
|
||||
return;
|
||||
}
|
||||
// 1.2 提取寄存器值
|
||||
int[] rawValues = IotModbusResponseParser.extractValues(frame);
|
||||
if (rawValues == null) {
|
||||
log.warn("[handlePollingResponse][提取寄存器值失败, deviceId={}, identifier={}]",
|
||||
info.getDeviceId(), request.getIdentifier());
|
||||
return;
|
||||
}
|
||||
// 1.3 查找点位配置
|
||||
IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(info.getDeviceId());
|
||||
if (config == null || config.getPoints() == null) {
|
||||
return;
|
||||
}
|
||||
// TODO @AI:findone arrayUtil;
|
||||
var point = config.getPoints().stream()
|
||||
.filter(p -> p.getId().equals(request.getPointId()))
|
||||
.findFirst().orElse(null);
|
||||
if (point == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO @AI:拆成 2.1、2.2
|
||||
// 4. 点位翻译 → 上报
|
||||
Object convertedValue = dataConverter.convertToPropertyValue(rawValues, point);
|
||||
Map<String, Object> params = MapUtil.of(request.getIdentifier(), convertedValue);
|
||||
IotDeviceMessage message = IotDeviceMessage.requestOf(
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), params);
|
||||
messageService.sendDeviceMessage(message, info.getProductKey(), info.getDeviceName(), serverId);
|
||||
log.debug("[handlePollingResponse][设备={}, 属性={}, 原始值={}, 转换值={}]",
|
||||
info.getDeviceId(), request.getIdentifier(), rawValues, convertedValue);
|
||||
}
|
||||
|
||||
// TODO @AI:不需要这个逻辑;
|
||||
/**
|
||||
* 处理主动上报(mode=2)
|
||||
* 设备直接上报 property.report 格式:{propertyId: value},不做点位翻译
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void handleActiveReport(ConnectionInfo info, IotModbusFrame frame) {
|
||||
// mode=2 下设备上报标准 Modbus 帧,但由于没有点位翻译,
|
||||
// 这里暂时将原始寄存器值以 FC+地址 为 key 上报
|
||||
int[] rawValues = IotModbusResponseParser.extractValues(frame);
|
||||
if (rawValues == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 简单上报:以 "register_FC{fc}" 作为属性名
|
||||
String propertyKey = "register_FC" + frame.getFunctionCode();
|
||||
Map<String, Object> params = MapUtil.of(propertyKey, rawValues);
|
||||
IotDeviceMessage message = IotDeviceMessage.requestOf(
|
||||
IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), params);
|
||||
messageService.sendDeviceMessage(message, info.getProductKey(), info.getDeviceName(), serverId);
|
||||
|
||||
log.debug("[handleActiveReport][设备={}, FC={}, 原始值={}]",
|
||||
info.getDeviceId(), frame.getFunctionCode(), rawValues);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
|
||||
|
||||
// TODO @AI:和 IotModbusTcpConfigCacheService 基本一致?!
|
||||
/**
|
||||
* IoT Modbus TCP Slave 配置缓存服务
|
||||
* <p>
|
||||
* 负责:从 biz 拉取 Modbus 设备配置,缓存配置数据,并检测配置变更
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IotModbusTcpSlaveConfigCacheService {
|
||||
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
|
||||
/**
|
||||
* 配置缓存:deviceId -> 配置
|
||||
*/
|
||||
private final Map<Long, IotModbusDeviceConfigRespDTO> configCache = new ConcurrentHashMap<>();
|
||||
|
||||
// TODO @AI:它的 diff 算法,是不是不用和 IotModbusTcpConfigCacheService 完全一致;更多是1)首次连接时,查找;2)断开连接,移除;3)定时轮询更新;
|
||||
/**
|
||||
* 已知的设备 ID 集合
|
||||
*/
|
||||
private final Set<Long> knownDeviceIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
/**
|
||||
* 刷新配置
|
||||
*
|
||||
* @return 最新的配置列表
|
||||
*/
|
||||
public List<IotModbusDeviceConfigRespDTO> refreshConfig() {
|
||||
try {
|
||||
// 1. 从远程获取配置
|
||||
// TODO @AI:需要过滤下,只查找连接的设备列表;并且只有主动轮询的,才会处理;方法名,应该是 List 结尾;
|
||||
CommonResult<List<IotModbusDeviceConfigRespDTO>> result = deviceApi.getEnabledModbusDeviceConfigs();
|
||||
if (result == null || !result.isSuccess() || result.getData() == null) {
|
||||
log.warn("[refreshConfig][获取 Modbus 配置失败: {}]", result);
|
||||
return new ArrayList<>(configCache.values());
|
||||
}
|
||||
List<IotModbusDeviceConfigRespDTO> configs = new ArrayList<>(result.getData());
|
||||
|
||||
// 2. 追加 Mock 测试数据(一次性测试用途)
|
||||
// TODO @芋艿:测试完成后移除
|
||||
configs.addAll(buildMockConfigs());
|
||||
|
||||
// 3. 更新缓存
|
||||
for (IotModbusDeviceConfigRespDTO config : configs) {
|
||||
configCache.put(config.getDeviceId(), config);
|
||||
}
|
||||
return configs;
|
||||
} catch (Exception e) {
|
||||
log.error("[refreshConfig][刷新配置失败]", e);
|
||||
return new ArrayList<>(configCache.values());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Mock 测试配置数据(一次性测试用途)
|
||||
*
|
||||
* 设备:PRODUCT_KEY=4aymZgOTOOCrDKRT, DEVICE_NAME=small
|
||||
* 点位:temperature(FC03, 地址 0)、humidity(FC03, 地址 1)
|
||||
*
|
||||
* TODO @芋艿:测试完成后移除
|
||||
*/
|
||||
private List<IotModbusDeviceConfigRespDTO> buildMockConfigs() {
|
||||
IotModbusDeviceConfigRespDTO config = new IotModbusDeviceConfigRespDTO();
|
||||
config.setDeviceId(1L);
|
||||
config.setProductKey("4aymZgOTOOCrDKRT");
|
||||
config.setDeviceName("small");
|
||||
config.setSlaveId(1);
|
||||
config.setMode(1); // 云端轮询
|
||||
config.setFrameFormat("modbus_tcp");
|
||||
|
||||
// 点位列表
|
||||
List<IotModbusPointRespDTO> points = new ArrayList<>();
|
||||
|
||||
// 点位 1:温度 - 保持寄存器 FC03, 地址 0, 1 个寄存器, INT16
|
||||
IotModbusPointRespDTO point1 = new IotModbusPointRespDTO();
|
||||
point1.setId(1L);
|
||||
point1.setIdentifier("temperature");
|
||||
point1.setName("温度");
|
||||
point1.setFunctionCode(3); // FC03 读保持寄存器
|
||||
point1.setRegisterAddress(0);
|
||||
point1.setRegisterCount(1);
|
||||
point1.setRawDataType("INT16");
|
||||
point1.setByteOrder("BIG_ENDIAN");
|
||||
point1.setScale(new BigDecimal("0.1"));
|
||||
point1.setPollInterval(5000); // 5 秒轮询一次
|
||||
points.add(point1);
|
||||
|
||||
// 点位 2:湿度 - 保持寄存器 FC03, 地址 1, 1 个寄存器, UINT16
|
||||
IotModbusPointRespDTO point2 = new IotModbusPointRespDTO();
|
||||
point2.setId(2L);
|
||||
point2.setIdentifier("humidity");
|
||||
point2.setName("湿度");
|
||||
point2.setFunctionCode(3); // FC03 读保持寄存器
|
||||
point2.setRegisterAddress(1);
|
||||
point2.setRegisterCount(1);
|
||||
point2.setRawDataType("UINT16");
|
||||
point2.setByteOrder("BIG_ENDIAN");
|
||||
point2.setScale(new BigDecimal("0.1"));
|
||||
point2.setPollInterval(5000); // 5 秒轮询一次
|
||||
points.add(point2);
|
||||
|
||||
config.setPoints(points);
|
||||
log.info("[buildMockConfigs][已加载 Mock 配置, deviceId={}, points={}]", config.getDeviceId(), points.size());
|
||||
return Collections.singletonList(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备配置
|
||||
*/
|
||||
public IotModbusDeviceConfigRespDTO getConfig(Long deviceId) {
|
||||
return configCache.get(deviceId);
|
||||
}
|
||||
|
||||
// TODO @AI:这个逻辑,是不是非必须?
|
||||
/**
|
||||
* 通过 clientId + username + password 查找设备配置(认证用)
|
||||
* 暂通过遍历缓存实现,后续可优化为索引
|
||||
*/
|
||||
public IotModbusDeviceConfigRespDTO findConfigByAuth(String clientId, String username, String password) {
|
||||
// TODO @芋艿:测试完成后移除 mock 逻辑,改为正式查找
|
||||
// Mock:通过 clientId(格式 productKey.deviceName)匹配缓存中的设备
|
||||
if (clientId != null && clientId.contains(".")) {
|
||||
String[] parts = clientId.split("\\.", 2);
|
||||
String productKey = parts[0];
|
||||
String deviceName = parts[1];
|
||||
for (IotModbusDeviceConfigRespDTO config : configCache.values()) {
|
||||
if (productKey.equals(config.getProductKey()) && deviceName.equals(config.getDeviceName())) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理已删除设备的资源
|
||||
*/
|
||||
public void cleanupRemovedDevices(List<IotModbusDeviceConfigRespDTO> currentConfigs, Consumer<Long> cleanupAction) {
|
||||
Set<Long> currentDeviceIds = convertSet(currentConfigs, IotModbusDeviceConfigRespDTO::getDeviceId);
|
||||
Set<Long> removedDeviceIds = new HashSet<>(knownDeviceIds);
|
||||
removedDeviceIds.removeAll(currentDeviceIds);
|
||||
|
||||
for (Long deviceId : removedDeviceIds) {
|
||||
log.info("[cleanupRemovedDevices][清理已删除设备: {}]", deviceId);
|
||||
configCache.remove(deviceId);
|
||||
cleanupAction.accept(deviceId);
|
||||
}
|
||||
|
||||
knownDeviceIds.clear();
|
||||
knownDeviceIds.addAll(currentDeviceIds);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.net.NetSocket;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP Slave 连接管理器
|
||||
* <p>
|
||||
* 管理设备 TCP 连接:socket ↔ 设备双向映射
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusTcpSlaveConnectionManager {
|
||||
|
||||
/**
|
||||
* socket → 连接信息
|
||||
*/
|
||||
private final Map<NetSocket, ConnectionInfo> connectionMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* deviceId → socket
|
||||
*/
|
||||
private final Map<Long, NetSocket> deviceSocketMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 连接信息
|
||||
*/
|
||||
@Data
|
||||
public static class ConnectionInfo {
|
||||
|
||||
/**
|
||||
* 设备编号
|
||||
*/
|
||||
private Long deviceId;
|
||||
/**
|
||||
* 产品标识
|
||||
*/
|
||||
private String productKey;
|
||||
/**
|
||||
* 设备名称
|
||||
*/
|
||||
private String deviceName;
|
||||
/**
|
||||
* 从站地址
|
||||
*/
|
||||
private Integer slaveId;
|
||||
|
||||
/**
|
||||
* 帧格式(首帧自动检测得到)
|
||||
*/
|
||||
private IotModbusFrameFormatEnum frameFormat;
|
||||
|
||||
// TODO @AI:mode 是否非必须?!
|
||||
/**
|
||||
* 模式:1-云端轮询 2-主动上报
|
||||
*/
|
||||
private Integer mode;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册已认证的连接
|
||||
*/
|
||||
public void registerConnection(NetSocket socket, ConnectionInfo info) {
|
||||
connectionMap.put(socket, info);
|
||||
deviceSocketMap.put(info.getDeviceId(), socket);
|
||||
log.info("[registerConnection][设备 {} 连接已注册, remoteAddress={}]",
|
||||
info.getDeviceId(), socket.remoteAddress());
|
||||
}
|
||||
|
||||
// TODO @芋艿:待定是不是要保留?!
|
||||
/**
|
||||
* 设置连接的帧格式(首帧检测后调用)
|
||||
*/
|
||||
public void setFrameFormat(NetSocket socket, IotModbusFrameFormatEnum frameFormat) {
|
||||
ConnectionInfo info = connectionMap.get(socket);
|
||||
if (info != null) {
|
||||
info.setFrameFormat(frameFormat);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接信息
|
||||
*/
|
||||
public ConnectionInfo getConnectionInfo(NetSocket socket) {
|
||||
return connectionMap.get(socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据设备 ID 获取连接信息
|
||||
*/
|
||||
public ConnectionInfo getConnectionInfoByDeviceId(Long deviceId) {
|
||||
NetSocket socket = deviceSocketMap.get(deviceId);
|
||||
return socket != null ? connectionMap.get(socket) : null;
|
||||
}
|
||||
|
||||
// TODO @AI:不用判断连接是否认证;
|
||||
/**
|
||||
* 判断连接是否已认证
|
||||
*/
|
||||
public boolean isAuthenticated(NetSocket socket) {
|
||||
return connectionMap.containsKey(socket);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除连接
|
||||
*/
|
||||
public ConnectionInfo removeConnection(NetSocket socket) {
|
||||
ConnectionInfo info = connectionMap.remove(socket);
|
||||
if (info != null && info.getDeviceId() != null) {
|
||||
deviceSocketMap.remove(info.getDeviceId());
|
||||
log.info("[removeConnection][设备 {} 连接已移除]", info.getDeviceId());
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送数据到设备
|
||||
*/
|
||||
public void sendToDevice(Long deviceId, byte[] data) {
|
||||
NetSocket socket = deviceSocketMap.get(deviceId);
|
||||
if (socket == null) {
|
||||
log.warn("[sendToDevice][设备 {} 没有连接]", deviceId);
|
||||
return;
|
||||
}
|
||||
// TODO @AI:直接复用 sendToSocket 方法?!
|
||||
socket.write(Buffer.buffer(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送数据到指定 socket
|
||||
*/
|
||||
public void sendToSocket(NetSocket socket, byte[] data) {
|
||||
socket.write(Buffer.buffer(data));
|
||||
}
|
||||
|
||||
// TODO @AI:貌似别的都没这个,是不是可以去掉哈?!
|
||||
/**
|
||||
* 关闭所有连接
|
||||
*/
|
||||
public void closeAll() {
|
||||
for (NetSocket socket : connectionMap.keySet()) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (Exception e) {
|
||||
log.error("[closeAll][关闭连接失败]", e);
|
||||
}
|
||||
}
|
||||
connectionMap.clear();
|
||||
deviceSocketMap.clear();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrame;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Deque;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP Slave 待响应请求管理器
|
||||
* <p>
|
||||
* 管理轮询下发的请求,用于匹配设备响应:
|
||||
* - TCP 模式:按 transactionId 精确匹配
|
||||
* - RTU 模式:按 slaveId + functionCode FIFO 匹配
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusTcpSlavePendingRequestManager {
|
||||
|
||||
/**
|
||||
* deviceId → 有序队列
|
||||
*/
|
||||
private final Map<Long, Deque<PendingRequest>> pendingRequests = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 待响应请求信息
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public static class PendingRequest {
|
||||
|
||||
private Long deviceId;
|
||||
private Long pointId;
|
||||
private String identifier;
|
||||
private int slaveId;
|
||||
private int functionCode;
|
||||
private int registerAddress;
|
||||
private int registerCount;
|
||||
private Integer transactionId;
|
||||
private long expireAt;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加待响应请求
|
||||
*/
|
||||
public void addRequest(PendingRequest request) {
|
||||
pendingRequests.computeIfAbsent(request.getDeviceId(), k -> new ConcurrentLinkedDeque<>())
|
||||
.addLast(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 匹配响应(TCP 模式按 transactionId,RTU 模式按 FIFO)
|
||||
*
|
||||
* @param deviceId 设备 ID
|
||||
* @param frame 收到的响应帧
|
||||
* @param frameFormat 帧格式
|
||||
* @return 匹配到的 PendingRequest,没有匹配返回 null
|
||||
*/
|
||||
public PendingRequest matchResponse(Long deviceId, IotModbusFrame frame,
|
||||
IotModbusFrameFormatEnum frameFormat) {
|
||||
Deque<PendingRequest> queue = pendingRequests.get(deviceId);
|
||||
// TODO @AI:CollUtil.isEmpty(queue)
|
||||
if (queue == null || queue.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP && frame.getTransactionId() != null) {
|
||||
// TCP 模式:按 transactionId 精确匹配
|
||||
return matchByTransactionId(queue, frame.getTransactionId());
|
||||
} else {
|
||||
// RTU 模式:FIFO,匹配 slaveId + functionCode
|
||||
return matchByFifo(queue, frame.getSlaveId(), frame.getFunctionCode());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 transactionId 匹配
|
||||
*/
|
||||
private PendingRequest matchByTransactionId(Deque<PendingRequest> queue, int transactionId) {
|
||||
// TODO @AI:需要兼容 jdk8;
|
||||
for (var it = queue.iterator(); it.hasNext(); ) {
|
||||
PendingRequest req = it.next();
|
||||
if (req.getTransactionId() != null && req.getTransactionId() == transactionId) {
|
||||
it.remove();
|
||||
return req;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 FIFO 匹配
|
||||
*/
|
||||
private PendingRequest matchByFifo(Deque<PendingRequest> queue, int slaveId, int functionCode) {
|
||||
// TODO @AI:需要兼容 jdk8;
|
||||
for (var it = queue.iterator(); it.hasNext(); ) {
|
||||
PendingRequest req = it.next();
|
||||
if (req.getSlaveId() == slaveId && req.getFunctionCode() == functionCode) {
|
||||
it.remove();
|
||||
return req;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期请求
|
||||
*/
|
||||
public void cleanupExpired() {
|
||||
long now = System.currentTimeMillis();
|
||||
for (Map.Entry<Long, Deque<PendingRequest>> entry : pendingRequests.entrySet()) {
|
||||
Deque<PendingRequest> queue = entry.getValue();
|
||||
int removed = 0;
|
||||
while (!queue.isEmpty()) {
|
||||
PendingRequest req = queue.peekFirst();
|
||||
// TODO @AI:if return 减少括号层级;
|
||||
if (req != null && req.getExpireAt() < now) {
|
||||
queue.pollFirst();
|
||||
removed++;
|
||||
} else {
|
||||
break; // 队列有序,后面的没过期
|
||||
}
|
||||
}
|
||||
if (removed > 0) {
|
||||
log.debug("[cleanupExpired][设备 {} 清理了 {} 个过期请求]", entry.getKey(), removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理指定设备的所有待响应请求
|
||||
*/
|
||||
public void removeDevice(Long deviceId) {
|
||||
pendingRequests.remove(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有待响应请求
|
||||
*/
|
||||
public void clear() {
|
||||
pendingRequests.clear();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrameCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlaveConnectionManager.ConnectionInfo;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.manager.IotModbusTcpSlavePendingRequestManager.PendingRequest;
|
||||
import io.vertx.core.Vertx;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
|
||||
|
||||
// TODO @AI:和 IotModbusTcpPollScheduler 很像,是不是可以做一些复用?
|
||||
/**
|
||||
* IoT Modbus TCP Slave 轮询调度器
|
||||
* <p>
|
||||
* 管理点位的轮询定时器,为 mode=1(云端轮询)的设备调度读取任务。
|
||||
* 与 tcpmaster 不同,这里不直接通过 j2mod 读取,而是:
|
||||
* 1. 编码 Modbus 读请求帧
|
||||
* 2. 通过 ConnectionManager 发送到设备的 TCP 连接
|
||||
* 3. 将请求注册到 PendingRequestManager,等待设备响应
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IotModbusTcpSlavePollScheduler {
|
||||
|
||||
private final Vertx vertx;
|
||||
private final IotModbusTcpSlaveConnectionManager connectionManager;
|
||||
private final IotModbusFrameCodec frameCodec;
|
||||
private final IotModbusTcpSlavePendingRequestManager pendingRequestManager;
|
||||
private final int requestTimeout;
|
||||
|
||||
/**
|
||||
* 设备点位的定时器映射:deviceId -> (pointId -> PointTimerInfo)
|
||||
*/
|
||||
private final Map<Long, Map<Long, PointTimerInfo>> devicePointTimers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* TCP 事务 ID 自增器
|
||||
*/
|
||||
private final AtomicInteger transactionIdCounter = new AtomicInteger(0);
|
||||
|
||||
/**
|
||||
* 点位定时器信息
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
private static class PointTimerInfo {
|
||||
|
||||
/**
|
||||
* Vert.x 定时器 ID
|
||||
*/
|
||||
private Long timerId;
|
||||
/**
|
||||
* 轮询间隔(用于判断是否需要更新定时器)
|
||||
*/
|
||||
private Integer pollInterval;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新轮询任务(增量更新)
|
||||
*/
|
||||
public void updatePolling(IotModbusDeviceConfigRespDTO config) {
|
||||
Long deviceId = config.getDeviceId();
|
||||
List<IotModbusPointRespDTO> newPoints = config.getPoints();
|
||||
Map<Long, PointTimerInfo> currentTimers = devicePointTimers
|
||||
.computeIfAbsent(deviceId, k -> new ConcurrentHashMap<>());
|
||||
// 1.1 计算新配置中的点位 ID 集合
|
||||
Set<Long> newPointIds = convertSet(newPoints, IotModbusPointRespDTO::getId);
|
||||
// 1.2 计算删除的点位 ID 集合
|
||||
Set<Long> removedPointIds = new HashSet<>(currentTimers.keySet());
|
||||
removedPointIds.removeAll(newPointIds);
|
||||
|
||||
// 2. 处理删除的点位:停止不再存在的定时器
|
||||
for (Long pointId : removedPointIds) {
|
||||
PointTimerInfo timerInfo = currentTimers.remove(pointId);
|
||||
if (timerInfo != null) {
|
||||
vertx.cancelTimer(timerInfo.getTimerId());
|
||||
log.debug("[updatePolling][设备 {} 点位 {} 定时器已删除]", deviceId, pointId);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 处理新增和修改的点位
|
||||
if (CollUtil.isEmpty(newPoints)) {
|
||||
return;
|
||||
}
|
||||
for (IotModbusPointRespDTO point : newPoints) {
|
||||
Long pointId = point.getId();
|
||||
Integer newPollInterval = point.getPollInterval();
|
||||
PointTimerInfo existingTimer = currentTimers.get(pointId);
|
||||
// 3.1 新增点位:创建定时器
|
||||
if (existingTimer == null) {
|
||||
Long timerId = createPollTimer(config, point);
|
||||
if (timerId != null) {
|
||||
currentTimers.put(pointId, new PointTimerInfo(timerId, newPollInterval));
|
||||
log.debug("[updatePolling][设备 {} 点位 {} 定时器已创建, interval={}ms]",
|
||||
deviceId, pointId, newPollInterval);
|
||||
}
|
||||
} else if (!Objects.equals(existingTimer.getPollInterval(), newPollInterval)) {
|
||||
// 3.2 pollInterval 变化:重建定时器
|
||||
vertx.cancelTimer(existingTimer.getTimerId());
|
||||
Long timerId = createPollTimer(config, point);
|
||||
if (timerId != null) {
|
||||
currentTimers.put(pointId, new PointTimerInfo(timerId, newPollInterval));
|
||||
log.debug("[updatePolling][设备 {} 点位 {} 定时器已更新, interval={}ms -> {}ms]",
|
||||
deviceId, pointId, existingTimer.getPollInterval(), newPollInterval);
|
||||
} else {
|
||||
currentTimers.remove(pointId);
|
||||
}
|
||||
}
|
||||
// 3.3 其他属性变化:不处理(下次轮询时自动使用新配置)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建轮询定时器
|
||||
*/
|
||||
private Long createPollTimer(IotModbusDeviceConfigRespDTO config, IotModbusPointRespDTO point) {
|
||||
if (point.getPollInterval() == null || point.getPollInterval() <= 0) {
|
||||
return null;
|
||||
}
|
||||
return vertx.setPeriodic(point.getPollInterval(), timerId -> {
|
||||
try {
|
||||
pollPoint(config, point);
|
||||
} catch (Exception e) {
|
||||
log.error("[createPollTimer][轮询点位失败, deviceId={}, identifier={}]",
|
||||
config.getDeviceId(), point.getIdentifier(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询单个点位:编码读请求帧 → 发送 → 注册 PendingRequest
|
||||
*/
|
||||
private void pollPoint(IotModbusDeviceConfigRespDTO config, IotModbusPointRespDTO point) {
|
||||
Long deviceId = config.getDeviceId();
|
||||
// 1. 获取连接信息
|
||||
ConnectionInfo connInfo = connectionManager.getConnectionInfoByDeviceId(deviceId);
|
||||
if (connInfo == null) {
|
||||
log.debug("[pollPoint][设备 {} 没有连接,跳过轮询]", deviceId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.1 确定帧格式和事务 ID
|
||||
// TODO @AI:不允许为空!
|
||||
IotModbusFrameFormatEnum frameFormat = connInfo.getFrameFormat();
|
||||
if (frameFormat == null) {
|
||||
frameFormat = IotModbusFrameFormatEnum.MODBUS_TCP;
|
||||
}
|
||||
// TODO @AI:transactionId 需要根据设备来么?然后递增也根据 IotModbusFrameFormatEnum.MODBUS_TCP 提前判断;
|
||||
int transactionId = transactionIdCounter.incrementAndGet() & 0xFFFF;
|
||||
int slaveId = connInfo.getSlaveId() != null ? connInfo.getSlaveId() : 1;
|
||||
// 2.2 编码读请求
|
||||
byte[] data = frameCodec.encodeReadRequest(slaveId, point.getFunctionCode(),
|
||||
point.getRegisterAddress(), point.getRegisterCount(), frameFormat, transactionId);
|
||||
// 2.3 注册 PendingRequest
|
||||
PendingRequest pendingRequest = new PendingRequest(
|
||||
deviceId, point.getId(), point.getIdentifier(),
|
||||
slaveId, point.getFunctionCode(),
|
||||
point.getRegisterAddress(), point.getRegisterCount(),
|
||||
frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP ? transactionId : null,
|
||||
System.currentTimeMillis() + requestTimeout);
|
||||
pendingRequestManager.addRequest(pendingRequest);
|
||||
|
||||
// 3. 发送读请求
|
||||
connectionManager.sendToDevice(deviceId, data);
|
||||
log.debug("[pollPoint][设备={}, 点位={}, FC={}, 地址={}, 数量={}]",
|
||||
deviceId, point.getIdentifier(), point.getFunctionCode(),
|
||||
point.getRegisterAddress(), point.getRegisterCount());
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止设备的轮询
|
||||
*/
|
||||
public void stopPolling(Long deviceId) {
|
||||
Map<Long, PointTimerInfo> timers = devicePointTimers.remove(deviceId);
|
||||
if (CollUtil.isEmpty(timers)) {
|
||||
return;
|
||||
}
|
||||
for (PointTimerInfo timerInfo : timers.values()) {
|
||||
vertx.cancelTimer(timerInfo.getTimerId());
|
||||
}
|
||||
log.debug("[stopPolling][设备 {} 停止了 {} 个轮询定时器]", deviceId, timers.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有轮询
|
||||
*/
|
||||
public void stopAll() {
|
||||
for (Long deviceId : new ArrayList<>(devicePointTimers.keySet())) {
|
||||
stopPolling(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -164,14 +164,26 @@ yudao:
|
||||
trust-store-path: "classpath:certs/trust.jks" # 信任的 CA 证书库路径
|
||||
trust-store-password: "your-truststore-password" # 信任的 CA 证书库密码
|
||||
# ====================================
|
||||
# 针对引入的 Modbus TCP 组件的配置
|
||||
# 针对引入的 Modbus TCP Master 组件的配置
|
||||
# ====================================
|
||||
- id: modbus-tcp-1
|
||||
enabled: true
|
||||
protocol: modbus_tcp
|
||||
- id: modbus-tcp-master-1
|
||||
enabled: false
|
||||
protocol: modbus_tcp_master
|
||||
port: 502
|
||||
modbus-tcp:
|
||||
modbus-tcp-master:
|
||||
config-refresh-interval: 30 # 配置刷新间隔(秒)
|
||||
# ====================================
|
||||
# 针对引入的 Modbus TCP Slave 组件的配置
|
||||
# ====================================
|
||||
- id: modbus-tcp-slave-1
|
||||
enabled: true
|
||||
protocol: modbus_tcp_slave
|
||||
port: 503
|
||||
modbus-tcp-slave:
|
||||
config-refresh-interval: 30 # 配置刷新间隔(秒)
|
||||
custom-function-code: 65 # 自定义功能码(用于认证等扩展交互)
|
||||
request-timeout: 5000 # Pending Request 超时时间(毫秒)
|
||||
request-cleanup-interval: 10000 # Pending Request 清理间隔(毫秒)
|
||||
|
||||
--- #################### 日志相关配置 ####################
|
||||
|
||||
@@ -193,7 +205,7 @@ logging:
|
||||
cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG
|
||||
cn.iocoder.yudao.module.iot.gateway.protocol.coap: DEBUG
|
||||
cn.iocoder.yudao.module.iot.gateway.protocol.websocket: DEBUG
|
||||
cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp: DEBUG
|
||||
cn.iocoder.yudao.module.iot.gateway.protocol.modbus: DEBUG
|
||||
# 根日志级别
|
||||
root: INFO
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus;
|
||||
|
||||
import com.ghgande.j2mod.modbus.io.ModbusRTUTCPTransport;
|
||||
import com.ghgande.j2mod.modbus.msg.*;
|
||||
import com.ghgande.j2mod.modbus.procimg.*;
|
||||
import com.ghgande.j2mod.modbus.slave.ModbusSlave;
|
||||
import com.ghgande.j2mod.modbus.slave.ModbusSlaveFactory;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
|
||||
/**
|
||||
* Modbus RTU over TCP 完整 Demo
|
||||
*
|
||||
* 架构:Master(主站)启动 TCP Server 监听 → Slave(从站)主动 TCP 连接上来
|
||||
* 通信协议:RTU 帧格式(带 CRC)通过 TCP 传输,而非标准 MBAP 头
|
||||
*
|
||||
* 流程:
|
||||
* 1. Master 启动 TCP ServerSocket 监听端口
|
||||
* 2. Slave(从站模拟器)作为 TCP Client 连接到 Master
|
||||
* 3. Master 通过 accept 得到的 Socket,使用 {@link ModbusRTUTCPTransport} 发送读写请求
|
||||
*
|
||||
* 实现说明:
|
||||
* 因为 j2mod 的 ModbusSlave 只能以 TCP Server 模式运行(监听端口等待 Master 连接),
|
||||
* 不支持"Slave 作为 TCP Client 主动连接 Master"的模式。
|
||||
* 所以这里用一个 TCP 桥接(bridge)来模拟:
|
||||
* - Slave 在本地内部端口启动(RTU over TCP 模式)
|
||||
* - 一个桥接线程同时连接 Master Server 和 Slave 内部端口,做双向数据转发
|
||||
* - Master 视角:看到的是 Slave 主动连上来
|
||||
*
|
||||
* 依赖:j2mod 3.2.1(pom.xml 中已声明)
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Deprecated // 仅技术演示,非是必须的
|
||||
public class ModbusRtuOverTcpDemo {
|
||||
|
||||
/**
|
||||
* Master(主站)TCP Server 监听端口
|
||||
*/
|
||||
private static final int PORT = 5021;
|
||||
/**
|
||||
* Slave 内部端口(仅本地中转用,不对外暴露)
|
||||
*/
|
||||
private static final int SLAVE_INTERNAL_PORT = PORT + 100;
|
||||
/**
|
||||
* Modbus 从站地址
|
||||
*/
|
||||
private static final int SLAVE_ID = 1;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
// ===================== 第一步:Master 启动 TCP Server 监听 =====================
|
||||
ServerSocket serverSocket = new ServerSocket(PORT);
|
||||
System.out.println("===================================================");
|
||||
System.out.println("[Master] TCP Server 已启动,监听端口: " + PORT);
|
||||
System.out.println("[Master] 等待 Slave 连接...");
|
||||
System.out.println("===================================================");
|
||||
|
||||
// ===================== 第二步:后台启动 Slave,它会主动连接 Master =====================
|
||||
ModbusSlave slave = startSlaveInBackground();
|
||||
|
||||
// Master accept Slave 的连接
|
||||
Socket slaveSocket = serverSocket.accept();
|
||||
System.out.println("[Master] Slave 已连接: " + slaveSocket.getRemoteSocketAddress());
|
||||
|
||||
// ===================== 第三步:Master 通过 RTU over TCP 发送读写请求 =====================
|
||||
// 使用 ModbusRTUTCPTransport 包装 Socket(RTU 帧 = SlaveID + 功能码 + 数据 + CRC,无 MBAP 头)
|
||||
ModbusRTUTCPTransport transport = new ModbusRTUTCPTransport(slaveSocket);
|
||||
|
||||
try {
|
||||
System.out.println("[Master] RTU over TCP 通道已建立\n");
|
||||
|
||||
// 1. 读操作演示:4 种功能码
|
||||
demoReadCoils(transport); // 功能码 01:读线圈
|
||||
demoReadDiscreteInputs(transport); // 功能码 02:读离散输入
|
||||
demoReadHoldingRegisters(transport); // 功能码 03:读保持寄存器
|
||||
demoReadInputRegisters(transport); // 功能码 04:读输入寄存器
|
||||
|
||||
// 2. 写操作演示 + 读回验证
|
||||
demoWriteCoil(transport); // 功能码 05:写单个线圈
|
||||
demoWriteRegister(transport); // 功能码 06:写单个保持寄存器
|
||||
|
||||
System.out.println("\n===================================================");
|
||||
System.out.println("所有 RTU over TCP 读写操作执行成功!");
|
||||
System.out.println("===================================================");
|
||||
} finally {
|
||||
// 清理资源
|
||||
transport.close();
|
||||
slaveSocket.close();
|
||||
serverSocket.close();
|
||||
slave.close();
|
||||
System.out.println("[Master] 资源已关闭");
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Slave 设备模拟(作为 TCP Client 连接 Master) =====================
|
||||
|
||||
/**
|
||||
* 在后台启动从站模拟器,并通过 TCP 桥接连到 Master Server
|
||||
*
|
||||
* @return ModbusSlave 实例(用于最后关闭资源)
|
||||
*/
|
||||
private static ModbusSlave startSlaveInBackground() throws Exception {
|
||||
// 1. 创建进程映像,初始化寄存器数据
|
||||
SimpleProcessImage spi = new SimpleProcessImage(SLAVE_ID);
|
||||
// 1.1 线圈(Coil,功能码 01/05)- 可读写,地址 0~9
|
||||
for (int i = 0; i < 10; i++) {
|
||||
spi.addDigitalOut(new SimpleDigitalOut(i % 2 == 0));
|
||||
}
|
||||
// 1.2 离散输入(Discrete Input,功能码 02)- 只读,地址 0~9
|
||||
for (int i = 0; i < 10; i++) {
|
||||
spi.addDigitalIn(new SimpleDigitalIn(i % 3 == 0));
|
||||
}
|
||||
// 1.3 保持寄存器(Holding Register,功能码 03/06/16)- 可读写,地址 0~19
|
||||
for (int i = 0; i < 20; i++) {
|
||||
spi.addRegister(new SimpleRegister(i * 100));
|
||||
}
|
||||
// 1.4 输入寄存器(Input Register,功能码 04)- 只读,地址 0~19
|
||||
for (int i = 0; i < 20; i++) {
|
||||
spi.addInputRegister(new SimpleInputRegister(i * 10 + 1));
|
||||
}
|
||||
|
||||
// 2. 启动 Slave(RTU over TCP 模式,在本地内部端口监听)
|
||||
ModbusSlave slave = ModbusSlaveFactory.createTCPSlave(SLAVE_INTERNAL_PORT, 5, true);
|
||||
slave.addProcessImage(SLAVE_ID, spi);
|
||||
slave.open();
|
||||
System.out.println("[Slave] 从站模拟器已启动(内部端口: " + SLAVE_INTERNAL_PORT + ")");
|
||||
|
||||
// 3. 启动桥接线程:TCP Client 连接 Master Server,同时连接 Slave 内部端口,双向转发
|
||||
Thread bridgeThread = new Thread(() -> {
|
||||
try {
|
||||
Socket toMaster = new Socket("127.0.0.1", PORT);
|
||||
Socket toSlave = new Socket("127.0.0.1", SLAVE_INTERNAL_PORT);
|
||||
System.out.println("[Bridge] 已建立桥接: Master(" + PORT + ") <-> Slave(" + SLAVE_INTERNAL_PORT + ")");
|
||||
|
||||
// 双向桥接:Master ↔ Bridge ↔ Slave
|
||||
Thread forward = new Thread(() -> bridge(toMaster, toSlave), "bridge-master→slave");
|
||||
Thread backward = new Thread(() -> bridge(toSlave, toMaster), "bridge-slave→master");
|
||||
forward.setDaemon(true);
|
||||
backward.setDaemon(true);
|
||||
forward.start();
|
||||
backward.start();
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}, "bridge-setup");
|
||||
bridgeThread.setDaemon(true);
|
||||
bridgeThread.start();
|
||||
|
||||
return slave;
|
||||
}
|
||||
|
||||
/**
|
||||
* TCP 双向桥接:从 src 读取数据,写入 dst
|
||||
*/
|
||||
private static void bridge(Socket src, Socket dst) {
|
||||
try {
|
||||
byte[] buf = new byte[1024];
|
||||
var in = src.getInputStream();
|
||||
var out = dst.getOutputStream();
|
||||
int len;
|
||||
while ((len = in.read(buf)) != -1) {
|
||||
out.write(buf, 0, len);
|
||||
out.flush();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 连接关闭时正常退出
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Master 读写操作 =====================
|
||||
|
||||
/**
|
||||
* 发送请求并接收响应(通用方法)
|
||||
*/
|
||||
private static ModbusResponse sendRequest(ModbusRTUTCPTransport transport, ModbusRequest request) throws Exception {
|
||||
request.setUnitID(SLAVE_ID);
|
||||
transport.writeRequest(request);
|
||||
return transport.readResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能码 01:读线圈(Read Coils)
|
||||
*/
|
||||
private static void demoReadCoils(ModbusRTUTCPTransport transport) throws Exception {
|
||||
ReadCoilsRequest request = new ReadCoilsRequest(0, 5);
|
||||
ReadCoilsResponse response = (ReadCoilsResponse) sendRequest(transport, request);
|
||||
|
||||
StringBuilder sb = new StringBuilder("[功能码 01] 读线圈(0~4): ");
|
||||
for (int i = 0; i < 5; i++) {
|
||||
sb.append(response.getCoilStatus(i) ? "ON" : "OFF");
|
||||
if (i < 4) {
|
||||
sb.append(", ");
|
||||
}
|
||||
}
|
||||
System.out.println(sb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能码 02:读离散输入(Read Discrete Inputs)
|
||||
*/
|
||||
private static void demoReadDiscreteInputs(ModbusRTUTCPTransport transport) throws Exception {
|
||||
ReadInputDiscretesRequest request = new ReadInputDiscretesRequest(0, 5);
|
||||
ReadInputDiscretesResponse response = (ReadInputDiscretesResponse) sendRequest(transport, request);
|
||||
|
||||
StringBuilder sb = new StringBuilder("[功能码 02] 读离散输入(0~4): ");
|
||||
for (int i = 0; i < 5; i++) {
|
||||
sb.append(response.getDiscreteStatus(i) ? "ON" : "OFF");
|
||||
if (i < 4) {
|
||||
sb.append(", ");
|
||||
}
|
||||
}
|
||||
System.out.println(sb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能码 03:读保持寄存器(Read Holding Registers)
|
||||
*/
|
||||
private static void demoReadHoldingRegisters(ModbusRTUTCPTransport transport) throws Exception {
|
||||
ReadMultipleRegistersRequest request = new ReadMultipleRegistersRequest(0, 5);
|
||||
ReadMultipleRegistersResponse response = (ReadMultipleRegistersResponse) sendRequest(transport, request);
|
||||
|
||||
StringBuilder sb = new StringBuilder("[功能码 03] 读保持寄存器(0~4): ");
|
||||
for (int i = 0; i < response.getWordCount(); i++) {
|
||||
sb.append(response.getRegisterValue(i));
|
||||
if (i < response.getWordCount() - 1) {
|
||||
sb.append(", ");
|
||||
}
|
||||
}
|
||||
System.out.println(sb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能码 04:读输入寄存器(Read Input Registers)
|
||||
*/
|
||||
private static void demoReadInputRegisters(ModbusRTUTCPTransport transport) throws Exception {
|
||||
ReadInputRegistersRequest request = new ReadInputRegistersRequest(0, 5);
|
||||
ReadInputRegistersResponse response = (ReadInputRegistersResponse) sendRequest(transport, request);
|
||||
|
||||
StringBuilder sb = new StringBuilder("[功能码 04] 读输入寄存器(0~4): ");
|
||||
for (int i = 0; i < response.getWordCount(); i++) {
|
||||
sb.append(response.getRegisterValue(i));
|
||||
if (i < response.getWordCount() - 1) {
|
||||
sb.append(", ");
|
||||
}
|
||||
}
|
||||
System.out.println(sb);
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能码 05:写单个线圈(Write Single Coil)+ 读回验证
|
||||
*/
|
||||
private static void demoWriteCoil(ModbusRTUTCPTransport transport) throws Exception {
|
||||
int address = 0;
|
||||
|
||||
// 1. 先读取当前值
|
||||
ReadCoilsRequest readReq = new ReadCoilsRequest(address, 1);
|
||||
ReadCoilsResponse readResp = (ReadCoilsResponse) sendRequest(transport, readReq);
|
||||
boolean beforeValue = readResp.getCoilStatus(0);
|
||||
|
||||
// 2. 写入相反的值
|
||||
boolean writeValue = !beforeValue;
|
||||
WriteCoilRequest writeReq = new WriteCoilRequest(address, writeValue);
|
||||
sendRequest(transport, writeReq);
|
||||
|
||||
// 3. 读回验证
|
||||
ReadCoilsResponse verifyResp = (ReadCoilsResponse) sendRequest(transport, readReq);
|
||||
boolean afterValue = verifyResp.getCoilStatus(0);
|
||||
|
||||
System.out.println("[功能码 05] 写线圈: 地址=" + address
|
||||
+ ", 写入前=" + (beforeValue ? "ON" : "OFF")
|
||||
+ ", 写入值=" + (writeValue ? "ON" : "OFF")
|
||||
+ ", 读回值=" + (afterValue ? "ON" : "OFF")
|
||||
+ (afterValue == writeValue ? " ✓ 验证通过" : " ✗ 验证失败"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 功能码 06:写单个保持寄存器(Write Single Register)+ 读回验证
|
||||
*/
|
||||
private static void demoWriteRegister(ModbusRTUTCPTransport transport) throws Exception {
|
||||
int address = 0;
|
||||
int writeValue = 12345;
|
||||
|
||||
// 1. 先读取当前值
|
||||
ReadMultipleRegistersRequest readReq = new ReadMultipleRegistersRequest(address, 1);
|
||||
ReadMultipleRegistersResponse readResp = (ReadMultipleRegistersResponse) sendRequest(transport, readReq);
|
||||
int beforeValue = readResp.getRegisterValue(0);
|
||||
|
||||
// 2. 写入新值
|
||||
WriteSingleRegisterRequest writeReq = new WriteSingleRegisterRequest(address, new SimpleRegister(writeValue));
|
||||
sendRequest(transport, writeReq);
|
||||
|
||||
// 3. 读回验证
|
||||
ReadMultipleRegistersResponse verifyResp = (ReadMultipleRegistersResponse) sendRequest(transport, readReq);
|
||||
int afterValue = verifyResp.getRegisterValue(0);
|
||||
|
||||
System.out.println("[功能码 06] 写保持寄存器: 地址=" + address
|
||||
+ ", 写入前=" + beforeValue
|
||||
+ ", 写入值=" + writeValue
|
||||
+ ", 读回值=" + afterValue
|
||||
+ (afterValue == writeValue ? " ✓ 验证通过" : " ✗ 验证失败"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbustcp;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster;
|
||||
|
||||
import com.ghgande.j2mod.modbus.procimg.*;
|
||||
import com.ghgande.j2mod.modbus.slave.ModbusSlave;
|
||||
@@ -0,0 +1,309 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrame;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrameCodec;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusRecordParserFactory;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.net.NetClient;
|
||||
import io.vertx.core.net.NetClientOptions;
|
||||
import io.vertx.core.net.NetSocket;
|
||||
import io.vertx.core.parsetools.RecordParser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP Slave 协议集成测试 — MODBUS_RTU 帧格式(手动测试)
|
||||
*
|
||||
* <p>测试场景:设备(TCP Client)连接到网关(TCP Server),使用 MODBUS_RTU(CRC16)帧格式通信
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(需开启 modbus-tcp-slave 协议,默认端口 503)</li>
|
||||
* <li>确保数据库有对应的 Modbus 设备配置(mode=1, frameFormat=modbus_rtu)</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testAuth()} - 自定义功能码认证</li>
|
||||
* <li>{@link #testPollingResponse()} - 轮询响应</li>
|
||||
* <li>{@link #testPropertySetWrite()} - 属性设置(接收写指令)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotModbusTcpSlaveModbusRtuIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 503;
|
||||
private static final int TIMEOUT_MS = 5000;
|
||||
|
||||
private static final int CUSTOM_FC = 65;
|
||||
private static final int SLAVE_ID = 1;
|
||||
|
||||
private static Vertx vertx;
|
||||
private static NetClient netClient;
|
||||
|
||||
// ===================== 编解码器 =====================
|
||||
|
||||
private static final IotModbusFrameCodec FRAME_CODEC = new IotModbusFrameCodec(CUSTOM_FC);
|
||||
|
||||
// ===================== 设备信息(根据实际情况修改,从 iot_device 表查询) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT";
|
||||
private static final String DEVICE_NAME = "small";
|
||||
private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3";
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
vertx = Vertx.vertx();
|
||||
NetClientOptions options = new NetClientOptions()
|
||||
.setConnectTimeout(TIMEOUT_MS)
|
||||
.setIdleTimeout(TIMEOUT_MS);
|
||||
netClient = vertx.createNetClient(options);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
if (netClient != null) {
|
||||
netClient.close();
|
||||
}
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 认证测试:发送自定义功能码 FC65 认证帧(RTU 格式),验证认证成功响应
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
try {
|
||||
// 1. 构造并发送认证帧
|
||||
IotModbusFrame response = authenticate(socket);
|
||||
|
||||
// 2. 验证响应
|
||||
log.info("[testAuth][认证响应帧: slaveId={}, FC={}, customData={}]",
|
||||
response.getSlaveId(), response.getFunctionCode(), response.getCustomData());
|
||||
JSONObject json = JSONUtil.parseObj(response.getCustomData());
|
||||
log.info("[testAuth][认证结果: code={}, message={}]", json.getInt("code"), json.getStr("message"));
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 轮询响应测试 =====================
|
||||
|
||||
/**
|
||||
* 轮询响应测试:认证后等待网关下发 FC03 读请求(RTU 格式),构造读响应帧发回
|
||||
*/
|
||||
@Test
|
||||
public void testPollingResponse() throws Exception {
|
||||
NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
try {
|
||||
// 1. 先认证
|
||||
IotModbusFrame authResponse = authenticate(socket);
|
||||
log.info("[testPollingResponse][认证响应: {}]", authResponse.getCustomData());
|
||||
|
||||
// 2. 等待网关下发读请求
|
||||
log.info("[testPollingResponse][等待网关下发读请求...]");
|
||||
IotModbusFrame readRequest = waitForRequest(socket);
|
||||
log.info("[testPollingResponse][收到读请求: slaveId={}, FC={}]",
|
||||
readRequest.getSlaveId(), readRequest.getFunctionCode());
|
||||
|
||||
// 3. 解析读请求中的起始地址和数量
|
||||
byte[] pdu = readRequest.getPdu();
|
||||
int startAddress = ((pdu[0] & 0xFF) << 8) | (pdu[1] & 0xFF);
|
||||
int quantity = ((pdu[2] & 0xFF) << 8) | (pdu[3] & 0xFF);
|
||||
log.info("[testPollingResponse][读请求参数: startAddress={}, quantity={}]", startAddress, quantity);
|
||||
|
||||
// 4. 构造读响应帧(模拟寄存器数据,RTU 格式)
|
||||
int[] registerValues = new int[quantity];
|
||||
for (int i = 0; i < quantity; i++) {
|
||||
registerValues[i] = 100 + i * 100; // 模拟值: 100, 200, 300, ...
|
||||
}
|
||||
byte[] responseData = buildReadResponse(readRequest.getSlaveId(),
|
||||
readRequest.getFunctionCode(), registerValues);
|
||||
socket.write(Buffer.buffer(responseData));
|
||||
log.info("[testPollingResponse][已发送读响应, registerValues={}]", registerValues);
|
||||
|
||||
// 5. 等待一段时间让网关处理
|
||||
Thread.sleep(20000);
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 属性设置测试 =====================
|
||||
|
||||
/**
|
||||
* 属性设置测试:认证后等待接收网关下发的 FC06/FC16 写请求(RTU 格式)
|
||||
* <p>
|
||||
* 注意:需手动在平台触发 property.set
|
||||
*/
|
||||
@Test
|
||||
public void testPropertySetWrite() throws Exception {
|
||||
NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
try {
|
||||
// 1. 先认证
|
||||
IotModbusFrame authResponse = authenticate(socket);
|
||||
log.info("[testPropertySetWrite][认证响应: {}]", authResponse.getCustomData());
|
||||
|
||||
// 2. 等待网关下发写请求(需手动在平台触发 property.set)
|
||||
log.info("[testPropertySetWrite][等待网关下发写请求(请在平台触发 property.set)...]");
|
||||
IotModbusFrame writeRequest = waitForRequest(socket);
|
||||
log.info("[testPropertySetWrite][收到写请求: slaveId={}, FC={}, pdu={}]",
|
||||
writeRequest.getSlaveId(), writeRequest.getFunctionCode(),
|
||||
bytesToHex(writeRequest.getPdu()));
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 建立 TCP 连接
|
||||
*/
|
||||
private CompletableFuture<NetSocket> connect() {
|
||||
CompletableFuture<NetSocket> future = new CompletableFuture<>();
|
||||
netClient.connect(SERVER_PORT, SERVER_HOST)
|
||||
.onSuccess(future::complete)
|
||||
.onFailure(future::completeExceptionally);
|
||||
return future;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行认证并返回响应帧
|
||||
*/
|
||||
private IotModbusFrame authenticate(NetSocket socket) throws Exception {
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
byte[] authFrame = buildAuthFrame(authInfo.getClientId(), authInfo.getUsername(), authInfo.getPassword());
|
||||
return sendAndReceive(socket, authFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送帧并等待响应(使用 IotModbusRecordParserFactory 自动检测帧格式)
|
||||
*/
|
||||
private IotModbusFrame sendAndReceive(NetSocket socket, byte[] frameData) throws Exception {
|
||||
CompletableFuture<IotModbusFrame> responseFuture = new CompletableFuture<>();
|
||||
// 使用 RecordParserFactory 创建拆包器(自动检测帧格式)
|
||||
RecordParser parser = IotModbusRecordParserFactory.create(CUSTOM_FC,
|
||||
buffer -> {
|
||||
try {
|
||||
// 检测到的帧格式应该是 RTU,使用 RTU 格式解码
|
||||
IotModbusFrame frame = FRAME_CODEC.decodeResponse(
|
||||
buffer.getBytes(), IotModbusFrameFormatEnum.MODBUS_RTU);
|
||||
responseFuture.complete(frame);
|
||||
} catch (Exception e) {
|
||||
responseFuture.completeExceptionally(e);
|
||||
}
|
||||
},
|
||||
format -> log.info("[sendAndReceive][检测到帧格式: {}]", format));
|
||||
socket.handler(parser);
|
||||
|
||||
// 发送请求
|
||||
log.info("[sendAndReceive][发送帧, 长度={}]", frameData.length);
|
||||
socket.write(Buffer.buffer(frameData));
|
||||
|
||||
// 等待响应
|
||||
return responseFuture.get(TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待接收网关下发的请求帧(不发送,只等待接收)
|
||||
*/
|
||||
private IotModbusFrame waitForRequest(NetSocket socket) throws Exception {
|
||||
CompletableFuture<IotModbusFrame> requestFuture = new CompletableFuture<>();
|
||||
// 使用 RecordParserFactory 创建拆包器
|
||||
RecordParser parser = IotModbusRecordParserFactory.create(CUSTOM_FC,
|
||||
buffer -> {
|
||||
try {
|
||||
IotModbusFrame frame = FRAME_CODEC.decodeResponse(
|
||||
buffer.getBytes(), IotModbusFrameFormatEnum.MODBUS_RTU);
|
||||
requestFuture.complete(frame);
|
||||
} catch (Exception e) {
|
||||
requestFuture.completeExceptionally(e);
|
||||
}
|
||||
},
|
||||
format -> log.info("[waitForRequest][检测到帧格式: {}]", format));
|
||||
socket.handler(parser);
|
||||
|
||||
// 等待(超时 30 秒,因为轮询间隔可能比较长)
|
||||
return requestFuture.get(30000, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造认证帧(MODBUS_RTU 格式)
|
||||
* <p>
|
||||
* JSON: {"method":"auth","params":{"clientId":"...","username":"...","password":"..."}}
|
||||
* <p>
|
||||
* RTU 帧格式:[SlaveId(1)] [FC=0x41(1)] [ByteCount(1)] [JSON(N)] [CRC16(2)]
|
||||
*/
|
||||
private byte[] buildAuthFrame(String clientId, String username, String password) {
|
||||
JSONObject params = new JSONObject();
|
||||
params.set("clientId", clientId);
|
||||
params.set("username", username);
|
||||
params.set("password", password);
|
||||
JSONObject json = new JSONObject();
|
||||
json.set("method", "auth");
|
||||
json.set("params", params);
|
||||
return FRAME_CODEC.encodeCustomFrame(SLAVE_ID, json.toString(),
|
||||
IotModbusFrameFormatEnum.MODBUS_RTU, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 FC03/FC01-04 读响应帧(MODBUS_RTU 格式)
|
||||
* <p>
|
||||
* RTU 帧格式:[SlaveId(1)] [FC(1)] [ByteCount(1)] [RegisterData(N*2)] [CRC16(2)]
|
||||
*/
|
||||
private byte[] buildReadResponse(int slaveId, int functionCode, int[] registerValues) {
|
||||
int byteCount = registerValues.length * 2;
|
||||
// 帧长度:SlaveId(1) + FC(1) + ByteCount(1) + Data(N*2) + CRC(2)
|
||||
int totalLength = 1 + 1 + 1 + byteCount + 2;
|
||||
byte[] frame = new byte[totalLength];
|
||||
frame[0] = (byte) slaveId;
|
||||
frame[1] = (byte) functionCode;
|
||||
frame[2] = (byte) byteCount;
|
||||
for (int i = 0; i < registerValues.length; i++) {
|
||||
frame[3 + i * 2] = (byte) ((registerValues[i] >> 8) & 0xFF);
|
||||
frame[3 + i * 2 + 1] = (byte) (registerValues[i] & 0xFF);
|
||||
}
|
||||
// 计算 CRC16
|
||||
int crc = IotModbusFrameCodec.calculateCrc16(frame, totalLength - 2);
|
||||
frame[totalLength - 2] = (byte) (crc & 0xFF); // CRC Low
|
||||
frame[totalLength - 1] = (byte) ((crc >> 8) & 0xFF); // CRC High
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节数组转十六进制字符串
|
||||
*/
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) {
|
||||
return "null";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02X ", b));
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrame;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrameCodec;
|
||||
import io.vertx.core.Handler;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.net.NetClient;
|
||||
import io.vertx.core.net.NetClientOptions;
|
||||
import io.vertx.core.net.NetSocket;
|
||||
import io.vertx.core.parsetools.RecordParser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP Slave 协议集成测试 — MODBUS_TCP 帧格式(手动测试)
|
||||
*
|
||||
* <p>测试场景:设备(TCP Client)连接到网关(TCP Server),使用 MODBUS_TCP(MBAP 头)帧格式通信
|
||||
*
|
||||
* <p>使用步骤:
|
||||
* <ol>
|
||||
* <li>启动 yudao-module-iot-gateway 服务(需开启 modbus-tcp-slave 协议,默认端口 503)</li>
|
||||
* <li>确保数据库有对应的 Modbus 设备配置(mode=1, frameFormat=modbus_tcp)</li>
|
||||
* <li>运行以下测试方法:
|
||||
* <ul>
|
||||
* <li>{@link #testAuth()} - 自定义功能码认证</li>
|
||||
* <li>{@link #testPollingResponse()} - 轮询响应</li>
|
||||
* <li>{@link #testPropertySetWrite()} - 属性设置(接收写指令)</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* </ol>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@Disabled
|
||||
public class IotModbusTcpSlaveModbusTcpIntegrationTest {
|
||||
|
||||
private static final String SERVER_HOST = "127.0.0.1";
|
||||
private static final int SERVER_PORT = 503;
|
||||
private static final int TIMEOUT_MS = 5000;
|
||||
|
||||
private static final int CUSTOM_FC = 65;
|
||||
private static final int SLAVE_ID = 1;
|
||||
|
||||
private static Vertx vertx;
|
||||
private static NetClient netClient;
|
||||
|
||||
// ===================== 编解码器 =====================
|
||||
|
||||
private static final IotModbusFrameCodec FRAME_CODEC = new IotModbusFrameCodec(CUSTOM_FC);
|
||||
|
||||
// ===================== 设备信息(根据实际情况修改,从 iot_device 表查询) =====================
|
||||
|
||||
private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT";
|
||||
private static final String DEVICE_NAME = "small";
|
||||
private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3";
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
vertx = Vertx.vertx();
|
||||
NetClientOptions options = new NetClientOptions()
|
||||
.setConnectTimeout(TIMEOUT_MS)
|
||||
.setIdleTimeout(TIMEOUT_MS);
|
||||
netClient = vertx.createNetClient(options);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
if (netClient != null) {
|
||||
netClient.close();
|
||||
}
|
||||
if (vertx != null) {
|
||||
vertx.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 认证测试 =====================
|
||||
|
||||
/**
|
||||
* 认证测试:发送自定义功能码 FC65 认证帧,验证认证成功响应
|
||||
*/
|
||||
@Test
|
||||
public void testAuth() throws Exception {
|
||||
NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
try {
|
||||
// 1. 构造并发送认证帧
|
||||
IotModbusFrame response = authenticate(socket);
|
||||
|
||||
// 2. 验证响应
|
||||
log.info("[testAuth][认证响应帧: slaveId={}, FC={}, customData={}]",
|
||||
response.getSlaveId(), response.getFunctionCode(), response.getCustomData());
|
||||
JSONObject json = JSONUtil.parseObj(response.getCustomData());
|
||||
log.info("[testAuth][认证结果: code={}, message={}]", json.getInt("code"), json.getStr("message"));
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 轮询响应测试 =====================
|
||||
|
||||
/**
|
||||
* 轮询响应测试:认证后等待网关下发 FC03 读请求,构造读响应帧发回
|
||||
*/
|
||||
@Test
|
||||
public void testPollingResponse() throws Exception {
|
||||
NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
try {
|
||||
// 1. 先认证
|
||||
IotModbusFrame authResponse = authenticate(socket);
|
||||
log.info("[testPollingResponse][认证响应: {}]", authResponse.getCustomData());
|
||||
|
||||
// 2. 等待网关下发读请求
|
||||
log.info("[testPollingResponse][等待网关下发读请求...]");
|
||||
IotModbusFrame readRequest = waitForRequest(socket);
|
||||
log.info("[testPollingResponse][收到读请求: slaveId={}, FC={}, transactionId={}]",
|
||||
readRequest.getSlaveId(), readRequest.getFunctionCode(), readRequest.getTransactionId());
|
||||
|
||||
// 3. 解析读请求中的起始地址和数量
|
||||
byte[] pdu = readRequest.getPdu();
|
||||
int startAddress = ((pdu[0] & 0xFF) << 8) | (pdu[1] & 0xFF);
|
||||
int quantity = ((pdu[2] & 0xFF) << 8) | (pdu[3] & 0xFF);
|
||||
log.info("[testPollingResponse][读请求参数: startAddress={}, quantity={}]", startAddress, quantity);
|
||||
|
||||
// 4. 构造读响应帧(模拟寄存器数据)
|
||||
int[] registerValues = new int[quantity];
|
||||
for (int i = 0; i < quantity; i++) {
|
||||
registerValues[i] = 100 + i * 100; // 模拟值: 100, 200, 300, ...
|
||||
}
|
||||
byte[] responseData = buildReadResponse(readRequest.getTransactionId(),
|
||||
readRequest.getSlaveId(), readRequest.getFunctionCode(), registerValues);
|
||||
socket.write(Buffer.buffer(responseData));
|
||||
log.info("[testPollingResponse][已发送读响应, registerValues={}]", registerValues);
|
||||
|
||||
// 5. 等待一段时间让网关处理
|
||||
Thread.sleep(200000);
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 属性设置测试 =====================
|
||||
|
||||
/**
|
||||
* 属性设置测试:认证后等待接收网关下发的 FC06/FC16 写请求
|
||||
* <p>
|
||||
* 注意:需手动在平台触发 property.set
|
||||
*/
|
||||
@Test
|
||||
public void testPropertySetWrite() throws Exception {
|
||||
NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
try {
|
||||
// 1. 先认证
|
||||
IotModbusFrame authResponse = authenticate(socket);
|
||||
log.info("[testPropertySetWrite][认证响应: {}]", authResponse.getCustomData());
|
||||
|
||||
// 2. 等待网关下发写请求(需手动在平台触发 property.set)
|
||||
log.info("[testPropertySetWrite][等待网关下发写请求(请在平台触发 property.set)...]");
|
||||
IotModbusFrame writeRequest = waitForRequest(socket);
|
||||
log.info("[testPropertySetWrite][收到写请求: slaveId={}, FC={}, transactionId={}, pdu={}]",
|
||||
writeRequest.getSlaveId(), writeRequest.getFunctionCode(),
|
||||
writeRequest.getTransactionId(), bytesToHex(writeRequest.getPdu()));
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== 辅助方法 =====================
|
||||
|
||||
/**
|
||||
* 建立 TCP 连接
|
||||
*/
|
||||
private CompletableFuture<NetSocket> connect() {
|
||||
CompletableFuture<NetSocket> future = new CompletableFuture<>();
|
||||
netClient.connect(SERVER_PORT, SERVER_HOST)
|
||||
.onSuccess(future::complete)
|
||||
.onFailure(future::completeExceptionally);
|
||||
return future;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行认证并返回响应帧
|
||||
*/
|
||||
private IotModbusFrame authenticate(NetSocket socket) throws Exception {
|
||||
IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
|
||||
byte[] authFrame = buildAuthFrame(authInfo.getClientId(), authInfo.getUsername(), authInfo.getPassword());
|
||||
return sendAndReceive(socket, authFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送帧并等待响应(MODBUS_TCP 格式)
|
||||
* <p>
|
||||
* 使用两阶段 RecordParser 拆包:fixedSizeMode(6) 读 MBAP 头 → fixedSizeMode(length) 读 body
|
||||
*/
|
||||
private IotModbusFrame sendAndReceive(NetSocket socket, byte[] frameData) throws Exception {
|
||||
CompletableFuture<IotModbusFrame> responseFuture = new CompletableFuture<>();
|
||||
// 创建 TCP 两阶段拆包 RecordParser
|
||||
RecordParser parser = RecordParser.newFixed(6);
|
||||
parser.handler(new TcpRecordParserHandler(parser, responseFuture));
|
||||
socket.handler(parser);
|
||||
|
||||
// 发送请求
|
||||
log.info("[sendAndReceive][发送帧, 长度={}]", frameData.length);
|
||||
socket.write(Buffer.buffer(frameData));
|
||||
|
||||
// 等待响应
|
||||
return responseFuture.get(TIMEOUT_MS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待接收网关下发的请求帧(不发送,只等待接收)
|
||||
*/
|
||||
private IotModbusFrame waitForRequest(NetSocket socket) throws Exception {
|
||||
CompletableFuture<IotModbusFrame> requestFuture = new CompletableFuture<>();
|
||||
RecordParser parser = RecordParser.newFixed(6);
|
||||
parser.handler(new TcpRecordParserHandler(parser, requestFuture));
|
||||
socket.handler(parser);
|
||||
|
||||
// 等待(超时 30 秒,因为轮询间隔可能比较长)
|
||||
return requestFuture.get(30000, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* MODBUS_TCP 两阶段拆包 Handler
|
||||
*/
|
||||
private class TcpRecordParserHandler implements Handler<Buffer> {
|
||||
|
||||
private final RecordParser parser;
|
||||
private final CompletableFuture<IotModbusFrame> future;
|
||||
private byte[] mbapHeader;
|
||||
private boolean waitingForBody = false;
|
||||
|
||||
TcpRecordParserHandler(RecordParser parser, CompletableFuture<IotModbusFrame> future) {
|
||||
this.parser = parser;
|
||||
this.future = future;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(Buffer buffer) {
|
||||
try {
|
||||
if (waitingForBody) {
|
||||
// Phase 2: 收到 body(unitId + PDU)
|
||||
byte[] body = buffer.getBytes();
|
||||
byte[] fullFrame = new byte[mbapHeader.length + body.length];
|
||||
System.arraycopy(mbapHeader, 0, fullFrame, 0, mbapHeader.length);
|
||||
System.arraycopy(body, 0, fullFrame, mbapHeader.length, body.length);
|
||||
|
||||
IotModbusFrame frame = FRAME_CODEC.decodeResponse(fullFrame, IotModbusFrameFormatEnum.MODBUS_TCP);
|
||||
future.complete(frame);
|
||||
} else {
|
||||
// Phase 1: 收到 MBAP 头 6 字节
|
||||
this.mbapHeader = buffer.getBytes();
|
||||
int length = ((mbapHeader[4] & 0xFF) << 8) | (mbapHeader[5] & 0xFF);
|
||||
this.waitingForBody = true;
|
||||
parser.fixedSizeMode(length);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造认证帧(MODBUS_TCP 格式)
|
||||
* <p>
|
||||
* JSON: {"method":"auth","params":{"clientId":"...","username":"...","password":"..."}}
|
||||
*/
|
||||
private byte[] buildAuthFrame(String clientId, String username, String password) {
|
||||
JSONObject params = new JSONObject();
|
||||
params.set("clientId", clientId);
|
||||
params.set("username", username);
|
||||
params.set("password", password);
|
||||
JSONObject json = new JSONObject();
|
||||
json.set("method", "auth");
|
||||
json.set("params", params);
|
||||
return FRAME_CODEC.encodeCustomFrame(SLAVE_ID, json.toString(),
|
||||
IotModbusFrameFormatEnum.MODBUS_TCP, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 FC03/FC01-04 读响应帧(MODBUS_TCP 格式)
|
||||
* <p>
|
||||
* 格式:[MBAP(6)] [UnitId(1)] [FC(1)] [ByteCount(1)] [RegisterData(N*2)]
|
||||
*/
|
||||
private byte[] buildReadResponse(int transactionId, int slaveId, int functionCode, int[] registerValues) {
|
||||
int byteCount = registerValues.length * 2;
|
||||
// PDU: FC(1) + ByteCount(1) + Data(N*2)
|
||||
int pduLength = 1 + 1 + byteCount;
|
||||
// 完整帧:MBAP(6) + UnitId(1) + PDU
|
||||
int totalLength = 6 + 1 + pduLength;
|
||||
ByteBuffer buf = ByteBuffer.allocate(totalLength).order(ByteOrder.BIG_ENDIAN);
|
||||
// MBAP Header
|
||||
buf.putShort((short) transactionId); // Transaction ID
|
||||
buf.putShort((short) 0); // Protocol ID
|
||||
buf.putShort((short) (1 + pduLength)); // Length (UnitId + PDU)
|
||||
// UnitId
|
||||
buf.put((byte) slaveId);
|
||||
// PDU
|
||||
buf.put((byte) functionCode);
|
||||
buf.put((byte) byteCount);
|
||||
for (int value : registerValues) {
|
||||
buf.putShort((short) value);
|
||||
}
|
||||
return buf.array();
|
||||
}
|
||||
|
||||
/**
|
||||
* 字节数组转十六进制字符串
|
||||
*/
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
if (bytes == null) {
|
||||
return "null";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02X ", b));
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user