Commit 88f090b66f2c9cc2cb151dbfe9f3275b237df85c
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<Integer> { |
| 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<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
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
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
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
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 | } | ... | ... |