Commit 88f090b66f2c9cc2cb151dbfe9f3275b237df85c

Authored by YunaiV
1 parent 4e4c776b

feat(iot):modbus-tcp-slave、modbus-tcp-master 接着大量优化,并且修复 modbus rtu 编解码的问题

Showing 27 changed files with 224 additions and 213 deletions
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusConfigSaveReqVO.java
... ... @@ -16,13 +16,9 @@ public class IotDeviceModbusConfigSaveReqVO {
16 16 private Long deviceId;
17 17  
18 18 @Schema(description = "Modbus 服务器 IP 地址", example = "192.168.1.100")
19   -// @NotEmpty(message = "Modbus 服务器 IP 地址不能为空")
20   - // TODO @AI:这个字段,要根据情况校验;
21 19 private String ip;
22 20  
23 21 @Schema(description = "Modbus 端口", example = "502")
24   -// @NotNull(message = "Modbus 端口不能为空")
25   - // TODO @AI:这个字段,要根据情况校验;
26 22 private Integer port;
27 23  
28 24 @Schema(description = "从站地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceModbusPointDO.java
... ... @@ -29,7 +29,12 @@ public class IotDeviceModbusPointDO extends TenantBaseDO {
29 29 */
30 30 @TableId
31 31 private Long id;
32   - // TODO @AI:增加 productId;
  32 + /**
  33 + * 产品编号
  34 + *
  35 + * 关联 {@link cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO#getId()}
  36 + */
  37 + private Long productId;
33 38 /**
34 39 * 设备编号
35 40 *
... ... @@ -42,7 +47,6 @@ public class IotDeviceModbusPointDO extends TenantBaseDO {
42 47 * 关联 {@link IotThingModelDO#getId()}
43 48 */
44 49 private Long thingModelId;
45   - // TODO @AI:每次物模型的变更时,需要按需刷下 identifier、name 配置;
46 50 /**
47 51 * 属性标识符
48 52 *
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceModbusPointMapper.java
... ... @@ -39,4 +39,9 @@ public interface IotDeviceModbusPointMapper extends BaseMapperX<IotDeviceModbusP
39 39 IotDeviceModbusPointDO::getIdentifier, identifier);
40 40 }
41 41  
  42 + default void updateByThingModelId(Long thingModelId, IotDeviceModbusPointDO updateObj) {
  43 + update(updateObj, new LambdaQueryWrapperX<IotDeviceModbusPointDO>()
  44 + .eq(IotDeviceModbusPointDO::getThingModelId, thingModelId));
  45 + }
  46 +
42 47 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java
... ... @@ -19,9 +19,9 @@ public enum IotDataSinkTypeEnum implements ArrayValuable&lt;Integer&gt; {
19 19 TCP(2, "TCP"),
20 20 WEBSOCKET(3, "WebSocket"),
21 21  
22   - MQTT(10, "MQTT"), // TODO 待实现;
  22 + MQTT(10, "MQTT"), // TODO @puhui999:待实现;
23 23  
24   - DATABASE(20, "Database"), // TODO @puhui999:待实现;可以简单点,对应的表名是什么,字段先固定了。
  24 + DATABASE(20, "Database"), // TODO @puhui999:待实现;
25 25 REDIS(21, "Redis"),
26 26  
27 27 ROCKETMQ(30, "RocketMQ"),
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java
... ... @@ -67,7 +67,6 @@ public class IotDeviceMessageSubscriber implements IotMessageSubscriber&lt;IotDevic
67 67 IotDeviceDO device = deviceService.validateDeviceExistsFromCache(message.getDeviceId());
68 68 devicePropertyService.updateDeviceReportTimeAsync(device.getId(), LocalDateTime.now());
69 69 // 1.2 更新设备的连接 server
70   - // TODO 芋艿:HTTP 网关的上行消息,不应该更新 serverId,会覆盖掉 MQTT 等长连接的 serverId,导致下行消息无法发送。
71 70 devicePropertyService.updateDeviceServerIdAsync(device.getId(), message.getServerId());
72 71  
73 72 // 2. 未上线的设备,强制上线
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageHandler.java
... ... @@ -9,7 +9,6 @@ import jakarta.annotation.Resource;
9 9 import lombok.extern.slf4j.Slf4j;
10 10 import org.springframework.stereotype.Component;
11 11  
12   -// TODO @puhui999:后面重构哈
13 12 /**
14 13 * 针对 {@link IotDeviceMessage} 的消费者,处理规则场景
15 14 *
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusConfigServiceImpl.java
1 1 package cn.iocoder.yudao.module.iot.service.device;
2 2  
  3 +import cn.hutool.core.lang.Assert;
  4 +import cn.hutool.core.util.StrUtil;
3 5 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
4 6 import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusConfigSaveReqVO;
5 7 import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO;
  8 +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
6 9 import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusConfigDO;
  10 +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
  11 +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
7 12 import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceModbusConfigMapper;
  13 +import cn.iocoder.yudao.module.iot.service.product.IotProductService;
8 14 import jakarta.annotation.Resource;
9 15 import org.springframework.stereotype.Service;
10 16 import org.springframework.validation.annotation.Validated;
... ... @@ -25,11 +31,17 @@ public class IotDeviceModbusConfigServiceImpl implements IotDeviceModbusConfigSe
25 31  
26 32 @Resource
27 33 private IotDeviceService deviceService;
  34 + @Resource
  35 + private IotProductService productService;
28 36  
29 37 @Override
30 38 public void saveDeviceModbusConfig(IotDeviceModbusConfigSaveReqVO saveReqVO) {
31   - // 1. 校验设备存在
32   - deviceService.validateDeviceExists(saveReqVO.getDeviceId());
  39 + // 1.1 校验设备存在
  40 + IotDeviceDO device = deviceService.validateDeviceExists(saveReqVO.getDeviceId());
  41 + // 1.2 根据产品 protocolType 条件校验
  42 + IotProductDO product = productService.getProduct(device.getProductId());
  43 + Assert.notNull(product, "产品不存在");
  44 + validateModbusConfigByProtocolType(saveReqVO, product.getProtocolType());
33 45  
34 46 // 2. 根据数据库中是否已有配置,决定是新增还是更新
35 47 IotDeviceModbusConfigDO existConfig = modbusConfigMapper.selectByDeviceId(saveReqVO.getDeviceId());
... ... @@ -58,4 +70,21 @@ public class IotDeviceModbusConfigServiceImpl implements IotDeviceModbusConfigSe
58 70 return modbusConfigMapper.selectList(listReqDTO);
59 71 }
60 72  
  73 + // TODO @AI:还是希望在 IotDeviceModbusConfigSaveReqVO 里,通过 validator 校验?!或者通过 group 来处理?
  74 + private void validateModbusConfigByProtocolType(IotDeviceModbusConfigSaveReqVO saveReqVO, String protocolType) {
  75 + IotProtocolTypeEnum protocolTypeEnum = IotProtocolTypeEnum.of(protocolType);
  76 + if (protocolTypeEnum == null) {
  77 + return;
  78 + }
  79 + if (protocolTypeEnum == IotProtocolTypeEnum.MODBUS_TCP_MASTER) {
  80 + Assert.isTrue(StrUtil.isNotEmpty(saveReqVO.getIp()), "Master 模式下,IP 地址不能为空");
  81 + Assert.notNull(saveReqVO.getPort(), "Master 模式下,端口不能为空");
  82 + Assert.notNull(saveReqVO.getTimeout(), "Master 模式下,连接超时时间不能为空");
  83 + Assert.notNull(saveReqVO.getRetryInterval(), "Master 模式下,重试间隔不能为空");
  84 + } else if (protocolTypeEnum == IotProtocolTypeEnum.MODBUS_TCP_SLAVE) {
  85 + Assert.notNull(saveReqVO.getMode(), "Slave 模式下,工作模式不能为空");
  86 + Assert.notNull(saveReqVO.getFrameFormat(), "Slave 模式下,数据帧格式不能为空");
  87 + }
  88 + }
  89 +
61 90 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusPointService.java
... ... @@ -56,6 +56,15 @@ public interface IotDeviceModbusPointService {
56 56 PageResult<IotDeviceModbusPointDO> getDeviceModbusPointPage(IotDeviceModbusPointPageReqVO pageReqVO);
57 57  
58 58 /**
  59 + * 物模型变更时,更新关联点位的冗余字段(identifier、name)
  60 + *
  61 + * @param thingModelId 物模型编号
  62 + * @param identifier 物模型标识符
  63 + * @param name 物模型名称
  64 + */
  65 + void updateDeviceModbusPointByThingModel(Long thingModelId, String identifier, String name);
  66 +
  67 + /**
59 68 * 根据设备编号批量获得启用的点位配置 Map
60 69 *
61 70 * @param deviceIds 设备编号集合
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusPointServiceImpl.java
... ... @@ -75,7 +75,12 @@ public class IotDeviceModbusPointServiceImpl implements IotDeviceModbusPointServ
75 75 modbusPointMapper.updateById(updateObj);
76 76 }
77 77  
78   - // TODO @AI:物模型更新的时候,更新下 identifier、name 信息;例如说 updateDeviceModbusPoint(thingModelId, identifier、name) 方法;
  78 + @Override
  79 + public void updateDeviceModbusPointByThingModel(Long thingModelId, String identifier, String name) {
  80 + IotDeviceModbusPointDO updateObj = new IotDeviceModbusPointDO()
  81 + .setIdentifier(identifier).setName(name);
  82 + modbusPointMapper.updateByThingModelId(thingModelId, updateObj);
  83 + }
79 84  
80 85 private IotThingModelDO validateThingModelExists(Long id) {
81 86 IotThingModelDO thingModel = thingModelService.getThingModel(id);
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDataRuleCacheableAction.java
... ... @@ -14,8 +14,6 @@ import java.time.Duration;
14 14  
15 15 // TODO @芋艿:数据库
16 16 // TODO @芋艿:mqtt
17   -// TODO @芋艿:tcp
18   -// TODO @芋艿:websocket
19 17  
20 18 /**
21 19 * 可缓存的 {@link IotDataRuleAction} 抽象实现
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImpl.java
... ... @@ -15,6 +15,7 @@ import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO;
15 15 import cn.iocoder.yudao.module.iot.dal.mysql.thingmodel.IotThingModelMapper;
16 16 import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants;
17 17 import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum;
  18 +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService;
18 19 import cn.iocoder.yudao.module.iot.service.product.IotProductService;
19 20 import jakarta.annotation.Resource;
20 21 import lombok.extern.slf4j.Slf4j;
... ... @@ -51,6 +52,9 @@ public class IotThingModelServiceImpl implements IotThingModelService {
51 52 @Lazy // 延迟加载,解决循环依赖
52 53 private IotProductService productService;
53 54  
  55 + @Resource
  56 + private IotDeviceModbusPointService deviceModbusPointService;
  57 +
54 58 @Override
55 59 @Transactional(rollbackFor = Exception.class)
56 60 public Long createThingModel(IotThingModelSaveReqVO createReqVO) {
... ... @@ -84,7 +88,11 @@ public class IotThingModelServiceImpl implements IotThingModelService {
84 88 IotThingModelDO thingModel = IotThingModelConvert.INSTANCE.convert(updateReqVO);
85 89 thingModelMapper.updateById(thingModel);
86 90  
87   - // 3. 删除缓存
  91 + // 3. 同步更新 Modbus 点位的冗余字段(identifier、name)
  92 + deviceModbusPointService.updateDeviceModbusPointByThingModel(
  93 + updateReqVO.getId(), updateReqVO.getIdentifier(), updateReqVO.getName());
  94 +
  95 + // 4. 删除缓存
88 96 deleteThingModelListCache(updateReqVO.getProductId());
89 97 }
90 98  
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtils.java
... ... @@ -500,6 +500,27 @@ public class IotModbusCommonUtils {
500 500 return values;
501 501 }
502 502  
  503 + /**
  504 + * 从响应帧中提取 registerCount(通过 PDU 的 byteCount 推断)
  505 + *
  506 + * @param frame 解码后的 Modbus 响应帧
  507 + * @return registerCount,无法提取时返回 -1(匹配时跳过校验)
  508 + */
  509 + public static int extractRegisterCountFromResponse(IotModbusFrame frame) {
  510 + byte[] pdu = frame.getPdu();
  511 + if (pdu == null || pdu.length < 1) {
  512 + return -1;
  513 + }
  514 + int byteCount = pdu[0] & 0xFF;
  515 + int fc = frame.getFunctionCode();
  516 + // FC03/04 寄存器读响应:registerCount = byteCount / 2
  517 + if (fc == FC_READ_HOLDING_REGISTERS || fc == FC_READ_INPUT_REGISTERS) {
  518 + return byteCount / 2;
  519 + }
  520 + // FC01/02 线圈/离散输入读响应:按 bit 打包有余位,无法精确反推,返回 -1 跳过校验
  521 + return -1;
  522 + }
  523 +
503 524 // ==================== 点位查找 ====================
504 525  
505 526 /**
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpmaster/IotModbusTcpMasterProtocol.java
... ... @@ -84,7 +84,6 @@ public class IotModbusTcpMasterProtocol implements IotProtocol {
84 84 IotDeviceCommonApi deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
85 85 IotDeviceMessageService messageService = SpringUtil.getBean(IotDeviceMessageService.class);
86 86 this.configCacheService = new IotModbusTcpMasterConfigCacheService(deviceApi);
87   - // DONE @AI:上线/下线消息已移到 ConnectionManager 内部处理,不再走回调
88 87 this.connectionManager = new IotModbusTcpMasterConnectionManager(redissonClient, vertx,
89 88 messageService, configCacheService, serverId);
90 89  
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpmaster/manager/IotModbusTcpMasterConfigCacheService.java
... ... @@ -79,8 +79,6 @@ public class IotModbusTcpMasterConfigCacheService {
79 79 /**
80 80 * 计算已删除设备的 ID 集合,清理缓存,并更新已知设备 ID 集合
81 81 *
82   - * DONE @AI:不再使用 callback 模式,返回已删除的设备 ID 集合,由调用方直接清理
83   - *
84 82 * @param currentConfigs 当前有效的配置列表
85 83 * @return 已删除的设备 ID 集合
86 84 */
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpmaster/manager/IotModbusTcpMasterConnectionManager.java
... ... @@ -96,18 +96,19 @@ public class IotModbusTcpMasterConnectionManager {
96 96 connection = connectionPool.get(connectionKey);
97 97 if (connection != null) {
98 98 addDeviceAndOnline(connection, config);
  99 + lock.unlock();
99 100 return;
100 101 }
101 102 // 3.2 创建新连接
102 103 connection = createConnection(config);
  104 + connection.setLock(lock);
103 105 connectionPool.put(connectionKey, connection);
104 106 log.info("[ensureConnection][创建 Modbus 连接成功: {}]", connectionKey);
105 107 // 3.3 注册设备并发送上线消息
106 108 addDeviceAndOnline(connection, config);
107 109 } catch (Exception e) {
108 110 log.error("[ensureConnection][创建 Modbus 连接失败: {}]", connectionKey, e);
109   - } finally {
110   - // TODO @AI:如果这里释放,会不会出现,集群模式下,多个节点同时创建连接的情况?需要验证一下 Redisson 的分布式锁特性?!
  111 + // 建连失败,释放锁让其他节点可重试
111 112 lock.unlock();
112 113 }
113 114 }
... ... @@ -231,11 +232,11 @@ public class IotModbusTcpMasterConnectionManager {
231 232 if (connection.getTcpConnection() != null) {
232 233 connection.getTcpConnection().close();
233 234 }
234   - // 强制解锁,避免死锁(正常情况下应该不会发生锁未释放的情况)
235   -// RLock lock = connection.getLock();
236   -// if (lock != null && lock.isLocked()) {
237   -// lock.forceUnlock();
238   -// }
  235 + // 释放分布式锁,让其他节点可接管
  236 + RLock lock = connection.getLock();
  237 + if (lock != null && lock.isHeldByCurrentThread()) {
  238 + lock.unlock();
  239 + }
239 240 log.info("[closeConnection][关闭 Modbus 连接: {}]", connectionKey);
240 241 } catch (Exception e) {
241 242 log.error("[closeConnection][关闭连接失败: {}]", connectionKey, e);
... ... @@ -266,17 +267,21 @@ public class IotModbusTcpMasterConnectionManager {
266 267 private TCPMasterConnection tcpConnection;
267 268 private Integer timeout;
268 269 private Integer retryInterval;
269   -
270   - private Context context;
271   -
272   - // TODO @AI:是不是需要 lock?!避免集群模式下的竞争(肯定不能让别的节点连接上)!!!【另外,RLock 在节点(持有所锁的节点) cransh 的时候,会自动释放】
273   -// private RLock lock;
274   -
275 270 /**
276 271 * 设备 ID 到 slave ID 的映射
277 272 */
278 273 private final Map<Long, Integer> deviceSlaveMap = new ConcurrentHashMap<>();
279 274  
  275 + /**
  276 + * 分布式锁,锁住连接的创建和销毁,避免多节点重复连接同一从站
  277 + */
  278 + private RLock lock;
  279 +
  280 + /**
  281 + * Vert.x Context,用于 executeBlocking 执行 Modbus 操作,保证同一连接的操作串行执行
  282 + */
  283 + private Context context;
  284 +
280 285 public void addDevice(Long deviceId, Integer slaveId) {
281 286 deviceSlaveMap.put(deviceId, slaveId);
282 287 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpslave/IotModbusTcpSlaveConfig.java
... ... @@ -41,10 +41,4 @@ public class IotModbusTcpSlaveConfig {
41 41 @NotNull(message = "请求清理间隔不能为空")
42 42 private Integer requestCleanupInterval = 10000;
43 43  
44   - // TODO @AI:可以去掉这个开关,因为本身就是模拟的,稍后我自己也会手动或者让你去掉(听我指令!)
45   - /**
46   - * 是否启用 Mock 测试数据(仅开发/测试环境使用,线上务必关闭)
47   - */
48   - private Boolean mockEnabled = false;
49   -
50 44 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpslave/codec/IotModbusFrameDecoder.java
... ... @@ -29,21 +29,36 @@ import java.util.function.BiConsumer;
29 29 @Slf4j
30 30 public class IotModbusFrameDecoder {
31 31  
  32 + private static final Boolean REQUEST_MODE_DEFAULT = false;
  33 +
32 34 /**
33 35 * 自定义功能码
34 36 */
35 37 private final int customFunctionCode;
36 38  
37 39 /**
38   - * 创建带自动帧格式检测的 RecordParser
  40 + * 创建带自动帧格式检测的 RecordParser(默认响应模式)
39 41 *
40 42 * @param frameHandler 完整帧回调(解码后的 IotModbusFrame + 检测到的帧格式)
41 43 * @return RecordParser 实例
42 44 */
43 45 public RecordParser createRecordParser(BiConsumer<IotModbusFrame, IotModbusFrameFormatEnum> frameHandler) {
  46 + return createRecordParser(frameHandler, REQUEST_MODE_DEFAULT);
  47 + }
  48 +
  49 + /**
  50 + * 创建带自动帧格式检测的 RecordParser
  51 + *
  52 + * @param frameHandler 完整帧回调(解码后的 IotModbusFrame + 检测到的帧格式)
  53 + * @param requestMode 是否为请求模式(true:接收方收到的是 Modbus 请求帧,FC01-04 按固定 8 字节解析;
  54 + * false:接收方收到的是 Modbus 响应帧,FC01-04 按 byteCount 变长解析)
  55 + * @return RecordParser 实例
  56 + */
  57 + public RecordParser createRecordParser(BiConsumer<IotModbusFrame, IotModbusFrameFormatEnum> frameHandler,
  58 + boolean requestMode) {
44 59 // 先创建一个 RecordParser:使用 fixedSizeMode(6) 读取首帧前 6 字节进行帧格式检测
45 60 RecordParser parser = RecordParser.newFixed(6);
46   - parser.handler(new DetectPhaseHandler(parser, customFunctionCode, frameHandler));
  61 + parser.handler(new DetectPhaseHandler(parser, customFunctionCode, frameHandler, requestMode));
47 62 return parser;
48 63 }
49 64  
... ... @@ -150,6 +165,7 @@ public class IotModbusFrameDecoder {
150 165 private final RecordParser parser;
151 166 private final int customFunctionCode;
152 167 private final BiConsumer<IotModbusFrame, IotModbusFrameFormatEnum> frameHandler;
  168 + private final boolean requestMode;
153 169  
154 170 @Override
155 171 public void handle(Buffer buffer) {
... ... @@ -169,7 +185,7 @@ public class IotModbusFrameDecoder {
169 185 } else {
170 186 // MODBUS_RTU:切换到 RTU 拆包 Handler
171 187 log.debug("[DetectPhaseHandler][检测到 MODBUS_RTU 帧格式]");
172   - RtuFrameHandler rtuHandler = new RtuFrameHandler(parser, frameHandler, customFunctionCode);
  188 + RtuFrameHandler rtuHandler = new RtuFrameHandler(parser, frameHandler, customFunctionCode, requestMode);
173 189 parser.handler(rtuHandler);
174 190 // 当前 bytes 包含前 6 字节(slaveId + FC + 部分数据),交给 rtuHandler 处理
175 191 rtuHandler.handleFirstBytes(bytes);
... ... @@ -248,6 +264,9 @@ public class IotModbusFrameDecoder {
248 264 * - 自定义 FC / FC01-04 响应:fixedSizeMode(1) → 读 byteCount → fixedSizeMode(byteCount + 2)
249 265 * - FC05/06 响应:fixedSizeMode(6) → addr(2) + value(2) + CRC(2)
250 266 * - FC15/16 响应:fixedSizeMode(6) → addr(2) + quantity(2) + CRC(2)
  267 + * <p>
  268 + * 请求模式(requestMode=true)时,FC01-04 按固定 8 字节解析(与写响应相同路径),
  269 + * 因为读请求格式为 [SlaveId(1)][FC(1)][StartAddr(2)][Quantity(2)][CRC(2)]
251 270 */
252 271 @RequiredArgsConstructor
253 272 private class RtuFrameHandler implements Handler<Buffer> {
... ... @@ -261,6 +280,12 @@ public class IotModbusFrameDecoder {
261 280 private final RecordParser parser;
262 281 private final BiConsumer<IotModbusFrame, IotModbusFrameFormatEnum> frameHandler;
263 282 private final int customFunctionCode;
  283 + /**
  284 + * 请求模式:
  285 + * - true 表示接收方收到的是 Modbus 请求帧(如设备端收到网关下发的读请求),FC01-04 按固定 8 字节帧解析
  286 + * - false 表示接收方收到的是 Modbus 响应帧,FC01-04 按 byteCount 变长解析
  287 + */
  288 + private final boolean requestMode;
264 289  
265 290 private int state = STATE_HEADER;
266 291 private byte slaveId;
... ... @@ -289,6 +314,13 @@ public class IotModbusFrameDecoder {
289 314 frame.appendBytes(bytes, 2, 3); // exceptionCode + CRC
290 315 emitFrame(frame);
291 316 resetToHeader();
  317 + } else if (IotModbusCommonUtils.isReadResponse(fc) && requestMode) {
  318 + // 请求模式下的读请求:固定 8 字节 [SlaveId(1)][FC(1)][StartAddr(2)][Quantity(2)][CRC(2)]
  319 + // 已有 6 字节,还需 2 字节(CRC)
  320 + state = STATE_WRITE_BODY;
  321 + this.pendingData = Buffer.buffer();
  322 + this.pendingData.appendBytes(bytes, 2, 4); // 暂存已有的 4 字节(StartAddr + Quantity)
  323 + parser.fixedSizeMode(2); // 还需 2 字节(CRC)
292 324 } else if (IotModbusCommonUtils.isReadResponse(fc) || fc == customFunctionCode) {
293 325 // 读响应或自定义 FC:bytes[2] = byteCount
294 326 this.byteCount = bytes[2];
... ... @@ -359,6 +391,11 @@ public class IotModbusFrameDecoder {
359 391 // 异常响应
360 392 state = STATE_EXCEPTION_BODY;
361 393 parser.fixedSizeMode(3); // exceptionCode(1) + CRC(2)
  394 + } else if (IotModbusCommonUtils.isReadResponse(fc) && requestMode) {
  395 + // 请求模式下的读请求:固定 8 字节,已读 2 字节(slaveId + FC),还需 6 字节
  396 + state = STATE_WRITE_BODY;
  397 + pendingData = Buffer.buffer();
  398 + parser.fixedSizeMode(6); // StartAddr(2) + Quantity(2) + CRC(2)
362 399 } else if (IotModbusCommonUtils.isReadResponse(fc) || fc == customFunctionCode) {
363 400 // 读响应或自定义 FC
364 401 state = STATE_READ_BYTE_COUNT;
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpslave/codec/IotModbusFrameEncoder.java
... ... @@ -46,9 +46,6 @@ public class IotModbusFrameEncoder {
46 46 /**
47 47 * 编码写请求(单个寄存器 FC06 / 单个线圈 FC05)
48 48 *
49   - * DONE @AI:【from codex】【高】FC05 写线圈时,value 已转换为 Modbus 标准值(非0 → 0xFF00,0 → 0x0000);
50   - * 新增 encodeWriteMultipleCoilsRequest 方法用于 FC15 编码(按 bit 打包)。
51   - *
52 49 * @param slaveId 从站地址
53 50 * @param functionCode 功能码
54 51 * @param address 寄存器地址
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpslave/handler/downstream/IotModbusTcpSlaveDownstreamHandler.java
... ... @@ -109,7 +109,6 @@ public class IotModbusTcpSlaveDownstreamHandler {
109 109 // 1.2 确定帧格式和事务 ID
110 110 IotModbusFrameFormatEnum frameFormat = connInfo.getFrameFormat();
111 111 Assert.notNull(frameFormat, "连接帧格式不能为空");
112   - // TODO @AI:【from 芋艿】需要和按照 deviceId 进行自增么???
113 112 Integer transactionId = frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP
114 113 ? (transactionIdCounter.incrementAndGet() & 0xFFFF)
115 114 : null;
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpslave/manager/IotModbusTcpSlaveConfigCacheService.java
... ... @@ -6,15 +6,15 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult;
6 6 import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
7 7 import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO;
8 8 import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
9   -import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
10   -import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
11 9 import cn.iocoder.yudao.module.iot.core.enums.IotModbusModeEnum;
12 10 import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
13 11 import lombok.RequiredArgsConstructor;
14 12 import lombok.extern.slf4j.Slf4j;
15 13  
16   -import java.math.BigDecimal;
17   -import java.util.*;
  14 +import java.util.Collections;
  15 +import java.util.List;
  16 +import java.util.Map;
  17 +import java.util.Set;
18 18 import java.util.concurrent.ConcurrentHashMap;
19 19  
20 20 /**
... ... @@ -33,8 +33,6 @@ public class IotModbusTcpSlaveConfigCacheService {
33 33 */
34 34 private final Map<Long, IotModbusDeviceConfigRespDTO> configCache = new ConcurrentHashMap<>();
35 35  
36   - // ==================== 按需加载(认证时) ====================
37   -
38 36 /**
39 37 * 加载单个设备的配置(认证成功后调用)
40 38 *
... ... @@ -66,8 +64,6 @@ public class IotModbusTcpSlaveConfigCacheService {
66 64 }
67 65 }
68 66  
69   - // ==================== 定时刷新(已连接设备) ====================
70   -
71 67 /**
72 68 * 刷新已连接设备的配置缓存
73 69 * <p>
... ... @@ -89,31 +85,17 @@ public class IotModbusTcpSlaveConfigCacheService {
89 85 .setDeviceIds(connectedDeviceIds));
90 86 List<IotModbusDeviceConfigRespDTO> modbusConfigs = result.getCheckedData();
91 87  
92   - // 2. 追加 Mock 测试数据(仅 mockEnabled=true 时)
93   - // TODO @芋艿:测试完成后移除
94   - // TODO @claude-code:【严重】同上,if(true) 导致 mockEnabled 开关失效,Mock 数据永远加载
95   - if (true) {
96   - modbusConfigs.addAll(buildMockConfigs());
97   - }
98   -
99   - // 2. 只保留已连接设备的配置,更新缓存
100   - // TODO @AI:是不是直接添加到 configCache 缓存(或者覆盖),然后返回 modbusConfigs 就 ok 了?!
101   - List<IotModbusDeviceConfigRespDTO> connectedConfigs = new ArrayList<>();
  88 + // 2. 更新缓存并返回
102 89 for (IotModbusDeviceConfigRespDTO config : modbusConfigs) {
103   - if (connectedDeviceIds.contains(config.getDeviceId())) {
104   - configCache.put(config.getDeviceId(), config);
105   - connectedConfigs.add(config);
106   - }
  90 + configCache.put(config.getDeviceId(), config);
107 91 }
108   - return connectedConfigs;
  92 + return modbusConfigs;
109 93 } catch (Exception e) {
110 94 log.error("[refreshConnectedDeviceConfigList][刷新配置失败]", e);
111 95 return null;
112 96 }
113 97 }
114 98  
115   - // ==================== 缓存操作 ====================
116   -
117 99 /**
118 100 * 获取设备配置
119 101 */
... ... @@ -133,59 +115,4 @@ public class IotModbusTcpSlaveConfigCacheService {
133 115 configCache.remove(deviceId);
134 116 }
135 117  
136   - // ==================== Mock 数据 ====================
137   -
138   - /**
139   - * 构建 Mock 测试配置数据(一次性测试用途)
140   - *
141   - * 设备:PRODUCT_KEY=4aymZgOTOOCrDKRT, DEVICE_NAME=small
142   - * 点位:temperature(FC03, 地址 0)、humidity(FC03, 地址 1)
143   - *
144   - * TODO @芋艿:测试完成后移除
145   - */
146   - private List<IotModbusDeviceConfigRespDTO> buildMockConfigs() {
147   - IotModbusDeviceConfigRespDTO config = new IotModbusDeviceConfigRespDTO();
148   - config.setDeviceId(25L);
149   - config.setProductKey("4aymZgOTOOCrDKRT");
150   - config.setDeviceName("small");
151   - config.setSlaveId(1);
152   - config.setMode(1); // 云端轮询
153   - config.setFrameFormat(IotModbusFrameFormatEnum.MODBUS_TCP.getFormat());
154   -
155   - // 点位列表
156   - List<IotModbusPointRespDTO> points = new ArrayList<>();
157   -
158   - // 点位 1:温度 - 保持寄存器 FC03, 地址 0, 1 个寄存器, INT16
159   - IotModbusPointRespDTO point1 = new IotModbusPointRespDTO();
160   - point1.setId(1L);
161   - point1.setIdentifier("temperature");
162   - point1.setName("温度");
163   - point1.setFunctionCode(3); // FC03 读保持寄存器
164   - point1.setRegisterAddress(0);
165   - point1.setRegisterCount(1);
166   - point1.setRawDataType("INT16");
167   - point1.setByteOrder("BIG_ENDIAN");
168   - point1.setScale(new BigDecimal("0.1"));
169   - point1.setPollInterval(5000); // 5 秒轮询一次
170   - points.add(point1);
171   -
172   - // 点位 2:湿度 - 保持寄存器 FC03, 地址 1, 1 个寄存器, UINT16
173   - IotModbusPointRespDTO point2 = new IotModbusPointRespDTO();
174   - point2.setId(2L);
175   - point2.setIdentifier("humidity");
176   - point2.setName("湿度");
177   - point2.setFunctionCode(3); // FC03 读保持寄存器
178   - point2.setRegisterAddress(1);
179   - point2.setRegisterCount(1);
180   - point2.setRawDataType("UINT16");
181   - point2.setByteOrder("BIG_ENDIAN");
182   - point2.setScale(new BigDecimal("0.1"));
183   - point2.setPollInterval(5000); // 5 秒轮询一次
184   - points.add(point2);
185   -
186   - config.setPoints(points);
187   - log.info("[buildMockConfigs][已加载 Mock 配置, deviceId={}, points={}]", config.getDeviceId(), points.size());
188   - return Collections.singletonList(config);
189   - }
190   -
191 118 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpslave/manager/IotModbusTcpSlavePendingRequestManager.java
... ... @@ -78,7 +78,7 @@ public class IotModbusTcpSlavePendingRequestManager {
78 78 return matchByTransactionId(queue, frame.getTransactionId());
79 79 }
80 80 // RTU 模式:FIFO,匹配 slaveId + functionCode + registerCount
81   - int responseRegisterCount = extractRegisterCountFromResponse(frame);
  81 + int responseRegisterCount = IotModbusCommonUtils.extractRegisterCountFromResponse(frame);
82 82 return matchByFifo(queue, frame.getSlaveId(), frame.getFunctionCode(), responseRegisterCount);
83 83 }
84 84  
... ... @@ -115,29 +115,6 @@ public class IotModbusTcpSlavePendingRequestManager {
115 115 return null;
116 116 }
117 117  
118   - // TODO @AI:是不是放到 modbus 工具类里,更合适?
119   - /**
120   - * 从响应帧中提取 registerCount(通过 PDU 的 byteCount 推断)
121   - *
122   - * @return registerCount,无法提取时返回 -1(匹配时跳过校验)
123   - */
124   - private int extractRegisterCountFromResponse(IotModbusFrame frame) {
125   - byte[] pdu = frame.getPdu();
126   - if (pdu == null || pdu.length < 1) {
127   - return -1;
128   - }
129   - int byteCount = pdu[0] & 0xFF;
130   - int fc = frame.getFunctionCode();
131   - // FC03/04 寄存器读响应:registerCount = byteCount / 2
132   - if (fc == IotModbusCommonUtils.FC_READ_HOLDING_REGISTERS
133   - || fc == IotModbusCommonUtils.FC_READ_INPUT_REGISTERS) {
134   - return byteCount / 2;
135   - }
136   - // FC01/02 线圈/离散输入读响应:registerCount = byteCount * 8(线圈数量)
137   - // 但因为按 bit 打包有余位,无法精确反推,返回 -1 跳过校验
138   - return -1;
139   - }
140   -
141 118 /**
142 119 * 清理过期请求
143 120 */
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java
... ... @@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.common.exception.ServiceException;
6 6 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
7 7 import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager;
8 8 import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
  9 +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
9 10 import io.vertx.mqtt.MqttEndpoint;
10 11 import lombok.extern.slf4j.Slf4j;
11 12  
... ... @@ -58,6 +59,11 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler {
58 59 Assert.notNull(connectionInfo, "无法获取连接信息");
59 60 Assert.equals(productKey, connectionInfo.getProductKey(), "产品 Key 不匹配");
60 61 Assert.equals(deviceName, connectionInfo.getDeviceName(), "设备名称不匹配");
  62 + // 1.4 校验 topic 是否允许发布
  63 + if (!IotMqttTopicUtils.isTopicPublishAllowed(topic, productKey, deviceName)) {
  64 + log.warn("[handleBusinessRequest][topic 不允许发布,客户端 ID: {},主题: {}]", clientId, topic);
  65 + return;
  66 + }
61 67  
62 68 // 2. 反序列化消息
63 69 message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName);
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java
... ... @@ -91,7 +91,6 @@ public class IotUdpUpstreamHandler {
91 91 this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class);
92 92 }
93 93  
94   - // TODO done @AI:vertx 有 udp 的实现么?当前已使用 Vert.x DatagramSocket 实现
95 94 /**
96 95 * 处理 UDP 数据包
97 96 *
... ...
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java
... ... @@ -101,8 +101,6 @@ public final class IotMqttTopicUtils {
101 101 * @param deviceName 设备名称
102 102 * @return 是否允许发布
103 103 */
104   - // TODO DONE @AI:这个逻辑,是不是 mqtt 协议,也要使用???答:是通用工具方法,MQTT 协议可按需调用;
105   - // TODO @AI:那你改下 mqtt,也调用!!!
106 104 public static boolean isTopicPublishAllowed(String topic, String productKey, String deviceName) {
107 105 if (!StrUtil.isAllNotBlank(topic, productKey, deviceName)) {
108 106 return false;
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpmaster/ModbusTcpSlaveSimulatorTest.java renamed to yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpmaster/IoTModbusTcpMasterIntegrationTest.java
... ... @@ -3,21 +3,49 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpmaster;
3 3 import com.ghgande.j2mod.modbus.procimg.*;
4 4 import com.ghgande.j2mod.modbus.slave.ModbusSlave;
5 5 import com.ghgande.j2mod.modbus.slave.ModbusSlaveFactory;
  6 +import lombok.extern.slf4j.Slf4j;
  7 +import org.junit.jupiter.api.Disabled;
  8 +import org.junit.jupiter.api.Test;
6 9  
7 10 /**
8   - * Modbus TCP 从站模拟器
  11 + * Modbus TCP 从站模拟器(手动测试)
9 12 *
10   - * 用于测试 Modbus TCP 网关的连接和数据读写功能
  13 + * <p>测试场景:模拟一个标准 Modbus TCP 从站设备,供 Modbus TCP Master 网关连接和读写数据
  14 + *
  15 + * <p>使用步骤:
  16 + * <ol>
  17 + * <li>运行 {@link #testStartSlaveSimulator()} 启动模拟从站(默认端口 5020,从站地址 1)</li>
  18 + * <li>启动 yudao-module-iot-gateway 服务(需开启 modbus-tcp-master 协议)</li>
  19 + * <li>确保数据库有对应的 Modbus Master 设备配置(ip=127.0.0.1, port=5020, slaveId=1)</li>
  20 + * <li>网关会自动连接模拟从站并开始轮询读取寄存器数据</li>
  21 + * <li>模拟器每 5 秒自动更新输入寄存器和保持寄存器的值,模拟传感器数据变化</li>
  22 + * </ol>
  23 + *
  24 + * <p>可用寄存器:
  25 + * <ul>
  26 + * <li>线圈 (Coil, 功能码 01/05): 地址 0-9,交替 true/false</li>
  27 + * <li>离散输入 (Discrete Input, 功能码 02): 地址 0-9,每 3 个一个 true</li>
  28 + * <li>保持寄存器 (Holding Register, 功能码 03/06/16): 地址 0-19,初始值 0,100,200,...</li>
  29 + * <li>输入寄存器 (Input Register, 功能码 04): 地址 0-19,初始值 1,11,21,...</li>
  30 + * </ul>
11 31 *
12 32 * @author 芋道源码
13 33 */
14   -public class ModbusTcpSlaveSimulatorTest {
  34 +@Slf4j
  35 +@Disabled
  36 +public class IoTModbusTcpMasterIntegrationTest {
15 37  
16 38 private static final int PORT = 5020;
17 39 private static final int SLAVE_ID = 1;
18 40  
19   - @SuppressWarnings({"InfiniteLoopStatement", "BusyWait", "CommentedOutCode"})
20   - public static void main(String[] args) throws Exception {
  41 + /**
  42 + * 启动 Modbus TCP 从站模拟器
  43 + *
  44 + * <p>模拟器会持续运行,每 5 秒更新一次寄存器数据,直到手动停止
  45 + */
  46 + @SuppressWarnings({"InfiniteLoopStatement", "BusyWait"})
  47 + @Test
  48 + public void testStartSlaveSimulator() throws Exception {
21 49 // 1. 创建进程映像(Process Image),存储寄存器数据
22 50 SimpleProcessImage spi = new SimpleProcessImage(SLAVE_ID);
23 51  
... ... @@ -53,24 +81,15 @@ public class ModbusTcpSlaveSimulatorTest {
53 81 // 3.2 启动从站
54 82 slave.open();
55 83  
56   - System.out.println("===================================================");
57   - System.out.println("Modbus TCP 从站模拟器已启动");
58   - System.out.println("端口: " + PORT);
59   - System.out.println("从站地址 (Slave ID): " + SLAVE_ID);
60   - System.out.println("===================================================");
61   - System.out.println("可用寄存器:");
62   - System.out.println(" - 线圈 (Coil, 功能码 01/05): 地址 0-9");
63   - System.out.println(" - 离散输入 (Discrete Input, 功能码 02): 地址 0-9");
64   - System.out.println(" - 保持寄存器 (Holding Register, 功能码 03/06/16): 地址 0-19");
65   - System.out.println(" - 输入寄存器 (Input Register, 功能码 04): 地址 0-19");
66   - System.out.println("===================================================");
67   - System.out.println("按 Ctrl+C 停止模拟器");
  84 + log.info("[testStartSlaveSimulator][Modbus TCP 从站模拟器已启动, 端口: {}, 从站地址: {}]", PORT, SLAVE_ID);
  85 + log.info("[testStartSlaveSimulator][可用寄存器: 线圈(01/05) 0-9, 离散输入(02) 0-9, " +
  86 + "保持寄存器(03/06/16) 0-19, 输入寄存器(04) 0-19]");
68 87  
69 88 // 4. 添加关闭钩子
70 89 Runtime.getRuntime().addShutdownHook(new Thread(() -> {
71   - System.out.println("\n正在关闭模拟器...");
  90 + log.info("[testStartSlaveSimulator][正在关闭模拟器...]");
72 91 slave.close();
73   - System.out.println("模拟器已关闭");
  92 + log.info("[testStartSlaveSimulator][模拟器已关闭]");
74 93 }));
75 94  
76 95 // 5. 保持运行,定时更新输入寄存器模拟数据变化
... ... @@ -87,9 +106,8 @@ public class ModbusTcpSlaveSimulatorTest {
87 106  
88 107 // 更新保持寄存器的第一个值
89 108 spi.getRegister(0).setValue(counter * 100);
90   -// System.out.println("[" + java.time.LocalTime.now() + "] 数据已更新, counter=" + counter
91   -// + ", 保持寄存器[0]=" + (counter * 100)
92   -// + ", 输入寄存器[0]=" + (1 + counter));
  109 + log.info("[testStartSlaveSimulator][数据已更新, counter={}, 保持寄存器[0]={}, 输入寄存器[0]={}]",
  110 + counter, counter * 100, 1 + counter);
93 111 }
94 112 }
95 113  
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpslave/IotModbusTcpSlaveModbusRtuIntegrationTest.java renamed to yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpslave/IotModbusTcpSlaveRtuIntegrationTest.java
1 1 package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave;
2 2  
  3 +import cn.hutool.core.util.HexUtil;
3 4 import cn.hutool.json.JSONObject;
4 5 import cn.hutool.json.JSONUtil;
5 6 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
6 7 import cn.iocoder.yudao.module.iot.core.enums.IotModbusFrameFormatEnum;
7 8 import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
  9 +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils;
8 10 import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrame;
9 11 import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrameDecoder;
10 12 import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave.codec.IotModbusFrameEncoder;
11   -import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils;
12 13 import io.vertx.core.Vertx;
13 14 import io.vertx.core.buffer.Buffer;
14 15 import io.vertx.core.net.NetClient;
... ... @@ -24,6 +25,8 @@ import org.junit.jupiter.api.Test;
24 25 import java.util.concurrent.CompletableFuture;
25 26 import java.util.concurrent.TimeUnit;
26 27  
  28 +import static org.junit.jupiter.api.Assertions.assertEquals;
  29 +
27 30 /**
28 31 * IoT Modbus TCP Slave 协议集成测试 — MODBUS_RTU 帧格式(手动测试)
29 32 *
... ... @@ -46,7 +49,7 @@ import java.util.concurrent.TimeUnit;
46 49 */
47 50 @Slf4j
48 51 @Disabled
49   -public class IotModbusTcpSlaveModbusRtuIntegrationTest {
  52 +public class IotModbusTcpSlaveRtuIntegrationTest {
50 53  
51 54 private static final String SERVER_HOST = "127.0.0.1";
52 55 private static final int SERVER_PORT = 503;
... ... @@ -65,9 +68,9 @@ public class IotModbusTcpSlaveModbusRtuIntegrationTest {
65 68  
66 69 // ===================== 设备信息(根据实际情况修改,从 iot_device 表查询) =====================
67 70  
68   - private static final String PRODUCT_KEY = "4aymZgOTOOCrDKRT";
69   - private static final String DEVICE_NAME = "small";
70   - private static final String DEVICE_SECRET = "0baa4c2ecc104ae1a26b4070c218bdf3";
  71 + private static final String PRODUCT_KEY = "modbus_tcp_slave_product_demo";
  72 + private static final String DEVICE_NAME = "modbus_tcp_slave_device_demo_rtu";
  73 + private static final String DEVICE_SECRET = "af01c55eb8e3424bb23fc6c783936b2e";
71 74  
72 75 @BeforeAll
73 76 static void setUp() {
... ... @@ -104,6 +107,7 @@ public class IotModbusTcpSlaveModbusRtuIntegrationTest {
104 107 log.info("[testAuth][认证响应帧: slaveId={}, FC={}, customData={}]",
105 108 response.getSlaveId(), response.getFunctionCode(), response.getCustomData());
106 109 JSONObject json = JSONUtil.parseObj(response.getCustomData());
  110 + assertEquals(0, json.getInt("code"));
107 111 log.info("[testAuth][认证结果: code={}, message={}]", json.getInt("code"), json.getStr("message"));
108 112 } finally {
109 113 socket.close();
... ... @@ -122,10 +126,13 @@ public class IotModbusTcpSlaveModbusRtuIntegrationTest {
122 126 // 1. 先认证
123 127 IotModbusFrame authResponse = authenticate(socket);
124 128 log.info("[testPollingResponse][认证响应: {}]", authResponse.getCustomData());
  129 + JSONObject authJson = JSONUtil.parseObj(authResponse.getCustomData());
  130 + assertEquals(0, authJson.getInt("code"));
125 131  
126 132 // 2. 设置持续监听:每收到一个读请求,自动回复
127 133 log.info("[testPollingResponse][开始持续监听网关下发的读请求...]");
128 134 CompletableFuture<Void> done = new CompletableFuture<>();
  135 + // 注意:使用 requestMode=true,因为设备端收到的是网关下发的读请求(非响应)
129 136 RecordParser parser = FRAME_DECODER.createRecordParser((frame, frameFormat) -> {
130 137 log.info("[testPollingResponse][收到请求: slaveId={}, FC={}]",
131 138 frame.getSlaveId(), frame.getFunctionCode());
... ... @@ -144,7 +151,7 @@ public class IotModbusTcpSlaveModbusRtuIntegrationTest {
144 151 frame.getFunctionCode(), registerValues);
145 152 socket.write(Buffer.buffer(responseData));
146 153 log.info("[testPollingResponse][已发送读响应, registerValues={}]", registerValues);
147   - });
  154 + }, true);
148 155 socket.handler(parser);
149 156  
150 157 // 3. 持续等待(200 秒),期间会自动回复所有收到的读请求
... ... @@ -174,7 +181,7 @@ public class IotModbusTcpSlaveModbusRtuIntegrationTest {
174 181 IotModbusFrame writeRequest = waitForRequest(socket);
175 182 log.info("[testPropertySetWrite][收到写请求: slaveId={}, FC={}, pdu={}]",
176 183 writeRequest.getSlaveId(), writeRequest.getFunctionCode(),
177   - bytesToHex(writeRequest.getPdu()));
  184 + HexUtil.encodeHexStr(writeRequest.getPdu()));
178 185 } finally {
179 186 socket.close();
180 187 }
... ... @@ -198,6 +205,7 @@ public class IotModbusTcpSlaveModbusRtuIntegrationTest {
198 205 */
199 206 private IotModbusFrame authenticate(NetSocket socket) throws Exception {
200 207 IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET);
  208 + authInfo.setClientId("");
201 209 byte[] authFrame = buildAuthFrame(authInfo.getClientId(), authInfo.getUsername(), authInfo.getPassword());
202 210 return sendAndReceive(socket, authFrame);
203 211 }
... ... @@ -291,18 +299,4 @@ public class IotModbusTcpSlaveModbusRtuIntegrationTest {
291 299 return frame;
292 300 }
293 301  
294   - /**
295   - * 字节数组转十六进制字符串
296   - */
297   - private static String bytesToHex(byte[] bytes) {
298   - if (bytes == null) {
299   - return "null";
300   - }
301   - StringBuilder sb = new StringBuilder();
302   - for (byte b : bytes) {
303   - sb.append(String.format("%02X ", b));
304   - }
305   - return sb.toString().trim();
306   - }
307   -
308 302 }
... ...
yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpslave/IotModbusTcpSlaveModbusTcpIntegrationTest.java renamed to yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpslave/IotModbusTcpSlaveTcpIntegrationTest.java
1 1 package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpslave;
2 2  
  3 +import cn.hutool.core.util.HexUtil;
3 4 import cn.hutool.json.JSONObject;
4 5 import cn.hutool.json.JSONUtil;
5 6 import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
... ... @@ -25,7 +26,8 @@ import java.nio.ByteOrder;
25 26 import java.util.concurrent.CompletableFuture;
26 27 import java.util.concurrent.TimeUnit;
27 28  
28   -// TODO @芋艿:【晚点改】单测需要简化!
  29 +import static org.junit.jupiter.api.Assertions.assertEquals;
  30 +
29 31 /**
30 32 * IoT Modbus TCP Slave 协议集成测试 — MODBUS_TCP 帧格式(手动测试)
31 33 *
... ... @@ -48,7 +50,7 @@ import java.util.concurrent.TimeUnit;
48 50 */
49 51 @Slf4j
50 52 @Disabled
51   -public class IotModbusTcpSlaveModbusTcpIntegrationTest {
  53 +public class IotModbusTcpSlaveTcpIntegrationTest {
52 54  
53 55 private static final String SERVER_HOST = "127.0.0.1";
54 56 private static final int SERVER_PORT = 503;
... ... @@ -106,6 +108,7 @@ public class IotModbusTcpSlaveModbusTcpIntegrationTest {
106 108 log.info("[testAuth][认证响应帧: slaveId={}, FC={}, customData={}]",
107 109 response.getSlaveId(), response.getFunctionCode(), response.getCustomData());
108 110 JSONObject json = JSONUtil.parseObj(response.getCustomData());
  111 + assertEquals(0, json.getInt("code"));
109 112 log.info("[testAuth][认证结果: code={}, message={}]", json.getInt("code"), json.getStr("message"));
110 113 } finally {
111 114 socket.close();
... ... @@ -124,7 +127,8 @@ public class IotModbusTcpSlaveModbusTcpIntegrationTest {
124 127 // 1. 先认证
125 128 IotModbusFrame authResponse = authenticate(socket);
126 129 log.info("[testPollingResponse][认证响应: {}]", authResponse.getCustomData());
127   - // TODO @AI:这里断言下,认证必须成功!
  130 + JSONObject authJson = JSONUtil.parseObj(authResponse.getCustomData());
  131 + assertEquals(0, authJson.getInt("code"));
128 132  
129 133 // 2. 设置持续监听:每收到一个读请求,自动回复
130 134 log.info("[testPollingResponse][开始持续监听网关下发的读请求...]");
... ... @@ -176,7 +180,7 @@ public class IotModbusTcpSlaveModbusTcpIntegrationTest {
176 180 IotModbusFrame writeRequest = waitForRequest(socket);
177 181 log.info("[testPropertySetWrite][收到写请求: slaveId={}, FC={}, transactionId={}, pdu={}]",
178 182 writeRequest.getSlaveId(), writeRequest.getFunctionCode(),
179   - writeRequest.getTransactionId(), bytesToHex(writeRequest.getPdu()));
  183 + writeRequest.getTransactionId(), HexUtil.encodeHexStr(writeRequest.getPdu()));
180 184 } finally {
181 185 socket.close();
182 186 }
... ... @@ -295,18 +299,4 @@ public class IotModbusTcpSlaveModbusTcpIntegrationTest {
295 299 return buf.array();
296 300 }
297 301  
298   - /**
299   - * 字节数组转十六进制字符串
300   - */
301   - private static String bytesToHex(byte[] bytes) {
302   - if (bytes == null) {
303   - return "null";
304   - }
305   - StringBuilder sb = new StringBuilder();
306   - for (byte b : bytes) {
307   - sb.append(String.format("%02X ", b));
308   - }
309   - return sb.toString().trim();
310   - }
311   -
312 302 }
... ...