diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java index ee4680ea6d..7922eb1b24 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/api/device/IoTDeviceApiImpl.java @@ -1,31 +1,41 @@ package cn.iocoder.yudao.module.iot.api.device; +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.enums.RpcConstants; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; -import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; -import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; -import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; -import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.*; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO; import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusConfigDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusPointDO; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusConfigService; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; import jakarta.annotation.Resource; import jakarta.annotation.security.PermitAll; +import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Primary; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Set; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; /** * IoT 设备 API 实现类 @@ -41,6 +51,12 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { private IotDeviceService deviceService; @Resource private IotProductService productService; + @Resource + @Lazy // 延迟加载,解决循环依赖 + private IotDeviceModbusConfigService modbusConfigService; + @Resource + @Lazy // 延迟加载,解决循环依赖 + private IotDeviceModbusPointService modbusPointService; @Override @PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/device/auth") @@ -63,6 +79,52 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi { })); } + @Override + @PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/modbus/config-list") + @PermitAll + @TenantIgnore + public CommonResult> getModbusDeviceConfigList( + @RequestBody IotModbusDeviceConfigListReqDTO listReqDTO) { + // 1. 获取 Modbus 连接配置 + List configList = modbusConfigService.getDeviceModbusConfigList(listReqDTO); + if (CollUtil.isEmpty(configList)) { + return success(new ArrayList<>()); + } + + // 2. 组装返回结果 + Set deviceIds = convertSet(configList, IotDeviceModbusConfigDO::getDeviceId); + Map deviceMap = deviceService.getDeviceMap(deviceIds); + Map> pointMap = modbusPointService.getEnabledDeviceModbusPointMapByDeviceIds(deviceIds); + Map productMap = productService.getProductMap(convertSet(deviceMap.values(), IotDeviceDO::getProductId)); + List result = new ArrayList<>(configList.size()); + for (IotDeviceModbusConfigDO config : configList) { + // 3.1 获取设备信息 + IotDeviceDO device = deviceMap.get(config.getDeviceId()); + if (device == null) { + continue; + } + // 3.2 按 protocolType 筛选(如果非空) + if (StrUtil.isNotEmpty(listReqDTO.getProtocolType())) { + IotProductDO product = productMap.get(device.getProductId()); + if (product == null || ObjUtil.notEqual(listReqDTO.getProtocolType(), product.getProtocolType())) { + continue; + } + } + // 3.3 获取启用的点位列表 + List pointList = pointMap.get(config.getDeviceId()); + if (CollUtil.isEmpty(pointList)) { + continue; + } + + // 3.4 构建 IotModbusDeviceConfigRespDTO 对象 + IotModbusDeviceConfigRespDTO configDTO = BeanUtils.toBean(config, IotModbusDeviceConfigRespDTO.class, o -> + o.setProductKey(device.getProductKey()).setDeviceName(device.getDeviceName()) + .setPoints(BeanUtils.toBean(pointList, IotModbusPointRespDTO.class))); + result.add(configDTO); + } + return success(result); + } + @Override @PostMapping(RpcConstants.RPC_API_PREFIX + "/iot/device/register") @PermitAll diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceModbusConfigController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceModbusConfigController.java new file mode 100644 index 0000000000..576d904aca --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceModbusConfigController.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusConfigRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusConfigSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusConfigDO; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusConfigService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - IoT 设备 Modbus 连接配置") +@RestController +@RequestMapping("/iot/device-modbus-config") +@Validated +public class IotDeviceModbusConfigController { + + @Resource + private IotDeviceModbusConfigService modbusConfigService; + + @PostMapping("/save") + @Operation(summary = "保存设备 Modbus 连接配置") + @PreAuthorize("@ss.hasPermission('iot:device:update')") + public CommonResult saveDeviceModbusConfig(@Valid @RequestBody IotDeviceModbusConfigSaveReqVO saveReqVO) { + modbusConfigService.saveDeviceModbusConfig(saveReqVO); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得设备 Modbus 连接配置") + @Parameter(name = "id", description = "编号", example = "1024") + @Parameter(name = "deviceId", description = "设备编号", example = "2048") + @PreAuthorize("@ss.hasPermission('iot:device:query')") + public CommonResult getDeviceModbusConfig( + @RequestParam(value = "id", required = false) Long id, + @RequestParam(value = "deviceId", required = false) Long deviceId) { + IotDeviceModbusConfigDO modbusConfig = null; + if (id != null) { + modbusConfig = modbusConfigService.getDeviceModbusConfig(id); + } else if (deviceId != null) { + modbusConfig = modbusConfigService.getDeviceModbusConfigByDeviceId(deviceId); + } + return success(BeanUtils.toBean(modbusConfig, IotDeviceModbusConfigRespVO.class)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceModbusPointController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceModbusPointController.java new file mode 100644 index 0000000000..4e813d8bfd --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/IotDeviceModbusPointController.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusPointPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusPointRespVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusPointSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusPointDO; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - IoT 设备 Modbus 点位配置") +@RestController +@RequestMapping("/iot/device-modbus-point") +@Validated +public class IotDeviceModbusPointController { + + @Resource + private IotDeviceModbusPointService modbusPointService; + + @PostMapping("/create") + @Operation(summary = "创建设备 Modbus 点位配置") + @PreAuthorize("@ss.hasPermission('iot:device:update')") + public CommonResult createDeviceModbusPoint(@Valid @RequestBody IotDeviceModbusPointSaveReqVO createReqVO) { + return success(modbusPointService.createDeviceModbusPoint(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新设备 Modbus 点位配置") + @PreAuthorize("@ss.hasPermission('iot:device:update')") + public CommonResult updateDeviceModbusPoint(@Valid @RequestBody IotDeviceModbusPointSaveReqVO updateReqVO) { + modbusPointService.updateDeviceModbusPoint(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除设备 Modbus 点位配置") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('iot:device:update')") + public CommonResult deleteDeviceModbusPoint(@RequestParam("id") Long id) { + modbusPointService.deleteDeviceModbusPoint(id); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得设备 Modbus 点位配置") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('iot:device:query')") + public CommonResult getDeviceModbusPoint(@RequestParam("id") Long id) { + IotDeviceModbusPointDO modbusPoint = modbusPointService.getDeviceModbusPoint(id); + return success(BeanUtils.toBean(modbusPoint, IotDeviceModbusPointRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得设备 Modbus 点位配置分页") + @PreAuthorize("@ss.hasPermission('iot:device:query')") + public CommonResult> getDeviceModbusPointPage(@Valid IotDeviceModbusPointPageReqVO pageReqVO) { + PageResult pageResult = modbusPointService.getDeviceModbusPointPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, IotDeviceModbusPointRespVO.class)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java index e527242fb3..ddedb61354 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java @@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.iot.controller.admin.device.vo.device; import cn.iocoder.yudao.framework.common.pojo.PageParam; import cn.iocoder.yudao.framework.common.validation.InEnum; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java index e53f5acb60..f9e4b75290 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/message/IotDeviceMessageRespVO.java @@ -38,7 +38,7 @@ public class IotDeviceMessageRespVO { @Schema(description = "请求编号", example = "req_123") private String requestId; - @Schema(description = "请求方法", requiredMode = Schema.RequiredMode.REQUIRED, example = "thing.property.report") + @Schema(description = "请求方法", requiredMode = Schema.RequiredMode.REQUIRED, example = "thing.property.post") private String method; @Schema(description = "请求参数") diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusConfigRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusConfigRespVO.java new file mode 100644 index 0000000000..ecce04de6e --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusConfigRespVO.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IoT 设备 Modbus 连接配置 Response VO") +@Data +public class IotDeviceModbusConfigRespVO { + + @Schema(description = "主键", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long deviceId; + + @Schema(description = "设备名称", example = "温湿度传感器") + private String deviceName; + + @Schema(description = "Modbus 服务器 IP 地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "192.168.1.100") + private String ip; + + @Schema(description = "Modbus 端口", requiredMode = Schema.RequiredMode.REQUIRED, example = "502") + private Integer port; + + @Schema(description = "从站地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer slaveId; + + @Schema(description = "连接超时时间(毫秒)", example = "3000") + private Integer timeout; + + @Schema(description = "重试间隔(毫秒)", example = "1000") + private Integer retryInterval; + + @Schema(description = "工作模式", example = "1") + private Integer mode; + + @Schema(description = "数据帧格式", example = "1") + private Integer frameFormat; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusConfigSaveReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusConfigSaveReqVO.java new file mode 100644 index 0000000000..f7f26dd428 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusConfigSaveReqVO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusModeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - IoT 设备 Modbus 连接配置新增/修改 Request VO") +@Data +public class IotDeviceModbusConfigSaveReqVO { + + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "设备编号不能为空") + private Long deviceId; + + @Schema(description = "Modbus 服务器 IP 地址", example = "192.168.1.100") + private String ip; + + @Schema(description = "Modbus 端口", example = "502") + private Integer port; + + @Schema(description = "从站地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "从站地址不能为空") + private Integer slaveId; + + @Schema(description = "连接超时时间(毫秒)", example = "3000") + private Integer timeout; + + @Schema(description = "重试间隔(毫秒)", example = "1000") + private Integer retryInterval; + + @Schema(description = "工作模式", example = "1") + @InEnum(IotModbusModeEnum.class) + private Integer mode; + + @Schema(description = "数据帧格式", example = "1") + @InEnum(IotModbusFrameFormatEnum.class) + private Integer frameFormat; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + private Integer status; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusPointPageReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusPointPageReqVO.java new file mode 100644 index 0000000000..344e8ce121 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusPointPageReqVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - IoT 设备 Modbus 点位配置分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class IotDeviceModbusPointPageReqVO extends PageParam { + + @Schema(description = "设备编号", example = "1024") + private Long deviceId; + + @Schema(description = "属性标识符", example = "temperature") + private String identifier; + + @Schema(description = "属性名称", example = "温度") + private String name; + + @Schema(description = "Modbus 功能码", example = "3") + private Integer functionCode; + + @Schema(description = "状态", example = "0") + private Integer status; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusPointRespVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusPointRespVO.java new file mode 100644 index 0000000000..c590e3e3f3 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusPointRespVO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IoT 设备 Modbus 点位配置 Response VO") +@Data +public class IotDeviceModbusPointRespVO { + + @Schema(description = "主键", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long deviceId; + + @Schema(description = "物模型属性编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + private Long thingModelId; + + @Schema(description = "属性标识符", requiredMode = Schema.RequiredMode.REQUIRED, example = "temperature") + private String identifier; + + @Schema(description = "属性名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "温度") + private String name; + + @Schema(description = "Modbus 功能码", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer functionCode; + + @Schema(description = "寄存器起始地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer registerAddress; + + @Schema(description = "寄存器数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer registerCount; + + @Schema(description = "字节序", requiredMode = Schema.RequiredMode.REQUIRED, example = "AB") + private String byteOrder; + + @Schema(description = "原始数据类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "INT16") + private String rawDataType; + + @Schema(description = "缩放因子", requiredMode = Schema.RequiredMode.REQUIRED, example = "1.0") + private BigDecimal scale; + + @Schema(description = "轮询间隔(毫秒)", requiredMode = Schema.RequiredMode.REQUIRED, example = "5000") + private Integer pollInterval; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusPointSaveReqVO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusPointSaveReqVO.java new file mode 100644 index 0000000000..18aea2bf61 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/modbus/IotDeviceModbusPointSaveReqVO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; + +@Schema(description = "管理后台 - IoT 设备 Modbus 点位配置新增/修改 Request VO") +@Data +public class IotDeviceModbusPointSaveReqVO { + + @Schema(description = "主键", example = "1") + private Long id; + + @Schema(description = "设备编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "设备编号不能为空") + private Long deviceId; + + @Schema(description = "物模型属性编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048") + @NotNull(message = "物模型属性编号不能为空") + private Long thingModelId; + + @Schema(description = "Modbus 功能码", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + @NotNull(message = "Modbus 功能码不能为空") + private Integer functionCode; + + @Schema(description = "寄存器起始地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "寄存器起始地址不能为空") + private Integer registerAddress; + + @Schema(description = "寄存器数量", example = "1") + private Integer registerCount; + + @Schema(description = "字节序", requiredMode = Schema.RequiredMode.REQUIRED, example = "AB") + @NotEmpty(message = "字节序不能为空") + private String byteOrder; + + @Schema(description = "原始数据类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "INT16") + @NotEmpty(message = "原始数据类型不能为空") + private String rawDataType; + + @Schema(description = "缩放因子", example = "1.0") + private BigDecimal scale; + + @Schema(description = "轮询间隔(毫秒)", example = "5000") + private Integer pollInterval; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + private Integer status; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.http b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.http new file mode 100644 index 0000000000..e4e258985f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.http @@ -0,0 +1,5 @@ +### 请求 /iot/product/sync-property-table 接口 => 成功 +POST {{baseUrl}}/iot/product/sync-property-table +Content-Type: application/json +tenant-id: {{adminTenantId}} +Authorization: Bearer {{token}} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java index 043f48772b..130c45d6a7 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/product/IotProductController.java @@ -141,6 +141,14 @@ public class IotProductController { result.getData().getList()); } + @PostMapping("/sync-property-table") + @Operation(summary = "同步产品属性表结构到 TDengine") + @PreAuthorize("@ss.hasPermission('iot:product:update')") + public CommonResult syncProductPropertyTable() { + productService.syncProductPropertyTable(); + return success(true); + } + @GetMapping("/simple-list") @Operation(summary = "获取产品的精简信息列表", description = "主要用于前端的下拉选项") @Parameter(name = "deviceType", description = "设备类型", example = "1") diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/IotStatisticsController.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/IotStatisticsController.java index 22837c48ba..539ebbfc20 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/IotStatisticsController.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/statistics/IotStatisticsController.java @@ -5,7 +5,7 @@ import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageReqVO; import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsDeviceMessageSummaryByDateRespVO; import cn.iocoder.yudao.module.iot.controller.admin.statistics.vo.IotStatisticsSummaryRespVO; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService; import cn.iocoder.yudao.module.iot.service.product.IotProductCategoryService; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceDO.java index 7b7d021c3b..0499ac09f4 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceDO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceDO.java @@ -4,7 +4,7 @@ import cn.iocoder.yudao.framework.mybatis.core.type.LongSetTypeHandler; import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaFirmwareDO; import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; @@ -108,10 +108,6 @@ public class IotDeviceDO extends TenantBaseDO { */ private LocalDateTime activeTime; - /** - * 设备的 IP 地址 - */ - private String ip; /** * 固件编号 * diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java index 9f1f6a6a0c..233b2c1402 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceMessageDO.java @@ -84,7 +84,7 @@ public class IotDeviceMessageDO { * 请求方法 * * 枚举 {@link IotDeviceMessageMethodEnum} - * 例如说:thing.property.report 属性上报 + * 例如说:thing.property.post 属性上报 */ private String method; /** diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceModbusConfigDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceModbusConfigDO.java new file mode 100644 index 0000000000..26981506b4 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceModbusConfigDO.java @@ -0,0 +1,82 @@ +package cn.iocoder.yudao.module.iot.dal.dataobject.device; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusModeEnum; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * IoT 设备 Modbus 连接配置 DO + * + * @author 芋道源码 + */ +@TableName("iot_device_modbus_config") +@KeySequence("iot_device_modbus_config_seq") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IotDeviceModbusConfigDO extends TenantBaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + /** + * 产品编号 + * + * 关联 {@link IotProductDO#getId()} + */ + private Long productId; + /** + * 设备编号 + * + * 关联 {@link IotDeviceDO#getId()} + */ + private Long deviceId; + + /** + * Modbus 服务器 IP 地址 + */ + private String ip; + /** + * Modbus 服务器端口 + */ + private Integer port; + /** + * 从站地址 + */ + private Integer slaveId; + /** + * 连接超时时间,单位:毫秒 + */ + private Integer timeout; + /** + * 重试间隔,单位:毫秒 + */ + private Integer retryInterval; + /** + * 模式 + * + * @see IotModbusModeEnum + */ + private Integer mode; + /** + * 数据帧格式 + * + * @see IotModbusFrameFormatEnum + */ + private Integer frameFormat; + /** + * 状态 + * + * 枚举 {@link cn.iocoder.yudao.framework.common.enums.CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceModbusPointDO.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceModbusPointDO.java new file mode 100644 index 0000000000..cfc084166a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/device/IotDeviceModbusPointDO.java @@ -0,0 +1,100 @@ +package cn.iocoder.yudao.module.iot.dal.dataobject.device; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusByteOrderEnum; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusRawDataTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.math.BigDecimal; + +/** + * IoT 设备 Modbus 点位配置 DO + * + * @author 芋道源码 + */ +@TableName("iot_device_modbus_point") +@KeySequence("iot_device_modbus_point_seq") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class IotDeviceModbusPointDO extends TenantBaseDO { + + /** + * 主键 + */ + @TableId + private Long id; + /** + * 设备编号 + * + * 关联 {@link IotDeviceDO#getId()} + */ + private Long deviceId; + /** + * 物模型属性编号 + * + * 关联 {@link IotThingModelDO#getId()} + */ + private Long thingModelId; + /** + * 属性标识符 + * + * 冗余 {@link IotThingModelDO#getIdentifier()} + */ + private String identifier; + /** + * 属性名称 + * + * 冗余 {@link IotThingModelDO#getName()} + */ + private String name; + + // ========== Modbus 协议配置 ========== + + /** + * Modbus 功能码 + * + * 取值范围:FC01-04(读线圈、读离散输入、读保持寄存器、读输入寄存器) + */ + private Integer functionCode; + /** + * 寄存器起始地址 + */ + private Integer registerAddress; + /** + * 寄存器数量 + */ + private Integer registerCount; + /** + * 字节序 + * + * 枚举 {@link IotModbusByteOrderEnum} + */ + private String byteOrder; + /** + * 原始数据类型 + * + * 枚举 {@link IotModbusRawDataTypeEnum} + */ + private String rawDataType; + /** + * 缩放因子 + */ + private BigDecimal scale; + /** + * 轮询间隔(毫秒) + */ + private Integer pollInterval; + /** + * 状态 + * + * 枚举 {@link cn.iocoder.yudao.framework.common.enums.CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceModbusConfigMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceModbusConfigMapper.java new file mode 100644 index 0000000000..b18769c6a6 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceModbusConfigMapper.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.iot.dal.mysql.device; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusConfigDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IoT 设备 Modbus 连接配置 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface IotDeviceModbusConfigMapper extends BaseMapperX { + + default IotDeviceModbusConfigDO selectByDeviceId(Long deviceId) { + return selectOne(IotDeviceModbusConfigDO::getDeviceId, deviceId); + } + + default List selectList(IotModbusDeviceConfigListReqDTO reqDTO) { + return selectList(new LambdaQueryWrapperX() + .eqIfPresent(IotDeviceModbusConfigDO::getStatus, reqDTO.getStatus()) + .eqIfPresent(IotDeviceModbusConfigDO::getMode, reqDTO.getMode()) + .inIfPresent(IotDeviceModbusConfigDO::getDeviceId, reqDTO.getDeviceIds())); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceModbusPointMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceModbusPointMapper.java new file mode 100644 index 0000000000..7c9b5d3bae --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceModbusPointMapper.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.iot.dal.mysql.device; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusPointPageReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusPointDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * IoT 设备 Modbus 点位配置 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface IotDeviceModbusPointMapper extends BaseMapperX { + + default PageResult selectPage(IotDeviceModbusPointPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(IotDeviceModbusPointDO::getDeviceId, reqVO.getDeviceId()) + .likeIfPresent(IotDeviceModbusPointDO::getIdentifier, reqVO.getIdentifier()) + .likeIfPresent(IotDeviceModbusPointDO::getName, reqVO.getName()) + .eqIfPresent(IotDeviceModbusPointDO::getFunctionCode, reqVO.getFunctionCode()) + .eqIfPresent(IotDeviceModbusPointDO::getStatus, reqVO.getStatus()) + .orderByDesc(IotDeviceModbusPointDO::getId)); + } + + default List selectListByDeviceIdsAndStatus(Collection deviceIds, Integer status) { + return selectList(new LambdaQueryWrapperX() + .in(IotDeviceModbusPointDO::getDeviceId, deviceIds) + .eq(IotDeviceModbusPointDO::getStatus, status)); + } + + default IotDeviceModbusPointDO selectByDeviceIdAndIdentifier(Long deviceId, String identifier) { + return selectOne(IotDeviceModbusPointDO::getDeviceId, deviceId, + IotDeviceModbusPointDO::getIdentifier, identifier); + } + + default void updateByThingModelId(Long thingModelId, IotDeviceModbusPointDO updateObj) { + update(updateObj, new LambdaQueryWrapperX() + .eq(IotDeviceModbusPointDO::getThingModelId, thingModelId)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/product/IotProductMapper.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/product/IotProductMapper.java index 2ed27dbb67..8c611d0d46 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/product/IotProductMapper.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/product/IotProductMapper.java @@ -38,6 +38,10 @@ public interface IotProductMapper extends BaseMapperX { .apply("LOWER(product_key) = {0}", productKey.toLowerCase())); } + default List selectListByStatus(Integer status) { + return selectList(IotProductDO::getStatus, status); + } + default Long selectCountByCreateTime(@Nullable LocalDateTime createTime) { return selectCount(new LambdaQueryWrapperX() .geIfPresent(IotProductDO::getCreateTime, createTime)); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java index 3679dbf1ce..065eb2d229 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java @@ -54,6 +54,14 @@ public interface ErrorCodeConstants { ErrorCode DEVICE_GROUP_NOT_EXISTS = new ErrorCode(1_050_005_000, "设备分组不存在"); ErrorCode DEVICE_GROUP_DELETE_FAIL_DEVICE_EXISTS = new ErrorCode(1_050_005_001, "设备分组下存在设备,不允许删除"); + // ========== 设备 Modbus 配置 1-050-006-000 ========== + ErrorCode DEVICE_MODBUS_CONFIG_NOT_EXISTS = new ErrorCode(1_050_006_000, "设备 Modbus 连接配置不存在"); + ErrorCode DEVICE_MODBUS_CONFIG_EXISTS = new ErrorCode(1_050_006_001, "设备 Modbus 连接配置已存在"); + + // ========== 设备 Modbus 点位 1-050-007-000 ========== + ErrorCode DEVICE_MODBUS_POINT_NOT_EXISTS = new ErrorCode(1_050_007_000, "设备 Modbus 点位配置不存在"); + ErrorCode DEVICE_MODBUS_POINT_EXISTS = new ErrorCode(1_050_007_001, "设备 Modbus 点位配置已存在"); + // ========== OTA 固件相关 1-050-008-000 ========== ErrorCode OTA_FIRMWARE_NOT_EXISTS = new ErrorCode(1_050_008_000, "固件信息不存在"); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java index 440fab5f53..96b477d69d 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java +++ b/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 { TCP(2, "TCP"), WEBSOCKET(3, "WebSocket"), - MQTT(10, "MQTT"), // TODO 待实现; + MQTT(10, "MQTT"), // TODO @puhui999:待实现; - DATABASE(20, "Database"), // TODO @puhui999:待实现;可以简单点,对应的表名是什么,字段先固定了。 + DATABASE(20, "Database"), // TODO @puhui999:待实现; REDIS(21, "Redis"), ROCKETMQ(30, "RocketMQ"), diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJob.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJob.java index 6bd27a679a..789b2f25ad 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJob.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/device/IotDeviceOfflineCheckJob.java @@ -4,7 +4,7 @@ import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.framework.iot.config.YudaoIotProperties; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/ota/IotOtaUpgradeJob.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/ota/IotOtaUpgradeJob.java index 8a15c5e7bb..9aa2312117 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/ota/IotOtaUpgradeJob.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/job/ota/IotOtaUpgradeJob.java @@ -4,7 +4,7 @@ import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler; import cn.iocoder.yudao.framework.tenant.core.job.TenantJob; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaFirmwareDO; import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskRecordDO; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java index 7e039d0327..31c507889b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/device/IotDeviceMessageSubscriber.java @@ -3,7 +3,7 @@ package cn.iocoder.yudao.module.iot.mq.consumer.device; import cn.hutool.core.util.ObjectUtil; import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; @@ -67,7 +67,6 @@ public class IotDeviceMessageSubscriber implements IotMessageSubscriber { +public class IotDataRuleMessageSubscriber implements IotMessageSubscriber { @Resource private IotDataRuleService dataRuleService; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageHandler.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageSubscriber.java similarity index 90% rename from yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageHandler.java rename to yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageSubscriber.java index 19e1f18ba3..de74bebcce 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageHandler.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mq/consumer/rule/IotSceneRuleMessageSubscriber.java @@ -9,7 +9,6 @@ import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -// TODO @puhui999:后面重构哈 /** * 针对 {@link IotDeviceMessage} 的消费者,处理规则场景 * @@ -17,7 +16,7 @@ import org.springframework.stereotype.Component; */ @Component @Slf4j -public class IotSceneRuleMessageHandler implements IotMessageSubscriber { +public class IotSceneRuleMessageSubscriber implements IotMessageSubscriber { @Resource private IotSceneRuleService sceneRuleService; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusConfigService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusConfigService.java new file mode 100644 index 0000000000..2d9ef7ec61 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusConfigService.java @@ -0,0 +1,48 @@ +package cn.iocoder.yudao.module.iot.service.device; + +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusConfigSaveReqVO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusConfigDO; +import jakarta.validation.Valid; + +import java.util.List; + +/** + * IoT 设备 Modbus 连接配置 Service 接口 + * + * @author 芋道源码 + */ +public interface IotDeviceModbusConfigService { + + /** + * 保存设备 Modbus 连接配置(新增或更新) + * + * @param saveReqVO 保存信息 + */ + void saveDeviceModbusConfig(@Valid IotDeviceModbusConfigSaveReqVO saveReqVO); + + /** + * 获得设备 Modbus 连接配置 + * + * @param id 编号 + * @return 设备 Modbus 连接配置 + */ + IotDeviceModbusConfigDO getDeviceModbusConfig(Long id); + + /** + * 根据设备编号获得 Modbus 连接配置 + * + * @param deviceId 设备编号 + * @return 设备 Modbus 连接配置 + */ + IotDeviceModbusConfigDO getDeviceModbusConfigByDeviceId(Long deviceId); + + /** + * 获得 Modbus 连接配置列表 + * + * @param listReqDTO 查询参数 + * @return Modbus 连接配置列表 + */ + List getDeviceModbusConfigList(IotModbusDeviceConfigListReqDTO listReqDTO); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusConfigServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusConfigServiceImpl.java new file mode 100644 index 0000000000..2a8ae4e439 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusConfigServiceImpl.java @@ -0,0 +1,89 @@ +package cn.iocoder.yudao.module.iot.service.device; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusConfigSaveReqVO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusConfigDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO; +import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceModbusConfigMapper; +import cn.iocoder.yudao.module.iot.service.product.IotProductService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +/** + * IoT 设备 Modbus 连接配置 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class IotDeviceModbusConfigServiceImpl implements IotDeviceModbusConfigService { + + @Resource + private IotDeviceModbusConfigMapper modbusConfigMapper; + + @Resource + private IotDeviceService deviceService; + @Resource + private IotProductService productService; + + @Override + public void saveDeviceModbusConfig(IotDeviceModbusConfigSaveReqVO saveReqVO) { + // 1.1 校验设备存在 + IotDeviceDO device = deviceService.validateDeviceExists(saveReqVO.getDeviceId()); + // 1.2 根据产品 protocolType 条件校验 + IotProductDO product = productService.getProduct(device.getProductId()); + Assert.notNull(product, "产品不存在"); + validateModbusConfigByProtocolType(saveReqVO, product.getProtocolType()); + + // 2. 根据数据库中是否已有配置,决定是新增还是更新 + IotDeviceModbusConfigDO existConfig = modbusConfigMapper.selectByDeviceId(saveReqVO.getDeviceId()); + if (existConfig == null) { + IotDeviceModbusConfigDO modbusConfig = BeanUtils.toBean(saveReqVO, IotDeviceModbusConfigDO.class); + modbusConfigMapper.insert(modbusConfig); + } else { + IotDeviceModbusConfigDO updateObj = BeanUtils.toBean(saveReqVO, IotDeviceModbusConfigDO.class, + o -> o.setId(existConfig.getId())); + modbusConfigMapper.updateById(updateObj); + } + } + + @Override + public IotDeviceModbusConfigDO getDeviceModbusConfig(Long id) { + return modbusConfigMapper.selectById(id); + } + + @Override + public IotDeviceModbusConfigDO getDeviceModbusConfigByDeviceId(Long deviceId) { + return modbusConfigMapper.selectByDeviceId(deviceId); + } + + @Override + public List getDeviceModbusConfigList(IotModbusDeviceConfigListReqDTO listReqDTO) { + return modbusConfigMapper.selectList(listReqDTO); + } + + private void validateModbusConfigByProtocolType(IotDeviceModbusConfigSaveReqVO saveReqVO, String protocolType) { + IotProtocolTypeEnum protocolTypeEnum = IotProtocolTypeEnum.of(protocolType); + if (protocolTypeEnum == null) { + return; + } + if (protocolTypeEnum == IotProtocolTypeEnum.MODBUS_TCP_CLIENT) { + Assert.isTrue(StrUtil.isNotEmpty(saveReqVO.getIp()), "Client 模式下,IP 地址不能为空"); + Assert.notNull(saveReqVO.getPort(), "Client 模式下,端口不能为空"); + Assert.notNull(saveReqVO.getTimeout(), "Client 模式下,连接超时时间不能为空"); + Assert.notNull(saveReqVO.getRetryInterval(), "Client 模式下,重试间隔不能为空"); + } else if (protocolTypeEnum == IotProtocolTypeEnum.MODBUS_TCP_SERVER) { + Assert.notNull(saveReqVO.getMode(), "Server 模式下,工作模式不能为空"); + Assert.notNull(saveReqVO.getFrameFormat(), "Server 模式下,数据帧格式不能为空"); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusPointService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusPointService.java new file mode 100644 index 0000000000..0be20e1057 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusPointService.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.iot.service.device; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusPointPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusPointSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusPointDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * IoT 设备 Modbus 点位配置 Service 接口 + * + * @author 芋道源码 + */ +public interface IotDeviceModbusPointService { + + /** + * 创建设备 Modbus 点位配置 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createDeviceModbusPoint(@Valid IotDeviceModbusPointSaveReqVO createReqVO); + + /** + * 更新设备 Modbus 点位配置 + * + * @param updateReqVO 更新信息 + */ + void updateDeviceModbusPoint(@Valid IotDeviceModbusPointSaveReqVO updateReqVO); + + /** + * 删除设备 Modbus 点位配置 + * + * @param id 编号 + */ + void deleteDeviceModbusPoint(Long id); + + /** + * 获得设备 Modbus 点位配置 + * + * @param id 编号 + * @return 设备 Modbus 点位配置 + */ + IotDeviceModbusPointDO getDeviceModbusPoint(Long id); + + /** + * 获得设备 Modbus 点位配置分页 + * + * @param pageReqVO 分页查询 + * @return 设备 Modbus 点位配置分页 + */ + PageResult getDeviceModbusPointPage(IotDeviceModbusPointPageReqVO pageReqVO); + + /** + * 物模型变更时,更新关联点位的冗余字段(identifier、name) + * + * @param thingModelId 物模型编号 + * @param identifier 物模型标识符 + * @param name 物模型名称 + */ + void updateDeviceModbusPointByThingModel(Long thingModelId, String identifier, String name); + + /** + * 根据设备编号批量获得启用的点位配置 Map + * + * @param deviceIds 设备编号集合 + * @return 设备点位 Map,key 为设备编号,value 为点位配置列表 + */ + Map> getEnabledDeviceModbusPointMapByDeviceIds(Collection deviceIds); + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusPointServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusPointServiceImpl.java new file mode 100644 index 0000000000..7683aa7ecc --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceModbusPointServiceImpl.java @@ -0,0 +1,135 @@ +package cn.iocoder.yudao.module.iot.service.device; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusPointPageReqVO; +import cn.iocoder.yudao.module.iot.controller.admin.device.vo.modbus.IotDeviceModbusPointSaveReqVO; +import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceModbusPointDO; +import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; +import cn.iocoder.yudao.module.iot.dal.mysql.device.IotDeviceModbusPointMapper; +import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; + +/** + * IoT 设备 Modbus 点位配置 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class IotDeviceModbusPointServiceImpl implements IotDeviceModbusPointService { + + @Resource + private IotDeviceModbusPointMapper modbusPointMapper; + + @Resource + private IotDeviceService deviceService; + + @Resource + private IotThingModelService thingModelService; + + @Override + public Long createDeviceModbusPoint(IotDeviceModbusPointSaveReqVO createReqVO) { + // 1.1 校验设备存在 + deviceService.validateDeviceExists(createReqVO.getDeviceId()); + // 1.2 校验物模型属性存在 + IotThingModelDO thingModel = validateThingModelExists(createReqVO.getThingModelId()); + // 1.3 校验同一设备下点位唯一性(基于 identifier) + validateDeviceModbusPointUnique(createReqVO.getDeviceId(), thingModel.getIdentifier(), null); + + // 2. 插入 + IotDeviceModbusPointDO modbusPoint = BeanUtils.toBean(createReqVO, IotDeviceModbusPointDO.class, + o -> o.setIdentifier(thingModel.getIdentifier()).setName(thingModel.getName())); + modbusPointMapper.insert(modbusPoint); + return modbusPoint.getId(); + } + + @Override + public void updateDeviceModbusPoint(IotDeviceModbusPointSaveReqVO updateReqVO) { + // 1.1 校验存在 + validateDeviceModbusPointExists(updateReqVO.getId()); + // 1.2 校验设备存在 + deviceService.validateDeviceExists(updateReqVO.getDeviceId()); + // 1.3 校验物模型属性存在 + IotThingModelDO thingModel = validateThingModelExists(updateReqVO.getThingModelId()); + // 1.4 校验同一设备下点位唯一性 + validateDeviceModbusPointUnique(updateReqVO.getDeviceId(), thingModel.getIdentifier(), updateReqVO.getId()); + + // 2. 更新 + IotDeviceModbusPointDO updateObj = BeanUtils.toBean(updateReqVO, IotDeviceModbusPointDO.class, + o -> o.setIdentifier(thingModel.getIdentifier()).setName(thingModel.getName())); + modbusPointMapper.updateById(updateObj); + } + + @Override + public void updateDeviceModbusPointByThingModel(Long thingModelId, String identifier, String name) { + IotDeviceModbusPointDO updateObj = new IotDeviceModbusPointDO() + .setIdentifier(identifier).setName(name); + modbusPointMapper.updateByThingModelId(thingModelId, updateObj); + } + + private IotThingModelDO validateThingModelExists(Long id) { + IotThingModelDO thingModel = thingModelService.getThingModel(id); + if (thingModel == null) { + throw exception(THING_MODEL_NOT_EXISTS); + } + return thingModel; + } + + @Override + public void deleteDeviceModbusPoint(Long id) { + // 校验存在 + validateDeviceModbusPointExists(id); + // 删除 + modbusPointMapper.deleteById(id); + } + + private void validateDeviceModbusPointExists(Long id) { + IotDeviceModbusPointDO point = modbusPointMapper.selectById(id); + if (point == null) { + throw exception(DEVICE_MODBUS_POINT_NOT_EXISTS); + } + } + + private void validateDeviceModbusPointUnique(Long deviceId, String identifier, Long excludeId) { + IotDeviceModbusPointDO point = modbusPointMapper.selectByDeviceIdAndIdentifier(deviceId, identifier); + if (point != null && ObjUtil.notEqual(point.getId(), excludeId)) { + throw exception(DEVICE_MODBUS_POINT_EXISTS); + } + } + + @Override + public IotDeviceModbusPointDO getDeviceModbusPoint(Long id) { + return modbusPointMapper.selectById(id); + } + + @Override + public PageResult getDeviceModbusPointPage(IotDeviceModbusPointPageReqVO pageReqVO) { + return modbusPointMapper.selectPage(pageReqVO); + } + + @Override + public Map> getEnabledDeviceModbusPointMapByDeviceIds(Collection deviceIds) { + if (CollUtil.isEmpty(deviceIds)) { + return Collections.emptyMap(); + } + List pointList = modbusPointMapper.selectListByDeviceIdsAndStatus( + deviceIds, CommonStatusEnum.ENABLE.getStatus()); + return convertMultiMap(pointList, IotDeviceModbusPointDO::getDeviceId); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceService.java index 5a622e5654..74339af6df 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceService.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceService.java @@ -4,7 +4,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.*; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java index 148dd071e5..f05776f4cb 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java @@ -17,7 +17,7 @@ import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.*; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskRecordServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskRecordServiceImpl.java index eb75b91540..f9cd776210 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskRecordServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/ota/IotOtaTaskRecordServiceImpl.java @@ -3,11 +3,15 @@ package cn.iocoder.yudao.module.iot.service.ota; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.convert.Convert; import cn.hutool.core.lang.Assert; -import cn.hutool.core.map.MapUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.iot.controller.admin.ota.vo.task.record.IotOtaTaskRecordPageReqVO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.topic.ota.IotDeviceOtaProgressReqDTO; +import cn.iocoder.yudao.module.iot.core.topic.ota.IotDeviceOtaUpgradeReqDTO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaFirmwareDO; import cn.iocoder.yudao.module.iot.dal.dataobject.ota.IotOtaTaskRecordDO; @@ -133,9 +137,9 @@ public class IotOtaTaskRecordServiceImpl implements IotOtaTaskRecordService { public boolean pushOtaTaskRecord(IotOtaTaskRecordDO record, IotOtaFirmwareDO fireware, IotDeviceDO device) { try { // 1. 推送 OTA 任务记录 - IotDeviceMessage message = IotDeviceMessage.buildOtaUpgrade( - fireware.getVersion(), fireware.getFileUrl(), fireware.getFileSize(), - fireware.getFileDigestAlgorithm(), fireware.getFileDigestValue()); + IotDeviceOtaUpgradeReqDTO params = BeanUtils.toBean(fireware, IotDeviceOtaUpgradeReqDTO.class); + IotDeviceMessage message = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.OTA_UPGRADE.getMethod(), params); deviceMessageService.sendDeviceMessage(message, device); // 2. 更新 OTA 升级记录状态为进行中 @@ -163,17 +167,16 @@ public class IotOtaTaskRecordServiceImpl implements IotOtaTaskRecordService { @Override @Transactional(rollbackFor = Exception.class) - @SuppressWarnings("unchecked") public void updateOtaRecordProgress(IotDeviceDO device, IotDeviceMessage message) { // 1.1 参数解析 - Map params = (Map) message.getParams(); - String version = MapUtil.getStr(params, "version"); + IotDeviceOtaProgressReqDTO params = JsonUtils.convertObject(message.getParams(), IotDeviceOtaProgressReqDTO.class); + String version = params.getVersion(); Assert.notBlank(version, "version 不能为空"); - Integer status = MapUtil.getInt(params, "status"); + Integer status = params.getStatus(); Assert.notNull(status, "status 不能为空"); Assert.notNull(IotOtaTaskRecordStatusEnum.of(status), "status 状态不正确"); - String description = MapUtil.getStr(params, "description"); - Integer progress = MapUtil.getInt(params, "progress"); + String description = params.getDescription(); + Integer progress = params.getProgress(); Assert.notNull(progress, "progress 不能为空"); Assert.isTrue(progress >= 0 && progress <= 100, "progress 必须在 0-100 之间"); // 1.2 查询 OTA 升级记录 diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java index d4292ef521..f31961cfd1 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductService.java @@ -10,6 +10,9 @@ import javax.annotation.Nullable; import java.time.LocalDateTime; import java.util.Collection; import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; /** * IoT 产品 Service 接口 @@ -121,6 +124,24 @@ public interface IotProductService { */ Long getProductCount(@Nullable LocalDateTime createTime); + /** + * 批量获得产品列表 + * + * @param ids 产品编号集合 + * @return 产品列表 + */ + List getProductList(Collection ids); + + /** + * 批量获得产品 Map + * + * @param ids 产品编号集合 + * @return 产品 Map(key: 产品编号, value: 产品) + */ + default Map getProductMap(Collection ids) { + return convertMap(getProductList(ids), IotProductDO::getId); + } + /** * 批量校验产品存在 * @@ -128,4 +149,11 @@ public interface IotProductService { */ void validateProductsExist(Collection ids); + /** + * 同步产品的 TDengine 表结构 + * + * 目的:当 MySQL 和 TDengine 不同步时,强制将已发布产品的表结构同步到 TDengine 中 + */ + void syncProductPropertyTable(); + } \ No newline at end of file diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java index e001f46a2b..4c8a789b93 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/product/IotProductServiceImpl.java @@ -15,8 +15,10 @@ import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; import com.baomidou.dynamic.datasource.annotation.DSTransactional; import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -33,6 +35,7 @@ import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.*; * * @author ahh */ +@Slf4j @Service @Validated public class IotProductServiceImpl implements IotProductService { @@ -40,10 +43,11 @@ public class IotProductServiceImpl implements IotProductService { @Resource private IotProductMapper productMapper; - @Resource - private IotDevicePropertyService devicePropertyDataService; @Resource private IotDeviceService deviceService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private IotDevicePropertyService devicePropertyDataService; @Override public Long createProduct(IotProductSaveReqVO createReqVO) { @@ -171,6 +175,32 @@ public class IotProductServiceImpl implements IotProductService { return productMapper.selectCountByCreateTime(createTime); } + @Override + public List getProductList(Collection ids) { + return productMapper.selectByIds(ids); + } + + @Override + public void syncProductPropertyTable() { + // 1. 获取所有已发布的产品 + List products = productMapper.selectListByStatus( + IotProductStatusEnum.PUBLISHED.getStatus()); + log.info("[syncProductPropertyTable][开始同步,已发布产品数量({})]", products.size()); + + // 2. 遍历同步 TDengine 表结构(创建产品超级表数据模型) + int successCount = 0; + for (IotProductDO product : products) { + try { + devicePropertyDataService.defineDevicePropertyData(product.getId()); + successCount++; + log.info("[syncProductPropertyTable][产品({}/{}) 同步成功]", product.getId(), product.getName()); + } catch (Exception e) { + log.error("[syncProductPropertyTable][产品({}/{}) 同步失败]", product.getId(), product.getName(), e); + } + } + log.info("[syncProductPropertyTable][同步完成,成功({}/{})个]", successCount, products.size()); + } + @Override public void validateProductsExist(Collection ids) { if (CollUtil.isEmpty(ids)) { diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDataRuleCacheableAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDataRuleCacheableAction.java index 4319469082..cc282e1b8d 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDataRuleCacheableAction.java +++ b/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; // TODO @芋艿:数据库 // TODO @芋艿:mqtt -// TODO @芋艿:tcp -// TODO @芋艿:websocket /** * 可缓存的 {@link IotDataRuleAction} 抽象实现 diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceControlSceneRuleAction.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDevicePropertySetSceneRuleAction.java similarity index 98% rename from yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceControlSceneRuleAction.java rename to yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDevicePropertySetSceneRuleAction.java index 79da298442..746e18d923 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDeviceControlSceneRuleAction.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/action/IotDevicePropertySetSceneRuleAction.java @@ -15,7 +15,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.util.List; -import java.util.Map; /** * IoT 设备属性设置的 {@link IotSceneRuleAction} 实现类 @@ -24,7 +23,7 @@ import java.util.Map; */ @Component @Slf4j -public class IotDeviceControlSceneRuleAction implements IotSceneRuleAction { +public class IotDevicePropertySetSceneRuleAction implements IotSceneRuleAction { @Resource private IotDeviceService deviceService; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImpl.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImpl.java index ca04ecd5f3..4a8b97475b 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImpl.java +++ b/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; import cn.iocoder.yudao.module.iot.dal.mysql.thingmodel.IotThingModelMapper; import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants; import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum; +import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService; import cn.iocoder.yudao.module.iot.service.product.IotProductService; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -50,6 +51,9 @@ public class IotThingModelServiceImpl implements IotThingModelService { @Resource @Lazy // 延迟加载,解决循环依赖 private IotProductService productService; + @Resource + @Lazy // 延迟加载,解决循环依赖 + private IotDeviceModbusPointService deviceModbusPointService; @Override @Transactional(rollbackFor = Exception.class) @@ -84,7 +88,11 @@ public class IotThingModelServiceImpl implements IotThingModelService { IotThingModelDO thingModel = IotThingModelConvert.INSTANCE.convert(updateReqVO); thingModelMapper.updateById(thingModel); - // 3. 删除缓存 + // 3. 同步更新 Modbus 点位的冗余字段(identifier、name) + deviceModbusPointService.updateDeviceModbusPointByThingModel( + updateReqVO.getId(), updateReqVO.getIdentifier(), updateReqVO.getName()); + + // 4. 删除缓存 deleteThingModelListCache(updateReqVO.getProductId()); } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java index 7fcae15713..7f4ec70d6a 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleTimerConditionIntegrationTest.java @@ -3,7 +3,7 @@ package cn.iocoder.yudao.module.iot.service.rule.scene; import cn.hutool.core.collection.ListUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDeviceStateConditionMatcherTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDeviceStateConditionMatcherTest.java index 6e7caecdd3..b83e0b0892 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDeviceStateConditionMatcherTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/condition/IotDeviceStateConditionMatcherTest.java @@ -1,6 +1,6 @@ package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.condition; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; diff --git a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceStateUpdateTriggerMatcherTest.java b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceStateUpdateTriggerMatcherTest.java index f41b0ec590..79511aaa9c 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceStateUpdateTriggerMatcherTest.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDeviceStateUpdateTriggerMatcherTest.java @@ -1,7 +1,7 @@ package cn.iocoder.yudao.module.iot.service.rule.scene.matcher.trigger; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO; import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum; diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java index cc0cb071a1..c0b3f9df31 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/IotDeviceCommonApi.java @@ -1,10 +1,7 @@ package cn.iocoder.yudao.module.iot.core.biz; import cn.iocoder.yudao.framework.common.pojo.CommonResult; -import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; -import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; -import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; -import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.*; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO; import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO; @@ -50,4 +47,12 @@ public interface IotDeviceCommonApi { */ CommonResult> registerSubDevices(IotSubDeviceRegisterFullReqDTO reqDTO); + /** + * 获取 Modbus 设备配置列表 + * + * @param listReqDTO 查询参数 + * @return Modbus 设备配置列表 + */ + CommonResult> getModbusDeviceConfigList(IotModbusDeviceConfigListReqDTO listReqDTO); + } diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotModbusDeviceConfigListReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotModbusDeviceConfigListReqDTO.java new file mode 100644 index 0000000000..7865a09f00 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotModbusDeviceConfigListReqDTO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.iot.core.biz.dto; + +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.Set; + +/** + * IoT Modbus 设备配置列表查询 Request DTO + * + * @author 芋道源码 + */ +@Data +@Accessors(chain = true) +public class IotModbusDeviceConfigListReqDTO { + + /** + * 状态 + */ + private Integer status; + + /** + * 模式 + */ + private Integer mode; + + /** + * 协议类型 + */ + private String protocolType; + + /** + * 设备 ID 集合 + */ + private Set deviceIds; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotModbusDeviceConfigRespDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotModbusDeviceConfigRespDTO.java new file mode 100644 index 0000000000..683bcef4c4 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotModbusDeviceConfigRespDTO.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.iot.core.biz.dto; + +import lombok.Data; + +import java.util.List; + +/** + * IoT Modbus 设备配置 Response DTO + * + * @author 芋道源码 + */ +@Data +public class IotModbusDeviceConfigRespDTO { + + /** + * 设备编号 + */ + private Long deviceId; + /** + * 产品标识 + */ + private String productKey; + /** + * 设备名称 + */ + private String deviceName; + + // ========== Modbus 连接配置 ========== + + /** + * Modbus 服务器 IP 地址 + */ + private String ip; + /** + * Modbus 服务器端口 + */ + private Integer port; + /** + * 从站地址 + */ + private Integer slaveId; + /** + * 连接超时时间,单位:毫秒 + */ + private Integer timeout; + /** + * 重试间隔,单位:毫秒 + */ + private Integer retryInterval; + /** + * 模式 + */ + private Integer mode; + /** + * 数据帧格式 + */ + private Integer frameFormat; + + // ========== Modbus 点位配置 ========== + + /** + * 点位列表 + */ + private List points; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotModbusPointRespDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotModbusPointRespDTO.java new file mode 100644 index 0000000000..dd6f9cf370 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/biz/dto/IotModbusPointRespDTO.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.iot.core.biz.dto; + +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusByteOrderEnum; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusRawDataTypeEnum; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * IoT Modbus 点位配置 Response DTO + * + * @author 芋道源码 + */ +@Data +public class IotModbusPointRespDTO { + + /** + * 点位编号 + */ + private Long id; + /** + * 属性标识符(物模型的 identifier) + */ + private String identifier; + /** + * 属性名称(物模型的 name) + */ + private String name; + + // ========== Modbus 协议配置 ========== + + /** + * Modbus 功能码 + * + * 取值范围:FC01-04(读线圈、读离散输入、读保持寄存器、读输入寄存器) + */ + private Integer functionCode; + /** + * 寄存器起始地址 + */ + private Integer registerAddress; + /** + * 寄存器数量 + */ + private Integer registerCount; + /** + * 字节序 + * + * 枚举 {@link IotModbusByteOrderEnum} + */ + private String byteOrder; + /** + * 原始数据类型 + * + * 枚举 {@link IotModbusRawDataTypeEnum} + */ + private String rawDataType; + /** + * 缩放因子 + */ + private BigDecimal scale; + /** + * 轮询间隔(毫秒) + */ + private Integer pollInterval; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java index d980032842..3b4495e333 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceMessageMethodEnum.java @@ -64,7 +64,7 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable { // ========== OTA 固件 ========== // 可参考:https://help.aliyun.com/zh/iot/user-guide/perform-ota-updates - OTA_UPGRADE("thing.ota.upgrade", "OTA 固定信息推送", false), + OTA_UPGRADE("thing.ota.upgrade", "OTA 固件信息推送", false), OTA_PROGRESS("thing.ota.progress", "OTA 升级进度上报", true), ; diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java index 5fbd713a8d..753605426b 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotProtocolTypeEnum.java @@ -25,7 +25,8 @@ public enum IotProtocolTypeEnum implements ArrayValuable { MQTT("mqtt"), EMQX("emqx"), COAP("coap"), - MODBUS_TCP("modbus_tcp"); + MODBUS_TCP_CLIENT("modbus_tcp_client"), + MODBUS_TCP_SERVER("modbus_tcp_server"); public static final String[] ARRAYS = Arrays.stream(values()).map(IotProtocolTypeEnum::getType).toArray(String[]::new); diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceStateEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/device/IotDeviceStateEnum.java similarity index 94% rename from yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceStateEnum.java rename to yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/device/IotDeviceStateEnum.java index d0ff8357e7..fd8ca0e310 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/IotDeviceStateEnum.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/device/IotDeviceStateEnum.java @@ -1,4 +1,4 @@ -package cn.iocoder.yudao.module.iot.core.enums; +package cn.iocoder.yudao.module.iot.core.enums.device; import cn.iocoder.yudao.framework.common.core.ArrayValuable; import lombok.Getter; diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusByteOrderEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusByteOrderEnum.java new file mode 100644 index 0000000000..229257a17a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusByteOrderEnum.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.iot.core.enums.modbus; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IoT Modbus 字节序枚举 + * + * @author 芋道源码 + */ +@Getter +@RequiredArgsConstructor +public enum IotModbusByteOrderEnum implements ArrayValuable { + + AB("AB", "大端序(16位)", 2), + BA("BA", "小端序(16位)", 2), + ABCD("ABCD", "大端序(32位)", 4), + CDAB("CDAB", "大端字交换(32位)", 4), + DCBA("DCBA", "小端序(32位)", 4), + BADC("BADC", "小端字交换(32位)", 4); + + public static final String[] ARRAYS = Arrays.stream(values()) + .map(IotModbusByteOrderEnum::getOrder) + .toArray(String[]::new); + + /** + * 字节序 + */ + private final String order; + /** + * 名称 + */ + private final String name; + /** + * 字节数 + */ + private final Integer byteCount; + + @Override + public String[] array() { + return ARRAYS; + } + + public static IotModbusByteOrderEnum getByOrder(String order) { + return Arrays.stream(values()) + .filter(e -> e.getOrder().equals(order)) + .findFirst() + .orElse(null); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusFrameFormatEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusFrameFormatEnum.java new file mode 100644 index 0000000000..bf1de5414b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusFrameFormatEnum.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.iot.core.enums.modbus; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IoT Modbus 数据帧格式枚举 + * + * @author 芋道源码 + */ +@Getter +@RequiredArgsConstructor +public enum IotModbusFrameFormatEnum implements ArrayValuable { + + MODBUS_TCP(1), + MODBUS_RTU(2); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(IotModbusFrameFormatEnum::getFormat) + .toArray(Integer[]::new); + + /** + * 格式 + */ + private final Integer format; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusModeEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusModeEnum.java new file mode 100644 index 0000000000..ed4b3891e7 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusModeEnum.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.iot.core.enums.modbus; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IoT Modbus 工作模式枚举 + * + * @author 芋道源码 + */ +@Getter +@RequiredArgsConstructor +public enum IotModbusModeEnum implements ArrayValuable { + + POLLING(1, "云端轮询"), + ACTIVE_REPORT(2, "边缘采集"); + + public static final Integer[] ARRAYS = Arrays.stream(values()) + .map(IotModbusModeEnum::getMode) + .toArray(Integer[]::new); + + /** + * 工作模式 + */ + private final Integer mode; + /** + * 模式名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusRawDataTypeEnum.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusRawDataTypeEnum.java new file mode 100644 index 0000000000..522b0aeafa --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/enums/modbus/IotModbusRawDataTypeEnum.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.iot.core.enums.modbus; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +/** + * IoT Modbus 原始数据类型枚举 + * + * @author 芋道源码 + */ +@Getter +@RequiredArgsConstructor +public enum IotModbusRawDataTypeEnum implements ArrayValuable { + + INT16("INT16", "有符号 16 位整数", 1), + UINT16("UINT16", "无符号 16 位整数", 1), + INT32("INT32", "有符号 32 位整数", 2), + UINT32("UINT32", "无符号 32 位整数", 2), + FLOAT("FLOAT", "32 位浮点数", 2), + DOUBLE("DOUBLE", "64 位浮点数", 4), + BOOLEAN("BOOLEAN", "布尔值(用于线圈)", 1), + STRING("STRING", "字符串", null); // null 表示可变长度 + + public static final String[] ARRAYS = Arrays.stream(values()) + .map(IotModbusRawDataTypeEnum::getType) + .toArray(String[]::new); + + /** + * 数据类型 + */ + private final String type; + /** + * 名称 + */ + private final String name; + /** + * 寄存器数量(null 表示可变) + */ + private final Integer registerCount; + + @Override + public String[] array() { + return ARRAYS; + } + + public static IotModbusRawDataTypeEnum getByType(String type) { + return Arrays.stream(values()) + .filter(e -> e.getType().equals(type)) + .findFirst() + .orElse(null); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java index 813b360433..cc9b138744 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/mq/message/IotDeviceMessage.java @@ -1,9 +1,9 @@ package cn.iocoder.yudao.module.iot.core.mq.message; -import cn.hutool.core.map.MapUtil; import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; -import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum; +import cn.iocoder.yudao.module.iot.core.topic.state.IotDeviceStateUpdateReqDTO; import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import lombok.AllArgsConstructor; import lombok.Builder; @@ -72,7 +72,7 @@ public class IotDeviceMessage { * 请求方法 * * 枚举 {@link IotDeviceMessageMethodEnum} - * 例如说:thing.property.report 属性上报 + * 例如说:thing.property.post 属性上报 */ private String method; /** @@ -149,20 +149,12 @@ public class IotDeviceMessage { public static IotDeviceMessage buildStateUpdateOnline() { return requestOf(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod(), - MapUtil.of("state", IotDeviceStateEnum.ONLINE.getState())); + new IotDeviceStateUpdateReqDTO(IotDeviceStateEnum.ONLINE.getState())); } public static IotDeviceMessage buildStateOffline() { return requestOf(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod(), - MapUtil.of("state", IotDeviceStateEnum.OFFLINE.getState())); - } - - public static IotDeviceMessage buildOtaUpgrade(String version, String fileUrl, Long fileSize, - String fileDigestAlgorithm, String fileDigestValue) { - return requestOf(IotDeviceMessageMethodEnum.OTA_UPGRADE.getMethod(), MapUtil.builder() - .put("version", version).put("fileUrl", fileUrl).put("fileSize", fileSize) - .put("fileDigestAlgorithm", fileDigestAlgorithm).put("fileDigestValue", fileDigestValue) - .build()); + new IotDeviceStateUpdateReqDTO(IotDeviceStateEnum.OFFLINE.getState())); } } diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterReqDTO.java index a77cd428ad..ad938749d3 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterReqDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterReqDTO.java @@ -1,12 +1,15 @@ package cn.iocoder.yudao.module.iot.core.topic.auth; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import jakarta.validation.constraints.NotEmpty; import lombok.Data; /** * IoT 设备动态注册 Request DTO *

- * 用于直连设备/网关的一型一密动态注册:使用 productSecret 验证,返回 deviceSecret + * 用于 {@link IotDeviceMessageMethodEnum#DEVICE_REGISTER} 消息的 params 参数 + *

+ * 直连设备/网关的一型一密动态注册:使用 productSecret 验证,返回 deviceSecret * * @author 芋道源码 * @see 阿里云 - 一型一密 diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterRespDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterRespDTO.java index 707f79890b..681aa72c5c 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterRespDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotDeviceRegisterRespDTO.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.core.topic.auth; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -7,7 +8,7 @@ import lombok.NoArgsConstructor; /** * IoT 设备动态注册 Response DTO *

- * 用于直连设备/网关的一型一密动态注册响应 + * 用于 {@link IotDeviceMessageMethodEnum#DEVICE_REGISTER} 响应的设备信息 * * @author 芋道源码 * @see 阿里云 - 一型一密 diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterReqDTO.java index cf34a1db2b..e2372e0cb8 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterReqDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterReqDTO.java @@ -1,13 +1,14 @@ package cn.iocoder.yudao.module.iot.core.topic.auth; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import jakarta.validation.constraints.NotEmpty; import lombok.Data; /** * IoT 子设备动态注册 Request DTO *

- * 用于 thing.auth.register.sub 消息的 params 数组元素 - * + * 用于 {@link IotDeviceMessageMethodEnum#SUB_DEVICE_REGISTER} 消息的 params 数组元素 + *

* 特殊:网关子设备的动态注册,必须已经创建好该网关子设备(不然哪来的 {@link #deviceName} 字段)。更多的好处,是设备不用提前烧录 deviceSecret 密钥。 * * @author 芋道源码 diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterRespDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterRespDTO.java index a45f14defe..7da2f4e47b 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterRespDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/auth/IotSubDeviceRegisterRespDTO.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.core.topic.auth; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -7,7 +8,7 @@ import lombok.NoArgsConstructor; /** * IoT 子设备动态注册 Response DTO *

- * 用于 thing.auth.register.sub 响应的设备信息 + * 用于 {@link IotDeviceMessageMethodEnum#SUB_DEVICE_REGISTER} 响应的设备信息 * * @author 芋道源码 * @see 阿里云 - 动态注册子设备 diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/config/IotDeviceConfigPushReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/config/IotDeviceConfigPushReqDTO.java new file mode 100644 index 0000000000..4828c9917a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/config/IotDeviceConfigPushReqDTO.java @@ -0,0 +1,54 @@ +package cn.iocoder.yudao.module.iot.core.topic.config; + +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT 设备配置推送 Request DTO + *

+ * 用于 {@link IotDeviceMessageMethodEnum#CONFIG_PUSH} 下行消息的 params 参数 + * + * @author 芋道源码 + * @see 阿里云 - 远程配置 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IotDeviceConfigPushReqDTO { + + /** + * 配置编号 + */ + private String configId; + + /** + * 配置文件大小(字节) + */ + private Long configSize; + + /** + * 签名方法 + */ + private String signMethod; + + /** + * 签名 + */ + private String sign; + + /** + * 配置文件下载地址 + */ + private String url; + + /** + * 获取类型 + *

+ * file: 文件 + * content: 内容 + */ + private String getType; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/event/IotDeviceEventPostReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/event/IotDeviceEventPostReqDTO.java index 3b6a7a7d4c..345419231c 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/event/IotDeviceEventPostReqDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/event/IotDeviceEventPostReqDTO.java @@ -1,11 +1,12 @@ package cn.iocoder.yudao.module.iot.core.topic.event; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import lombok.Data; /** * IoT 设备事件上报 Request DTO *

- * 用于 thing.event.post 消息的 params 参数 + * 用于 {@link IotDeviceMessageMethodEnum#EVENT_POST} 消息的 params 参数 * * @author 芋道源码 * @see 阿里云 - 设备上报事件 diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/ota/IotDeviceOtaProgressReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/ota/IotDeviceOtaProgressReqDTO.java new file mode 100644 index 0000000000..ef16e3e036 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/ota/IotDeviceOtaProgressReqDTO.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.iot.core.topic.ota; + +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT 设备 OTA 升级进度上报 Request DTO + *

+ * 用于 {@link IotDeviceMessageMethodEnum#OTA_PROGRESS} 上行消息的 params 参数 + * + * @author 芋道源码 + * @see 阿里云 - OTA 升级 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IotDeviceOtaProgressReqDTO { + + /** + * 固件版本号 + */ + private String version; + + /** + * 升级状态 + */ + private Integer status; + + /** + * 描述信息 + */ + private String description; + + /** + * 升级进度(0-100) + */ + private Integer progress; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/ota/IotDeviceOtaUpgradeReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/ota/IotDeviceOtaUpgradeReqDTO.java new file mode 100644 index 0000000000..096ac699b8 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/ota/IotDeviceOtaUpgradeReqDTO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.iot.core.topic.ota; + +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT 设备 OTA 固件升级推送 Request DTO + *

+ * 用于 {@link IotDeviceMessageMethodEnum#OTA_UPGRADE} 下行消息的 params 参数 + * + * @author 芋道源码 + * @see 阿里云 - OTA 升级 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IotDeviceOtaUpgradeReqDTO { + + /** + * 固件版本号 + */ + private String version; + + /** + * 固件文件下载地址 + */ + private String fileUrl; + + /** + * 固件文件大小(字节) + */ + private Long fileSize; + + /** + * 固件文件摘要算法 + */ + private String fileDigestAlgorithm; + + /** + * 固件文件摘要值 + */ + private String fileDigestValue; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPackPostReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPackPostReqDTO.java index 24494984eb..509e457752 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPackPostReqDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPackPostReqDTO.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.core.topic.property; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; import lombok.Data; @@ -9,7 +10,7 @@ import java.util.Map; /** * IoT 设备属性批量上报 Request DTO *

- * 用于 thing.event.property.pack.post 消息的 params 参数 + * 用于 {@link IotDeviceMessageMethodEnum#PROPERTY_PACK_POST} 消息的 params 参数 * * @author 芋道源码 * @see 阿里云 - 网关批量上报数据 diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPostReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPostReqDTO.java index 2e537442d7..98471d1d50 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPostReqDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertyPostReqDTO.java @@ -1,12 +1,14 @@ package cn.iocoder.yudao.module.iot.core.topic.property; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; + import java.util.HashMap; import java.util.Map; /** * IoT 设备属性上报 Request DTO *

- * 用于 thing.property.post 消息的 params 参数 + * 用于 {@link IotDeviceMessageMethodEnum#PROPERTY_POST} 消息的 params 参数 *

* 本质是一个 Map,key 为属性标识符,value 为属性值 * diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertySetReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertySetReqDTO.java new file mode 100644 index 0000000000..ba51f1bba1 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/property/IotDevicePropertySetReqDTO.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.iot.core.topic.property; + +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; + +import java.util.HashMap; +import java.util.Map; + +/** + * IoT 设备属性设置 Request DTO + *

+ * 用于 {@link IotDeviceMessageMethodEnum#PROPERTY_SET} 下行消息的 params 参数 + *

+ * 本质是一个 Map,key 为属性标识符,value 为属性值 + * + * @author 芋道源码 + */ +public class IotDevicePropertySetReqDTO extends HashMap { + + public IotDevicePropertySetReqDTO() { + super(); + } + + public IotDevicePropertySetReqDTO(Map properties) { + super(properties); + } + + /** + * 创建属性设置 DTO + * + * @param properties 属性数据 + * @return DTO 对象 + */ + public static IotDevicePropertySetReqDTO of(Map properties) { + return new IotDevicePropertySetReqDTO(properties); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/service/IotDeviceServiceInvokeReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/service/IotDeviceServiceInvokeReqDTO.java new file mode 100644 index 0000000000..dafadd24a2 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/service/IotDeviceServiceInvokeReqDTO.java @@ -0,0 +1,32 @@ +package cn.iocoder.yudao.module.iot.core.topic.service; + +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * IoT 设备服务调用 Request DTO + *

+ * 用于 {@link IotDeviceMessageMethodEnum#SERVICE_INVOKE} 下行消息的 params 参数 + * + * @author 芋道源码 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IotDeviceServiceInvokeReqDTO { + + /** + * 服务标识符 + */ + private String identifier; + + /** + * 服务输入参数 + */ + private Map inputParams; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/state/IotDeviceStateUpdateReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/state/IotDeviceStateUpdateReqDTO.java new file mode 100644 index 0000000000..fce44e03b5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/state/IotDeviceStateUpdateReqDTO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.iot.core.topic.state; + +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * IoT 设备状态更新 Request DTO + *

+ * 用于 {@link IotDeviceMessageMethodEnum#STATE_UPDATE} 消息的 params 参数 + * + * @author 芋道源码 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class IotDeviceStateUpdateReqDTO { + + /** + * 设备状态 + */ + private Integer state; + +} diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoAddReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoAddReqDTO.java index 97ec33200a..b9444ed6d6 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoAddReqDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoAddReqDTO.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.iot.core.topic.topo; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import jakarta.validation.constraints.NotEmpty; import lombok.Data; @@ -9,7 +10,7 @@ import java.util.List; /** * IoT 设备拓扑添加 Request DTO *

- * 用于 thing.topo.add 消息的 params 参数 + * 用于 {@link IotDeviceMessageMethodEnum#TOPO_ADD} 消息的 params 参数 * * @author 芋道源码 * @see 阿里云 - 添加拓扑关系 diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoChangeReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoChangeReqDTO.java index 0198206fe3..615e509ae6 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoChangeReqDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoChangeReqDTO.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.core.topic.topo; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; import lombok.AllArgsConstructor; import lombok.Data; @@ -10,7 +11,7 @@ import java.util.List; /** * IoT 设备拓扑关系变更通知 Request DTO *

- * 用于 thing.topo.change 下行消息的 params 参数 + * 用于 {@link IotDeviceMessageMethodEnum#TOPO_CHANGE} 下行消息的 params 参数 * * @author 芋道源码 * @see 阿里云 - 通知网关拓扑关系变化 diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoDeleteReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoDeleteReqDTO.java index 71ee2bb8b2..6db2b5db8d 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoDeleteReqDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoDeleteReqDTO.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.core.topic.topo; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; @@ -10,7 +11,7 @@ import java.util.List; /** * IoT 设备拓扑删除 Request DTO *

- * 用于 thing.topo.delete 消息的 params 参数 + * 用于 {@link IotDeviceMessageMethodEnum#TOPO_DELETE} 消息的 params 参数 * * @author 芋道源码 * @see 阿里云 - 删除拓扑关系 diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetReqDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetReqDTO.java index 7a61af0a58..1da86c9505 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetReqDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetReqDTO.java @@ -1,11 +1,12 @@ package cn.iocoder.yudao.module.iot.core.topic.topo; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import lombok.Data; /** * IoT 设备拓扑关系获取 Request DTO *

- * 用于 thing.topo.get 请求的 params 参数(目前为空,预留扩展) + * 用于 {@link IotDeviceMessageMethodEnum#TOPO_GET} 请求的 params 参数(目前为空,预留扩展) * * @author 芋道源码 * @see 阿里云 - 获取拓扑关系 diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetRespDTO.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetRespDTO.java index 69c9b1555e..0aef9c8680 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetRespDTO.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/topic/topo/IotDeviceTopoGetRespDTO.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.core.topic.topo; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; import lombok.Data; @@ -8,7 +9,7 @@ import java.util.List; /** * IoT 设备拓扑关系获取 Response DTO *

- * 用于 thing.topo.get 响应 + * 用于 {@link IotDeviceMessageMethodEnum#TOPO_GET} 响应 * * @author 芋道源码 * @see 阿里云 - 获取拓扑关系 diff --git a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java index 609d0a60ae..1aa9cfcabf 100644 --- a/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java +++ b/yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceAuthUtils.java @@ -25,6 +25,14 @@ public class IotDeviceAuthUtils { return String.format("%s.%s", productKey, deviceName); } + public static String buildClientIdFromUsername(String username) { + IotDeviceIdentity identity = parseUsername(username); + if (identity == null) { + return null; + } + return buildClientId(identity.getProductKey(), identity.getDeviceName()); + } + public static String buildUsername(String productKey, String deviceName) { return String.format("%s&%s", deviceName, productKey); } diff --git a/yudao-module-iot/yudao-module-iot-gateway/pom.xml b/yudao-module-iot/yudao-module-iot-gateway/pom.xml index 38ace822d8..0731198fd7 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/pom.xml +++ b/yudao-module-iot/yudao-module-iot-gateway/pom.xml @@ -33,7 +33,7 @@ org.apache.rocketmq rocketmq-spring-boot-starter - + true @@ -48,6 +48,13 @@ vertx-mqtt + + + com.ghgande + j2mod + 3.2.1 + + org.eclipse.californium diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java index 3c62c0d221..5c2fd860e9 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java @@ -6,6 +6,11 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * IoT 网关配置类 + * + * @author 芋道源码 + */ @Configuration @EnableConfigurationProperties(IotGatewayProperties.class) public class IotGatewayConfiguration { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java index 6019b0f079..63894dc9df 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java @@ -4,6 +4,8 @@ import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapConfig; import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxConfig; import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpConfig; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientConfig; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.IotModbusTcpServerConfig; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttConfig; import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig; import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpConfig; @@ -166,6 +168,18 @@ public class IotGatewayProperties { @Valid private IotEmqxConfig emqx; + /** + * Modbus TCP Client 协议配置 + */ + @Valid + private IotModbusTcpClientConfig modbusTcpClient; + + /** + * Modbus TCP Server 协议配置 + */ + @Valid + private IotModbusTcpServerConfig modbusTcpServer; + } /** diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriber.java similarity index 95% rename from yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolDownstreamSubscriber.java rename to yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriber.java index 2e2150f6f7..efd61e13a2 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/AbstractIotProtocolDownstreamSubscriber.java @@ -17,7 +17,7 @@ import lombok.extern.slf4j.Slf4j; */ @AllArgsConstructor @Slf4j -public abstract class IotProtocolDownstreamSubscriber implements IotMessageSubscriber { +public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMessageSubscriber { private final IotProtocol protocol; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java index 3cd00c7573..310533d22e 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/IotProtocolManager.java @@ -7,6 +7,8 @@ import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.IotModbusTcpServerProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpProtocol; import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpProtocol; @@ -112,6 +114,10 @@ public class IotProtocolManager implements SmartLifecycle { return createMqttProtocol(config); case EMQX: return createEmqxProtocol(config); + case MODBUS_TCP_CLIENT: + return createModbusTcpClientProtocol(config); + case MODBUS_TCP_SERVER: + return createModbusTcpServerProtocol(config); default: throw new IllegalArgumentException(String.format( "[createProtocol][协议实例 %s 的协议类型 %s 暂不支持]", config.getId(), protocolType)); @@ -188,4 +194,24 @@ public class IotProtocolManager implements SmartLifecycle { return new IotEmqxProtocol(config); } + /** + * 创建 Modbus TCP Client 协议实例 + * + * @param config 协议实例配置 + * @return Modbus TCP Client 协议实例 + */ + private IotModbusTcpClientProtocol createModbusTcpClientProtocol(IotGatewayProperties.ProtocolProperties config) { + return new IotModbusTcpClientProtocol(config); + } + + /** + * 创建 Modbus TCP Server 协议实例 + * + * @param config 协议实例配置 + * @return Modbus TCP Server 协议实例 + */ + private IotModbusTcpServerProtocol createModbusTcpServerProtocol(IotGatewayProperties.ProtocolProperties config) { + return new IotModbusTcpServerProtocol(config); + } + } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java index 14fe10dcd8..4bc8cdbe28 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/IotCoapProtocol.java @@ -64,17 +64,13 @@ public class IotCoapProtocol implements IotProtocol { /** * 下行消息订阅者 */ - private final IotCoapDownstreamSubscriber downstreamSubscriber; + private IotCoapDownstreamSubscriber downstreamSubscriber; public IotCoapProtocol(ProtocolProperties properties) { IotCoapConfig coapConfig = properties.getCoap(); Assert.notNull(coapConfig, "CoAP 协议配置(coap)不能为空"); this.properties = properties; this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort()); - - // 初始化下行消息订阅者 - IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); - this.downstreamSubscriber = new IotCoapDownstreamSubscriber(this, messageBus); } @Override @@ -94,9 +90,9 @@ public class IotCoapProtocol implements IotProtocol { return; } - IotCoapConfig coapConfig = properties.getCoap(); try { // 1.1 创建 CoAP 配置 + IotCoapConfig coapConfig = properties.getCoap(); Configuration config = Configuration.createStandardWithoutFile(); config.set(CoapConfig.COAP_PORT, properties.getPort()); config.set(CoapConfig.MAX_MESSAGE_SIZE, coapConfig.getMaxMessageSize()); @@ -131,13 +127,12 @@ public class IotCoapProtocol implements IotProtocol { getId(), properties.getPort(), serverId); // 4. 启动下行消息订阅者 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + this.downstreamSubscriber = new IotCoapDownstreamSubscriber(this, messageBus); this.downstreamSubscriber.start(); } catch (Exception e) { log.error("[start][IoT CoAP 协议 {} 启动失败]", getId(), e); - if (coapServer != null) { - coapServer.destroy(); - coapServer = null; - } + stop0(); throw e; } } @@ -147,12 +142,19 @@ public class IotCoapProtocol implements IotProtocol { if (!running) { return; } + stop0(); + } + + private void stop0() { // 1. 停止下行消息订阅者 - try { - downstreamSubscriber.stop(); - log.info("[stop][IoT CoAP 协议 {} 下行消息订阅者已停止]", getId()); - } catch (Exception e) { - log.error("[stop][IoT CoAP 协议 {} 下行消息订阅者停止失败]", getId(), e); + if (downstreamSubscriber != null) { + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT CoAP 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT CoAP 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + downstreamSubscriber = null; } // 2. 关闭 CoAP 服务器 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/downstream/IotCoapDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/downstream/IotCoapDownstreamSubscriber.java index 188d2e6428..3309d2cd49 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/downstream/IotCoapDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/coap/handler/downstream/IotCoapDownstreamSubscriber.java @@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.downstream; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; -import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol; import lombok.extern.slf4j.Slf4j; @@ -12,7 +12,7 @@ import lombok.extern.slf4j.Slf4j; * @author 芋道源码 */ @Slf4j -public class IotCoapDownstreamSubscriber extends IotProtocolDownstreamSubscriber { +public class IotCoapDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { public IotCoapDownstreamSubscriber(IotCoapProtocol protocol, IotMessageBus messageBus) { super(protocol, messageBus); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocol.java index a9e251736f..f110f64b4d 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/IotEmqxProtocol.java @@ -90,7 +90,7 @@ public class IotEmqxProtocol implements IotProtocol { /** * 下行消息订阅者 */ - private final IotEmqxDownstreamSubscriber downstreamSubscriber; + private IotEmqxDownstreamSubscriber downstreamSubscriber; public IotEmqxProtocol(ProtocolProperties properties) { Assert.notNull(properties, "协议实例配置不能为空"); @@ -101,10 +101,6 @@ public class IotEmqxProtocol implements IotProtocol { "MQTT 连接超时时间(emqx.connect-timeout-seconds)不能为空"); this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort()); this.upstreamHandler = new IotEmqxUpstreamHandler(serverId); - - // 初始化下行消息订阅者 - IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); - this.downstreamSubscriber = new IotEmqxDownstreamSubscriber(this, messageBus); } @Override @@ -124,7 +120,7 @@ public class IotEmqxProtocol implements IotProtocol { return; } - // 1.1 创建 Vertx 实例 + // 1.1 创建 Vertx 实例 和 下行消息订阅者 this.vertx = Vertx.vertx(); try { @@ -138,6 +134,8 @@ public class IotEmqxProtocol implements IotProtocol { getId(), properties.getPort(), serverId); // 2. 启动下行消息订阅者 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + this.downstreamSubscriber = new IotEmqxDownstreamSubscriber(this, messageBus); this.downstreamSubscriber.start(); } catch (Exception e) { log.error("[start][IoT EMQX 协议 {} 启动失败]", getId(), e); @@ -157,11 +155,14 @@ public class IotEmqxProtocol implements IotProtocol { private void stop0() { // 1. 停止下行消息订阅者 - try { - downstreamSubscriber.stop(); - log.info("[stop][IoT EMQX 协议 {} 下行消息订阅者已停止]", getId()); - } catch (Exception e) { - log.error("[stop][IoT EMQX 协议 {} 下行消息订阅者停止失败]", getId(), e); + if (downstreamSubscriber != null) { + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT EMQX 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT EMQX 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + downstreamSubscriber = null; } // 2.1 先置为 false:避免 closeHandler 触发重连 diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamSubscriber.java index 55aaaac69c..e7e5de98db 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/downstream/IotEmqxDownstreamSubscriber.java @@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; -import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol; import lombok.extern.slf4j.Slf4j; @@ -12,7 +12,7 @@ import lombok.extern.slf4j.Slf4j; * @author 芋道源码 */ @Slf4j -public class IotEmqxDownstreamSubscriber extends IotProtocolDownstreamSubscriber { +public class IotEmqxDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { private final IotEmqxDownstreamHandler downstreamHandler; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxUpstreamHandler.java index 17d5f85fc0..45c94818cb 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/emqx/handler/upstream/IotEmqxUpstreamHandler.java @@ -5,6 +5,7 @@ import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; import io.vertx.mqtt.messages.MqttPublishMessage; import lombok.extern.slf4j.Slf4j; @@ -42,12 +43,14 @@ public class IotEmqxUpstreamHandler { return; } - // 2. 反序列化消息 + // 2.1 反序列化消息 IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); if (message == null) { log.warn("[handle][topic({}) payload({}) 消息解码失败]", topic, new String(payload)); return; } + // 2.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) + IotMqttTopicUtils.normalizeReplyMethod(message); // 3. 发送消息到队列 deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpProtocol.java index 2f92419161..f6c9bdc900 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/IotHttpProtocol.java @@ -64,10 +64,6 @@ public class IotHttpProtocol implements IotProtocol { public IotHttpProtocol(ProtocolProperties properties) { this.properties = properties; this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort()); - - // 初始化下行消息订阅者 - IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); - this.downstreamSubscriber = new IotHttpDownstreamSubscriber(this, messageBus); } @Override @@ -87,7 +83,7 @@ public class IotHttpProtocol implements IotProtocol { return; } - // 1.1 创建 Vertx 实例(每个 Protocol 独立管理) + // 1.1 创建 Vertx 实例 this.vertx = Vertx.vertx(); // 1.2 创建路由 @@ -123,18 +119,12 @@ public class IotHttpProtocol implements IotProtocol { getId(), properties.getPort(), serverId); // 2. 启动下行消息订阅者 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + this.downstreamSubscriber = new IotHttpDownstreamSubscriber(this, messageBus); this.downstreamSubscriber.start(); } catch (Exception e) { log.error("[start][IoT HTTP 协议 {} 启动失败]", getId(), e); - // 启动失败时关闭资源 - if (httpServer != null) { - httpServer.close(); - httpServer = null; - } - if (vertx != null) { - vertx.close(); - vertx = null; - } + stop0(); throw e; } } @@ -144,6 +134,10 @@ public class IotHttpProtocol implements IotProtocol { if (!running) { return; } + stop0(); + } + + private void stop0() { // 1. 停止下行消息订阅者 if (downstreamSubscriber != null) { try { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/downstream/IotHttpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/downstream/IotHttpDownstreamSubscriber.java index bfac16ca5e..fe94fe6172 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/downstream/IotHttpDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/http/handler/downstream/IotHttpDownstreamSubscriber.java @@ -3,7 +3,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.downstream; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; -import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; import lombok.extern.slf4j.Slf4j; /** @@ -13,7 +13,7 @@ import lombok.extern.slf4j.Slf4j; */ @Slf4j -public class IotHttpDownstreamSubscriber extends IotProtocolDownstreamSubscriber { +public class IotHttpDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { public IotHttpDownstreamSubscriber(IotProtocol protocol, IotMessageBus messageBus) { super(protocol, messageBus); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/manager/AbstractIotModbusPollScheduler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/manager/AbstractIotModbusPollScheduler.java new file mode 100644 index 0000000000..e62f85fcf6 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/manager/AbstractIotModbusPollScheduler.java @@ -0,0 +1,278 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import io.vertx.core.Vertx; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +/** + * Modbus 轮询调度器基类 + *

+ * 封装通用的定时器管理、per-device 请求队列限速逻辑。 + * 子类只需实现 {@link #pollPoint(Long, Long)} 定义具体的轮询动作。 + *

+ * + * @author 芋道源码 + */ +@Slf4j +public abstract class AbstractIotModbusPollScheduler { + + protected final Vertx vertx; + + /** + * 同设备最小请求间隔(毫秒),防止 Modbus 设备性能不足时请求堆积 + */ + private static final long MIN_REQUEST_INTERVAL = 1000; + /** + * 每个设备请求队列的最大长度,超出时丢弃最旧请求 + */ + private static final int MAX_QUEUE_SIZE = 1000; + + /** + * 设备点位的定时器映射:deviceId -> (pointId -> PointTimerInfo) + */ + private final Map> devicePointTimers = new ConcurrentHashMap<>(); + + /** + * per-device 请求队列:deviceId -> 待执行请求队列 + */ + private final Map> deviceRequestQueues = new ConcurrentHashMap<>(); + /** + * per-device 上次请求时间戳:deviceId -> lastRequestTimeMs + */ + private final Map deviceLastRequestTime = new ConcurrentHashMap<>(); + /** + * per-device 延迟 timer 标记:deviceId -> 是否有延迟 timer 在等待 + */ + private final Map deviceDelayTimerActive = new ConcurrentHashMap<>(); + + protected AbstractIotModbusPollScheduler(Vertx vertx) { + this.vertx = vertx; + } + + /** + * 点位定时器信息 + */ + @Data + @AllArgsConstructor + private static class PointTimerInfo { + + /** + * Vert.x 定时器 ID + */ + private Long timerId; + /** + * 轮询间隔(用于判断是否需要更新定时器) + */ + private Integer pollInterval; + + } + + // ========== 轮询管理 ========== + + /** + * 更新轮询任务(增量更新) + * + * 1. 【删除】点位:停止对应的轮询定时器 + * 2. 【新增】点位:创建对应的轮询定时器 + * 3. 【修改】点位:pollInterval 变化,重建对应的轮询定时器 + * 【修改】其他属性变化:不需要重建定时器(pollPoint 运行时从 configCache 取最新 point) + */ + public void updatePolling(IotModbusDeviceConfigRespDTO config) { + Long deviceId = config.getDeviceId(); + List newPoints = config.getPoints(); + Map currentTimers = devicePointTimers + .computeIfAbsent(deviceId, k -> new ConcurrentHashMap<>()); + // 1.1 计算新配置中的点位 ID 集合 + Set newPointIds = convertSet(newPoints, IotModbusPointRespDTO::getId); + // 1.2 计算删除的点位 ID 集合 + Set removedPointIds = new HashSet<>(currentTimers.keySet()); + removedPointIds.removeAll(newPointIds); + + // 2. 处理删除的点位:停止不再存在的定时器 + for (Long pointId : removedPointIds) { + PointTimerInfo timerInfo = currentTimers.remove(pointId); + if (timerInfo != null) { + vertx.cancelTimer(timerInfo.getTimerId()); + log.debug("[updatePolling][设备 {} 点位 {} 定时器已删除]", deviceId, pointId); + } + } + + // 3. 处理新增和修改的点位 + if (CollUtil.isEmpty(newPoints)) { + return; + } + for (IotModbusPointRespDTO point : newPoints) { + Long pointId = point.getId(); + Integer newPollInterval = point.getPollInterval(); + PointTimerInfo existingTimer = currentTimers.get(pointId); + // 3.1 新增点位:创建定时器 + if (existingTimer == null) { + Long timerId = createPollTimer(deviceId, pointId, newPollInterval); + if (timerId != null) { + currentTimers.put(pointId, new PointTimerInfo(timerId, newPollInterval)); + log.debug("[updatePolling][设备 {} 点位 {} 定时器已创建, interval={}ms]", + deviceId, pointId, newPollInterval); + } + } else if (!Objects.equals(existingTimer.getPollInterval(), newPollInterval)) { + // 3.2 pollInterval 变化:重建定时器 + vertx.cancelTimer(existingTimer.getTimerId()); + Long timerId = createPollTimer(deviceId, pointId, newPollInterval); + if (timerId != null) { + currentTimers.put(pointId, new PointTimerInfo(timerId, newPollInterval)); + log.debug("[updatePolling][设备 {} 点位 {} 定时器已更新, interval={}ms -> {}ms]", + deviceId, pointId, existingTimer.getPollInterval(), newPollInterval); + } else { + currentTimers.remove(pointId); + } + } + // 3.3 其他属性变化:无需重建定时器,因为 pollPoint() 运行时从 configCache 获取最新 point,自动使用新配置 + } + } + + /** + * 创建轮询定时器 + */ + private Long createPollTimer(Long deviceId, Long pointId, Integer pollInterval) { + if (pollInterval == null || pollInterval <= 0) { + return null; + } + return vertx.setPeriodic(pollInterval, timerId -> { + try { + submitPollRequest(deviceId, pointId); + } catch (Exception e) { + log.error("[createPollTimer][轮询点位失败, deviceId={}, pointId={}]", deviceId, pointId, e); + } + }); + } + + // ========== 请求队列(per-device 限速) ========== + + /** + * 提交轮询请求到设备请求队列(保证同设备请求间隔) + */ + private void submitPollRequest(Long deviceId, Long pointId) { + // 1. 【重要】将请求添加到设备的请求队列 + Queue queue = deviceRequestQueues.computeIfAbsent(deviceId, k -> new ConcurrentLinkedQueue<>()); + while (queue.size() >= MAX_QUEUE_SIZE) { + // 超出上限时,丢弃最旧的请求 + queue.poll(); + log.warn("[submitPollRequest][设备 {} 请求队列已满({}), 丢弃最旧请求]", deviceId, MAX_QUEUE_SIZE); + } + queue.offer(() -> pollPoint(deviceId, pointId)); + + // 2. 处理设备请求队列(如果没有延迟 timer 在等待) + processDeviceQueue(deviceId); + } + + /** + * 处理设备请求队列 + */ + private void processDeviceQueue(Long deviceId) { + Queue queue = deviceRequestQueues.get(deviceId); + if (CollUtil.isEmpty(queue)) { + return; + } + // 检查是否已有延迟 timer 在等待 + if (Boolean.TRUE.equals(deviceDelayTimerActive.get(deviceId))) { + return; + } + + // 不满足间隔要求,延迟执行 + long now = System.currentTimeMillis(); + long lastTime = deviceLastRequestTime.getOrDefault(deviceId, 0L); + long elapsed = now - lastTime; + if (elapsed < MIN_REQUEST_INTERVAL) { + scheduleNextRequest(deviceId, MIN_REQUEST_INTERVAL - elapsed); + return; + } + + // 满足间隔要求,立即执行 + Runnable task = queue.poll(); + if (task == null) { + return; + } + deviceLastRequestTime.put(deviceId, now); + task.run(); + // 继续处理队列中的下一个(如果有的话,需要延迟) + if (CollUtil.isNotEmpty(queue)) { + scheduleNextRequest(deviceId); + } + } + + private void scheduleNextRequest(Long deviceId) { + scheduleNextRequest(deviceId, MIN_REQUEST_INTERVAL); + } + + private void scheduleNextRequest(Long deviceId, long delayMs) { + deviceDelayTimerActive.put(deviceId, true); + vertx.setTimer(delayMs, id -> { + deviceDelayTimerActive.put(deviceId, false); + Queue queue = deviceRequestQueues.get(deviceId); + if (CollUtil.isEmpty(queue)) { + return; + } + + // 满足间隔要求,立即执行 + Runnable task = queue.poll(); + if (task == null) { + return; + } + deviceLastRequestTime.put(deviceId, System.currentTimeMillis()); + task.run(); + // 继续处理队列中的下一个(如果有的话,需要延迟) + if (CollUtil.isNotEmpty(queue)) { + scheduleNextRequest(deviceId); + } + }); + } + + // ========== 轮询执行 ========== + + /** + * 轮询单个点位(子类实现具体的读取逻辑) + * + * @param deviceId 设备 ID + * @param pointId 点位 ID + */ + protected abstract void pollPoint(Long deviceId, Long pointId); + + // ========== 停止 ========== + + /** + * 停止设备的轮询 + */ + public void stopPolling(Long deviceId) { + Map timers = devicePointTimers.remove(deviceId); + if (CollUtil.isEmpty(timers)) { + return; + } + for (PointTimerInfo timerInfo : timers.values()) { + vertx.cancelTimer(timerInfo.getTimerId()); + } + // 清理请求队列 + deviceRequestQueues.remove(deviceId); + deviceLastRequestTime.remove(deviceId); + deviceDelayTimerActive.remove(deviceId); + log.debug("[stopPolling][设备 {} 停止了 {} 个轮询定时器]", deviceId, timers.size()); + } + + /** + * 停止所有轮询 + */ + public void stopAll() { + for (Long deviceId : new ArrayList<>(devicePointTimers.keySet())) { + stopPolling(deviceId); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtils.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtils.java new file mode 100644 index 0000000000..312e796df1 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusCommonUtils.java @@ -0,0 +1,557 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusByteOrderEnum; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusRawDataTypeEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * IoT Modbus 协议工具类 + *

+ * 提供 Modbus 协议全链路能力: + *

    + *
  • 协议常量:功能码(FC01~FC16)、异常掩码等
  • + *
  • 功能码判断:读/写/异常分类、可写判断、写功能码映射
  • + *
  • CRC-16/MODBUS 计算和校验
  • + *
  • 数据转换:原始值 ↔ 物模型属性值({@link #convertToPropertyValue} / {@link #convertToRawValues})
  • + *
  • 帧值提取:从 Modbus 帧提取寄存器/线圈值({@link #extractValues})
  • + *
  • 点位查找({@link #findPoint})
  • + *
+ * + * @author 芋道源码 + */ +@UtilityClass +@Slf4j +public class IotModbusCommonUtils { + + /** FC01: 读线圈 */ + public static final int FC_READ_COILS = 1; + /** FC02: 读离散输入 */ + public static final int FC_READ_DISCRETE_INPUTS = 2; + /** FC03: 读保持寄存器 */ + public static final int FC_READ_HOLDING_REGISTERS = 3; + /** FC04: 读输入寄存器 */ + public static final int FC_READ_INPUT_REGISTERS = 4; + + /** FC05: 写单个线圈 */ + public static final int FC_WRITE_SINGLE_COIL = 5; + /** FC06: 写单个寄存器 */ + public static final int FC_WRITE_SINGLE_REGISTER = 6; + /** FC15: 写多个线圈 */ + public static final int FC_WRITE_MULTIPLE_COILS = 15; + /** FC16: 写多个寄存器 */ + public static final int FC_WRITE_MULTIPLE_REGISTERS = 16; + + /** + * 异常响应掩码:响应帧的功能码最高位为 1 时,表示异常响应 + * 例如:请求 FC=0x03,异常响应 FC=0x83(0x03 | 0x80) + */ + public static final int FC_EXCEPTION_MASK = 0x80; + + /** + * 功能码掩码:用于从异常响应中提取原始功能码 + * 例如:异常 FC=0x83,原始 FC = 0x83 & 0x7F = 0x03 + */ + public static final int FC_MASK = 0x7F; + + // ==================== 功能码分类判断 ==================== + + /** + * 判断是否为读响应(FC01-04) + */ + public static boolean isReadResponse(int functionCode) { + return functionCode >= FC_READ_COILS && functionCode <= FC_READ_INPUT_REGISTERS; + } + + /** + * 判断是否为写响应(FC05/06/15/16) + */ + public static boolean isWriteResponse(int functionCode) { + return functionCode == FC_WRITE_SINGLE_COIL || functionCode == FC_WRITE_SINGLE_REGISTER + || functionCode == FC_WRITE_MULTIPLE_COILS || functionCode == FC_WRITE_MULTIPLE_REGISTERS; + } + + /** + * 判断是否为异常响应 + */ + public static boolean isExceptionResponse(int functionCode) { + return (functionCode & FC_EXCEPTION_MASK) != 0; + } + + /** + * 从异常响应中提取原始功能码 + */ + public static int extractOriginalFunctionCode(int exceptionFunctionCode) { + return exceptionFunctionCode & FC_MASK; + } + + /** + * 判断读功能码是否支持写操作 + *

+ * FC01(读线圈)和 FC03(读保持寄存器)支持写操作; + * FC02(读离散输入)和 FC04(读输入寄存器)为只读。 + * + * @param readFunctionCode 读功能码(FC01-04) + * @return 是否支持写操作 + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public static boolean isWritable(int readFunctionCode) { + return readFunctionCode == FC_READ_COILS || readFunctionCode == FC_READ_HOLDING_REGISTERS; + } + + /** + * 获取单写功能码 + *

+ * FC01(读线圈)→ FC05(写单个线圈); + * FC03(读保持寄存器)→ FC06(写单个寄存器); + * 其他返回 null(不支持写)。 + * + * @param readFunctionCode 读功能码 + * @return 单写功能码,不支持写时返回 null + */ + @SuppressWarnings("EnhancedSwitchMigration") + public static Integer getWriteSingleFunctionCode(int readFunctionCode) { + switch (readFunctionCode) { + case FC_READ_COILS: + return FC_WRITE_SINGLE_COIL; + case FC_READ_HOLDING_REGISTERS: + return FC_WRITE_SINGLE_REGISTER; + default: + return null; + } + } + + /** + * 获取多写功能码 + *

+ * FC01(读线圈)→ FC15(写多个线圈); + * FC03(读保持寄存器)→ FC16(写多个寄存器); + * 其他返回 null(不支持写)。 + * + * @param readFunctionCode 读功能码 + * @return 多写功能码,不支持写时返回 null + */ + @SuppressWarnings("EnhancedSwitchMigration") + public static Integer getWriteMultipleFunctionCode(int readFunctionCode) { + switch (readFunctionCode) { + case FC_READ_COILS: + return FC_WRITE_MULTIPLE_COILS; + case FC_READ_HOLDING_REGISTERS: + return FC_WRITE_MULTIPLE_REGISTERS; + default: + return null; + } + } + + // ==================== CRC16 工具 ==================== + + /** + * 计算 CRC-16/MODBUS + * + * @param data 数据 + * @param length 计算长度 + * @return CRC16 值 + */ + public static int calculateCrc16(byte[] data, int length) { + int crc = 0xFFFF; + for (int i = 0; i < length; i++) { + crc ^= (data[i] & 0xFF); + for (int j = 0; j < 8; j++) { + if ((crc & 0x0001) != 0) { + crc >>= 1; + crc ^= 0xA001; + } else { + crc >>= 1; + } + } + } + return crc; + } + + /** + * 校验 CRC16 + * + * @param data 包含 CRC 的完整数据 + * @return 校验是否通过 + */ + public static boolean verifyCrc16(byte[] data) { + if (data.length < 3) { + return false; + } + int computed = calculateCrc16(data, data.length - 2); + int received = (data[data.length - 2] & 0xFF) | ((data[data.length - 1] & 0xFF) << 8); + return computed == received; + } + + // ==================== 数据转换 ==================== + + /** + * 将原始值转换为物模型属性值 + * + * @param rawValues 原始值数组(寄存器值或线圈值) + * @param point 点位配置 + * @return 转换后的属性值 + */ + public static Object convertToPropertyValue(int[] rawValues, IotModbusPointRespDTO point) { + if (ArrayUtil.isEmpty(rawValues)) { + return null; + } + String rawDataType = point.getRawDataType(); + String byteOrder = point.getByteOrder(); + BigDecimal scale = ObjectUtil.defaultIfNull(point.getScale(), BigDecimal.ONE); + + // 1. 根据原始数据类型解析原始数值 + Number rawNumber = parseRawValue(rawValues, rawDataType, byteOrder); + if (rawNumber == null) { + return null; + } + + // 2. 应用缩放因子:实际值 = 原始值 × scale + BigDecimal actualValue = new BigDecimal(rawNumber.toString()).multiply(scale); + + // 3. 根据数据类型返回合适的 Java 类型 + return formatValue(actualValue, rawDataType); + } + + /** + * 将物模型属性值转换为原始寄存器值 + * + * @param propertyValue 属性值 + * @param point 点位配置 + * @return 原始值数组 + */ + public static int[] convertToRawValues(Object propertyValue, IotModbusPointRespDTO point) { + if (propertyValue == null) { + return new int[0]; + } + String rawDataType = point.getRawDataType(); + String byteOrder = point.getByteOrder(); + BigDecimal scale = ObjectUtil.defaultIfNull(point.getScale(), BigDecimal.ONE); + int registerCount = ObjectUtil.defaultIfNull(point.getRegisterCount(), 1); + + // 1. 转换为 BigDecimal + BigDecimal actualValue = new BigDecimal(propertyValue.toString()); + + // 2. 应用缩放因子:原始值 = 实际值 ÷ scale + BigDecimal rawValue = actualValue.divide(scale, 0, RoundingMode.HALF_UP); + + // 3. 根据原始数据类型编码为寄存器值 + return encodeToRegisters(rawValue, rawDataType, byteOrder, registerCount); + } + + @SuppressWarnings("EnhancedSwitchMigration") + private static Number parseRawValue(int[] rawValues, String rawDataType, String byteOrder) { + IotModbusRawDataTypeEnum dataTypeEnum = IotModbusRawDataTypeEnum.getByType(rawDataType); + if (dataTypeEnum == null) { + log.warn("[parseRawValue][不支持的数据类型: {}]", rawDataType); + return rawValues[0]; + } + switch (dataTypeEnum) { + case BOOLEAN: + return rawValues[0] != 0 ? 1 : 0; + case INT16: + return (short) rawValues[0]; + case UINT16: + return rawValues[0] & 0xFFFF; + case INT32: + return parseInt32(rawValues, byteOrder); + case UINT32: + return parseUint32(rawValues, byteOrder); + case FLOAT: + return parseFloat(rawValues, byteOrder); + case DOUBLE: + return parseDouble(rawValues, byteOrder); + default: + log.warn("[parseRawValue][不支持的数据类型: {}]", rawDataType); + return rawValues[0]; + } + } + + private static int parseInt32(int[] rawValues, String byteOrder) { + if (rawValues.length < 2) { + return rawValues[0]; + } + byte[] bytes = reorderBytes(registersToBytes(rawValues, 2), byteOrder); + return ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getInt(); + } + + private static long parseUint32(int[] rawValues, String byteOrder) { + if (rawValues.length < 2) { + return rawValues[0] & 0xFFFFFFFFL; + } + byte[] bytes = reorderBytes(registersToBytes(rawValues, 2), byteOrder); + return ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getInt() & 0xFFFFFFFFL; + } + + private static float parseFloat(int[] rawValues, String byteOrder) { + if (rawValues.length < 2) { + return (float) rawValues[0]; + } + byte[] bytes = reorderBytes(registersToBytes(rawValues, 2), byteOrder); + return ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getFloat(); + } + + private static double parseDouble(int[] rawValues, String byteOrder) { + if (rawValues.length < 4) { + return rawValues[0]; + } + byte[] bytes = reorderBytes(registersToBytes(rawValues, 4), byteOrder); + return ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getDouble(); + } + + private static byte[] registersToBytes(int[] registers, int count) { + byte[] bytes = new byte[count * 2]; + for (int i = 0; i < Math.min(registers.length, count); i++) { + bytes[i * 2] = (byte) ((registers[i] >> 8) & 0xFF); + bytes[i * 2 + 1] = (byte) (registers[i] & 0xFF); + } + return bytes; + } + + @SuppressWarnings("EnhancedSwitchMigration") + private static byte[] reorderBytes(byte[] bytes, String byteOrder) { + IotModbusByteOrderEnum byteOrderEnum = IotModbusByteOrderEnum.getByOrder(byteOrder); + // null 或者大端序,不需要调整 + if (ObjectUtils.equalsAny(byteOrderEnum, null, IotModbusByteOrderEnum.ABCD, IotModbusByteOrderEnum.AB)) { + return bytes; + } + + // 其他字节序调整 + byte[] result = new byte[bytes.length]; + switch (byteOrderEnum) { + case BA: // 小端序:按每 2 字节一组交换(16 位场景 [1,0],32 位场景 [1,0,3,2]) + for (int i = 0; i + 1 < bytes.length; i += 2) { + result[i] = bytes[i + 1]; + result[i + 1] = bytes[i]; + } + break; + case CDAB: // 大端字交换(32 位) + if (bytes.length >= 4) { + result[0] = bytes[2]; + result[1] = bytes[3]; + result[2] = bytes[0]; + result[3] = bytes[1]; + } + break; + case DCBA: // 小端序(32 位) + if (bytes.length >= 4) { + result[0] = bytes[3]; + result[1] = bytes[2]; + result[2] = bytes[1]; + result[3] = bytes[0]; + } + break; + case BADC: // 小端字交换(32 位) + if (bytes.length >= 4) { + result[0] = bytes[1]; + result[1] = bytes[0]; + result[2] = bytes[3]; + result[3] = bytes[2]; + } + break; + default: + return bytes; + } + return result; + } + + @SuppressWarnings("EnhancedSwitchMigration") + private static int[] encodeToRegisters(BigDecimal rawValue, String rawDataType, String byteOrder, int registerCount) { + IotModbusRawDataTypeEnum dataTypeEnum = IotModbusRawDataTypeEnum.getByType(rawDataType); + if (dataTypeEnum == null) { + return new int[]{rawValue.intValue()}; + } + switch (dataTypeEnum) { + case BOOLEAN: + return new int[]{rawValue.intValue() != 0 ? 1 : 0}; + case INT16: + case UINT16: + return new int[]{rawValue.intValue() & 0xFFFF}; + case INT32: + return encodeInt32(rawValue.intValue(), byteOrder); + case UINT32: + // 使用 longValue() 避免超过 Integer.MAX_VALUE 时溢出, + // 强转 int 保留低 32 位 bit pattern,写入寄存器的字节是正确的无符号值 + return encodeInt32((int) rawValue.longValue(), byteOrder); + case FLOAT: + return encodeFloat(rawValue.floatValue(), byteOrder); + case DOUBLE: + return encodeDouble(rawValue.doubleValue(), byteOrder); + default: + return new int[]{rawValue.intValue()}; + } + } + + private static int[] encodeInt32(int value, String byteOrder) { + byte[] bytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(value).array(); + bytes = reorderBytes(bytes, byteOrder); + return bytesToRegisters(bytes); + } + + private static int[] encodeFloat(float value, String byteOrder) { + byte[] bytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putFloat(value).array(); + bytes = reorderBytes(bytes, byteOrder); + return bytesToRegisters(bytes); + } + + private static int[] encodeDouble(double value, String byteOrder) { + byte[] bytes = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).putDouble(value).array(); + bytes = reorderBytes(bytes, byteOrder); + return bytesToRegisters(bytes); + } + + private static int[] bytesToRegisters(byte[] bytes) { + int[] registers = new int[bytes.length / 2]; + for (int i = 0; i < registers.length; i++) { + registers[i] = ((bytes[i * 2] & 0xFF) << 8) | (bytes[i * 2 + 1] & 0xFF); + } + return registers; + } + + @SuppressWarnings("EnhancedSwitchMigration") + private static Object formatValue(BigDecimal value, String rawDataType) { + IotModbusRawDataTypeEnum dataTypeEnum = IotModbusRawDataTypeEnum.getByType(rawDataType); + if (dataTypeEnum == null) { + return value; + } + switch (dataTypeEnum) { + case BOOLEAN: + return value.intValue() != 0; + case INT16: + case INT32: + return value.intValue(); + case UINT16: + case UINT32: + return value.longValue(); + case FLOAT: + return value.floatValue(); + case DOUBLE: + return value.doubleValue(); + default: + return value; + } + } + + // ==================== 帧值提取 ==================== + + /** + * 从帧中提取寄存器值(FC01-04 读响应) + * + * @param frame 解码后的 Modbus 帧 + * @return 寄存器值数组(int[]),失败返回 null + */ + @SuppressWarnings("EnhancedSwitchMigration") + public static int[] extractValues(IotModbusFrame frame) { + if (frame == null || frame.isException()) { + return null; + } + byte[] pdu = frame.getPdu(); + if (pdu == null || pdu.length < 1) { + return null; + } + + int functionCode = frame.getFunctionCode(); + switch (functionCode) { + case FC_READ_COILS: + case FC_READ_DISCRETE_INPUTS: + return extractCoilValues(pdu); + case FC_READ_HOLDING_REGISTERS: + case FC_READ_INPUT_REGISTERS: + return extractRegisterValues(pdu); + default: + log.warn("[extractValues][不支持的功能码: {}]", functionCode); + return null; + } + } + + private static int[] extractCoilValues(byte[] pdu) { + if (pdu.length < 2) { + return null; + } + int byteCount = pdu[0] & 0xFF; + int bitCount = byteCount * 8; + int[] values = new int[bitCount]; + for (int i = 0; i < bitCount && (1 + i / 8) < pdu.length; i++) { + values[i] = ((pdu[1 + i / 8] >> (i % 8)) & 0x01); + } + return values; + } + + private static int[] extractRegisterValues(byte[] pdu) { + if (pdu.length < 2) { + return null; + } + int byteCount = pdu[0] & 0xFF; + int registerCount = byteCount / 2; + int[] values = new int[registerCount]; + for (int i = 0; i < registerCount && (1 + i * 2 + 1) < pdu.length; i++) { + values[i] = ((pdu[1 + i * 2] & 0xFF) << 8) | (pdu[1 + i * 2 + 1] & 0xFF); + } + return values; + } + + /** + * 从响应帧中提取 registerCount(通过 PDU 的 byteCount 推断) + * + * @param frame 解码后的 Modbus 响应帧 + * @return registerCount,无法提取时返回 -1(匹配时跳过校验) + */ + public static int extractRegisterCountFromResponse(IotModbusFrame frame) { + byte[] pdu = frame.getPdu(); + if (pdu == null || pdu.length < 1) { + return -1; + } + int byteCount = pdu[0] & 0xFF; + int fc = frame.getFunctionCode(); + // FC03/04 寄存器读响应:registerCount = byteCount / 2 + if (fc == FC_READ_HOLDING_REGISTERS || fc == FC_READ_INPUT_REGISTERS) { + return byteCount / 2; + } + // FC01/02 线圈/离散输入读响应:按 bit 打包有余位,无法精确反推,返回 -1 跳过校验 + return -1; + } + + // ==================== 点位查找 ==================== + + /** + * 查找点位配置 + * + * @param config 设备 Modbus 配置 + * @param identifier 点位标识符 + * @return 匹配的点位配置,未找到返回 null + */ + public static IotModbusPointRespDTO findPoint(IotModbusDeviceConfigRespDTO config, String identifier) { + if (config == null || StrUtil.isBlank(identifier)) { + return null; + } + return CollUtil.findOne(config.getPoints(), p -> identifier.equals(p.getIdentifier())); + } + + /** + * 根据点位 ID 查找点位配置 + * + * @param config 设备 Modbus 配置 + * @param pointId 点位 ID + * @return 匹配的点位配置,未找到返回 null + */ + public static IotModbusPointRespDTO findPointById(IotModbusDeviceConfigRespDTO config, Long pointId) { + if (config == null || pointId == null) { + return null; + } + return CollUtil.findOne(config.getPoints(), p -> p.getId().equals(pointId)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtils.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtils.java new file mode 100644 index 0000000000..1324f3aa5a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/common/utils/IotModbusTcpClientUtils.java @@ -0,0 +1,195 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils; + +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager; +import com.ghgande.j2mod.modbus.io.ModbusTCPTransaction; +import com.ghgande.j2mod.modbus.msg.*; +import com.ghgande.j2mod.modbus.procimg.InputRegister; +import com.ghgande.j2mod.modbus.procimg.Register; +import com.ghgande.j2mod.modbus.procimg.SimpleRegister; +import com.ghgande.j2mod.modbus.util.BitVector; +import io.vertx.core.Future; +import lombok.experimental.UtilityClass; +import lombok.extern.slf4j.Slf4j; + +import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.*; + +/** + * IoT Modbus TCP 客户端工具类 + *

+ * 封装基于 j2mod 的 Modbus TCP 读写操作: + * 1. 根据功能码创建对应的 Modbus 读/写请求 + * 2. 通过 {@link IotModbusTcpClientConnectionManager.ModbusConnection} 执行事务 + * 3. 从响应中提取原始值 + * + * @author 芋道源码 + */ +@UtilityClass +@Slf4j +public class IotModbusTcpClientUtils { + + /** + * 读取 Modbus 数据 + * + * @param connection Modbus 连接 + * @param slaveId 从站地址 + * @param point 点位配置 + * @return 原始值(int 数组) + */ + public static Future read(IotModbusTcpClientConnectionManager.ModbusConnection connection, + Integer slaveId, + IotModbusPointRespDTO point) { + return connection.executeBlocking(tcpConnection -> { + try { + // 1. 创建请求 + ModbusRequest request = createReadRequest(point.getFunctionCode(), + point.getRegisterAddress(), point.getRegisterCount()); + request.setUnitID(slaveId); + + // 2. 执行事务(请求) + ModbusTCPTransaction transaction = new ModbusTCPTransaction(tcpConnection); + transaction.setRequest(request); + transaction.execute(); + + // 3. 解析响应 + ModbusResponse response = transaction.getResponse(); + return extractValues(response, point.getFunctionCode()); + } catch (Exception e) { + throw new RuntimeException(String.format("Modbus 读取失败 [slaveId=%d, identifier=%s, address=%d]", + slaveId, point.getIdentifier(), point.getRegisterAddress()), e); + } + }); + } + + /** + * 写入 Modbus 数据 + * + * @param connection Modbus 连接 + * @param slaveId 从站地址 + * @param point 点位配置 + * @param values 要写入的值 + * @return 是否成功 + */ + public static Future write(IotModbusTcpClientConnectionManager.ModbusConnection connection, + Integer slaveId, + IotModbusPointRespDTO point, + int[] values) { + return connection.executeBlocking(tcpConnection -> { + try { + // 1. 创建请求 + ModbusRequest request = createWriteRequest(point.getFunctionCode(), + point.getRegisterAddress(), point.getRegisterCount(), values); + if (request == null) { + throw new RuntimeException("功能码 " + point.getFunctionCode() + " 不支持写操作"); + } + request.setUnitID(slaveId); + + // 2. 执行事务(请求) + ModbusTCPTransaction transaction = new ModbusTCPTransaction(tcpConnection); + transaction.setRequest(request); + transaction.execute(); + return true; + } catch (Exception e) { + throw new RuntimeException(String.format("Modbus 写入失败 [slaveId=%d, identifier=%s, address=%d]", + slaveId, point.getIdentifier(), point.getRegisterAddress()), e); + } + }); + } + + /** + * 创建读取请求 + */ + @SuppressWarnings("EnhancedSwitchMigration") + private static ModbusRequest createReadRequest(Integer functionCode, Integer address, Integer count) { + switch (functionCode) { + case FC_READ_COILS: + return new ReadCoilsRequest(address, count); + case FC_READ_DISCRETE_INPUTS: + return new ReadInputDiscretesRequest(address, count); + case FC_READ_HOLDING_REGISTERS: + return new ReadMultipleRegistersRequest(address, count); + case FC_READ_INPUT_REGISTERS: + return new ReadInputRegistersRequest(address, count); + default: + throw new IllegalArgumentException("不支持的功能码: " + functionCode); + } + } + + /** + * 创建写入请求 + */ + @SuppressWarnings("EnhancedSwitchMigration") + private static ModbusRequest createWriteRequest(Integer functionCode, Integer address, Integer count, int[] values) { + switch (functionCode) { + case FC_READ_COILS: // 写线圈(使用功能码 5 或 15) + if (count == 1) { + return new WriteCoilRequest(address, values[0] != 0); + } else { + BitVector bv = new BitVector(count); + for (int i = 0; i < Math.min(values.length, count); i++) { + bv.setBit(i, values[i] != 0); + } + return new WriteMultipleCoilsRequest(address, bv); + } + case FC_READ_HOLDING_REGISTERS: // 写保持寄存器(使用功能码 6 或 16) + if (count == 1) { + return new WriteSingleRegisterRequest(address, new SimpleRegister(values[0])); + } else { + Register[] registers = new SimpleRegister[count]; + for (int i = 0; i < count; i++) { + registers[i] = new SimpleRegister(i < values.length ? values[i] : 0); + } + return new WriteMultipleRegistersRequest(address, registers); + } + case FC_READ_DISCRETE_INPUTS: // 只读 + case FC_READ_INPUT_REGISTERS: // 只读 + return null; + default: + throw new IllegalArgumentException("不支持的功能码: " + functionCode); + } + } + + /** + * 从响应中提取值 + */ + @SuppressWarnings("EnhancedSwitchMigration") + private static int[] extractValues(ModbusResponse response, Integer functionCode) { + switch (functionCode) { + case FC_READ_COILS: + ReadCoilsResponse coilsResponse = (ReadCoilsResponse) response; + int bitCount = coilsResponse.getBitCount(); + int[] coilValues = new int[bitCount]; + for (int i = 0; i < bitCount; i++) { + coilValues[i] = coilsResponse.getCoilStatus(i) ? 1 : 0; + } + return coilValues; + case FC_READ_DISCRETE_INPUTS: + ReadInputDiscretesResponse discretesResponse = (ReadInputDiscretesResponse) response; + int discreteCount = discretesResponse.getBitCount(); + int[] discreteValues = new int[discreteCount]; + for (int i = 0; i < discreteCount; i++) { + discreteValues[i] = discretesResponse.getDiscreteStatus(i) ? 1 : 0; + } + return discreteValues; + case FC_READ_HOLDING_REGISTERS: + ReadMultipleRegistersResponse holdingResponse = (ReadMultipleRegistersResponse) response; + InputRegister[] holdingRegisters = holdingResponse.getRegisters(); + int[] holdingValues = new int[holdingRegisters.length]; + for (int i = 0; i < holdingRegisters.length; i++) { + holdingValues[i] = holdingRegisters[i].getValue(); + } + return holdingValues; + case FC_READ_INPUT_REGISTERS: + ReadInputRegistersResponse inputResponse = (ReadInputRegistersResponse) response; + InputRegister[] inputRegisters = inputResponse.getRegisters(); + int[] inputValues = new int[inputRegisters.length]; + for (int i = 0; i < inputRegisters.length; i++) { + inputValues[i] = inputRegisters[i].getValue(); + } + return inputValues; + default: + throw new IllegalArgumentException("不支持的功能码: " + functionCode); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IotModbusTcpClientConfig.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IotModbusTcpClientConfig.java new file mode 100644 index 0000000000..1743140953 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IotModbusTcpClientConfig.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * IoT Modbus TCP Client 协议配置 + * + * @author 芋道源码 + */ +@Data +public class IotModbusTcpClientConfig { + + /** + * 配置刷新间隔(秒) + */ + @NotNull(message = "配置刷新间隔不能为空") + @Min(value = 1, message = "配置刷新间隔不能小于 1 秒") + private Integer configRefreshInterval = 30; + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IotModbusTcpClientProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IotModbusTcpClientProtocol.java new file mode 100644 index 0000000000..f632b62fee --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IotModbusTcpClientProtocol.java @@ -0,0 +1,218 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient; + +import cn.hutool.core.lang.Assert; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream.IotModbusTcpClientDownstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream.IotModbusTcpClientDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.upstream.IotModbusTcpClientUpstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConfigCacheService; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientPollScheduler; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.Vertx; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RedissonClient; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * IoT 网关 Modbus TCP Client 协议:主动轮询 Modbus 从站设备数据 + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpClientProtocol implements IotProtocol { + + /** + * 协议配置 + */ + private final ProtocolProperties properties; + /** + * 服务器 ID(用于消息追踪,全局唯一) + */ + @Getter + private final String serverId; + + /** + * 运行状态 + */ + @Getter + private volatile boolean running = false; + + /** + * Vert.x 实例 + */ + private final Vertx vertx; + /** + * 配置刷新定时器 ID + */ + private Long configRefreshTimerId; + + /** + * 连接管理器 + */ + private final IotModbusTcpClientConnectionManager connectionManager; + /** + * 下行消息订阅者 + */ + private IotModbusTcpClientDownstreamSubscriber downstreamSubscriber; + + private final IotModbusTcpClientConfigCacheService configCacheService; + private final IotModbusTcpClientPollScheduler pollScheduler; + + public IotModbusTcpClientProtocol(ProtocolProperties properties) { + IotModbusTcpClientConfig modbusTcpClientConfig = properties.getModbusTcpClient(); + Assert.notNull(modbusTcpClientConfig, "Modbus TCP Client 协议配置(modbusTcpClient)不能为空"); + this.properties = properties; + this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort()); + + // 初始化 Vertx + this.vertx = Vertx.vertx(); + + // 初始化 Manager + RedissonClient redissonClient = SpringUtil.getBean(RedissonClient.class); + IotDeviceCommonApi deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + IotDeviceMessageService messageService = SpringUtil.getBean(IotDeviceMessageService.class); + this.configCacheService = new IotModbusTcpClientConfigCacheService(deviceApi); + this.connectionManager = new IotModbusTcpClientConnectionManager(redissonClient, vertx, + messageService, configCacheService, serverId); + + // 初始化 Handler + IotModbusTcpClientUpstreamHandler upstreamHandler = new IotModbusTcpClientUpstreamHandler(messageService, serverId); + + // 初始化轮询调度器 + this.pollScheduler = new IotModbusTcpClientPollScheduler(vertx, connectionManager, upstreamHandler, configCacheService); + } + + @Override + public String getId() { + return properties.getId(); + } + + @Override + public IotProtocolTypeEnum getType() { + return IotProtocolTypeEnum.MODBUS_TCP_CLIENT; + } + + @Override + public void start() { + if (running) { + log.warn("[start][IoT Modbus TCP Client 协议 {} 已经在运行中]", getId()); + return; + } + + try { + // 1.1 首次加载配置 + refreshConfig(); + // 1.2 启动配置刷新定时器 + int refreshInterval = properties.getModbusTcpClient().getConfigRefreshInterval(); + configRefreshTimerId = vertx.setPeriodic( + TimeUnit.SECONDS.toMillis(refreshInterval), + id -> refreshConfig() + ); + running = true; + log.info("[start][IoT Modbus TCP Client 协议 {} 启动成功,serverId={}]", getId(), serverId); + + // 2. 启动下行消息订阅者 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + IotModbusTcpClientDownstreamHandler downstreamHandler = new IotModbusTcpClientDownstreamHandler(connectionManager, + configCacheService); + this.downstreamSubscriber = new IotModbusTcpClientDownstreamSubscriber(this, downstreamHandler, messageBus); + this.downstreamSubscriber.start(); + } catch (Exception e) { + log.error("[start][IoT Modbus TCP Client 协议 {} 启动失败]", getId(), e); + stop0(); + throw e; + } + } + + @Override + public void stop() { + if (!running) { + return; + } + stop0(); + } + + private void stop0() { + // 1. 停止下行消息订阅者 + if (downstreamSubscriber != null) { + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT Modbus TCP Client 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT Modbus TCP Client 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + downstreamSubscriber = null; + } + + // 2.1 取消配置刷新定时器 + if (configRefreshTimerId != null) { + vertx.cancelTimer(configRefreshTimerId); + configRefreshTimerId = null; + } + // 2.2 停止轮询调度器 + pollScheduler.stopAll(); + // 2.3 关闭所有连接 + connectionManager.closeAll(); + + // 3. 关闭 Vert.x 实例 + if (vertx != null) { + try { + vertx.close().result(); + log.info("[stop][IoT Modbus TCP Client 协议 {} Vertx 已关闭]", getId()); + } catch (Exception e) { + log.error("[stop][IoT Modbus TCP Client 协议 {} Vertx 关闭失败]", getId(), e); + } + } + running = false; + log.info("[stop][IoT Modbus TCP Client 协议 {} 已停止]", getId()); + } + + /** + * 刷新配置 + */ + private synchronized void refreshConfig() { + try { + // 1. 从 biz 拉取最新配置(API 失败时返回 null) + List configs = configCacheService.refreshConfig(); + if (configs == null) { + log.warn("[refreshConfig][API 失败,跳过本轮刷新]"); + return; + } + log.debug("[refreshConfig][获取到 {} 个 Modbus 设备配置]", configs.size()); + + // 2. 更新连接和轮询任务 + for (IotModbusDeviceConfigRespDTO config : configs) { + try { + // 2.1 确保连接存在 + connectionManager.ensureConnection(config); + // 2.2 更新轮询任务 + pollScheduler.updatePolling(config); + } catch (Exception e) { + log.error("[refreshConfig][处理设备配置失败, deviceId={}]", config.getDeviceId(), e); + } + } + + // 3. 清理已删除设备的资源 + Set removedDeviceIds = configCacheService.cleanupRemovedDevices(configs); + for (Long deviceId : removedDeviceIds) { + pollScheduler.stopPolling(deviceId); + connectionManager.removeDevice(deviceId); + } + } catch (Exception e) { + log.error("[refreshConfig][刷新配置失败]", e); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamHandler.java new file mode 100644 index 0000000000..045e61fdf2 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamHandler.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusTcpClientUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConfigCacheService; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +/** + * IoT Modbus TCP Client 下行消息处理器 + *

+ * 负责: + * 1. 处理下行消息(如属性设置 thing.service.property.set) + * 2. 将属性值转换为 Modbus 写指令,通过 TCP 连接发送给设备 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Slf4j +public class IotModbusTcpClientDownstreamHandler { + + private final IotModbusTcpClientConnectionManager connectionManager; + private final IotModbusTcpClientConfigCacheService configCacheService; + + /** + * 处理下行消息 + */ + @SuppressWarnings({"unchecked", "DuplicatedCode"}) + public void handle(IotDeviceMessage message) { + // 1.1 检查是否是属性设置消息 + if (ObjUtil.equals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), message.getMethod())) { + return; + } + if (ObjUtil.notEqual(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), message.getMethod())) { + log.warn("[handle][忽略非属性设置消息: {}]", message.getMethod()); + return; + } + // 1.2 获取设备配置 + IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(message.getDeviceId()); + if (config == null) { + log.warn("[handle][设备 {} 没有 Modbus 配置]", message.getDeviceId()); + return; + } + + // 2. 解析属性值并写入 + Object params = message.getParams(); + if (!(params instanceof Map)) { + log.warn("[handle][params 不是 Map 类型: {}]", params); + return; + } + Map propertyMap = (Map) params; + for (Map.Entry entry : propertyMap.entrySet()) { + String identifier = entry.getKey(); + Object value = entry.getValue(); + // 2.1 查找对应的点位配置 + IotModbusPointRespDTO point = IotModbusCommonUtils.findPoint(config, identifier); + if (point == null) { + log.warn("[handle][设备 {} 没有点位配置: {}]", message.getDeviceId(), identifier); + continue; + } + // 2.2 检查是否支持写操作 + if (!IotModbusCommonUtils.isWritable(point.getFunctionCode())) { + log.warn("[handle][点位 {} 不支持写操作, 功能码={}]", identifier, point.getFunctionCode()); + continue; + } + + // 2.3 执行写入 + writeProperty(config, point, value); + } + } + + /** + * 写入属性值 + */ + private void writeProperty(IotModbusDeviceConfigRespDTO config, IotModbusPointRespDTO point, Object value) { + // 1.1 获取连接 + IotModbusTcpClientConnectionManager.ModbusConnection connection = connectionManager.getConnection(config.getDeviceId()); + if (connection == null) { + log.warn("[writeProperty][设备 {} 没有连接]", config.getDeviceId()); + return; + } + // 1.2 获取 slave ID + Integer slaveId = connectionManager.getSlaveId(config.getDeviceId()); + if (slaveId == null) { + log.warn("[writeProperty][设备 {} 没有 slaveId]", config.getDeviceId()); + return; + } + + // 2.1 转换属性值为原始值 + int[] rawValues = IotModbusCommonUtils.convertToRawValues(value, point); + // 2.2 执行 Modbus 写入 + IotModbusTcpClientUtils.write(connection, slaveId, point, rawValues) + .onSuccess(success -> log.info("[writeProperty][写入成功, deviceId={}, identifier={}, value={}]", + config.getDeviceId(), point.getIdentifier(), value)) + .onFailure(e -> log.error("[writeProperty][写入失败, deviceId={}, identifier={}]", + config.getDeviceId(), point.getIdentifier(), e)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriber.java new file mode 100644 index 0000000000..6c8a4be9bb --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/downstream/IotModbusTcpClientDownstreamSubscriber.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientProtocol; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT Modbus TCP 下行消息订阅器:订阅消息总线的下行消息并转发给处理器 + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpClientDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { + + private final IotModbusTcpClientDownstreamHandler downstreamHandler; + + public IotModbusTcpClientDownstreamSubscriber(IotModbusTcpClientProtocol protocol, + IotModbusTcpClientDownstreamHandler downstreamHandler, + IotMessageBus messageBus) { + super(protocol, messageBus); + this.downstreamHandler = downstreamHandler; + } + + @Override + protected void handleMessage(IotDeviceMessage message) { + downstreamHandler.handle(message); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/upstream/IotModbusTcpClientUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/upstream/IotModbusTcpClientUpstreamHandler.java new file mode 100644 index 0000000000..2b7e9c206a --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/handler/upstream/IotModbusTcpClientUpstreamHandler.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.upstream; + +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +/** + * IoT Modbus TCP 上行数据处理器:将原始值转换为物模型属性值并上报 + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpClientUpstreamHandler { + + private final IotDeviceMessageService messageService; + + private final String serverId; + + public IotModbusTcpClientUpstreamHandler(IotDeviceMessageService messageService, + String serverId) { + this.messageService = messageService; + this.serverId = serverId; + } + + /** + * 处理 Modbus 读取结果 + * + * @param config 设备配置 + * @param point 点位配置 + * @param rawValue 原始值(int 数组) + */ + public void handleReadResult(IotModbusDeviceConfigRespDTO config, + IotModbusPointRespDTO point, + int[] rawValue) { + try { + // 1.1 转换原始值为物模型属性值(点位翻译) + Object convertedValue = IotModbusCommonUtils.convertToPropertyValue(rawValue, point); + log.debug("[handleReadResult][设备={}, 属性={}, 原始值={}, 转换值={}]", + config.getDeviceId(), point.getIdentifier(), rawValue, convertedValue); + // 1.2 构造属性上报消息 + Map params = MapUtil.of(point.getIdentifier(), convertedValue); + IotDeviceMessage message = IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), params); + + // 2. 发送到消息总线 + messageService.sendDeviceMessage(message, config.getProductKey(), + config.getDeviceName(), serverId); + } catch (Exception e) { + log.error("[handleReadResult][处理读取结果失败, deviceId={}, identifier={}]", + config.getDeviceId(), point.getIdentifier(), e); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientConfigCacheService.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientConfigCacheService.java new file mode 100644 index 0000000000..b28e507c45 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientConfigCacheService.java @@ -0,0 +1,104 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusModeEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +/** + * IoT Modbus TCP Client 配置缓存服务 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Slf4j +public class IotModbusTcpClientConfigCacheService { + + private final IotDeviceCommonApi deviceApi; + + /** + * 配置缓存:deviceId -> 配置 + */ + private final Map configCache = new ConcurrentHashMap<>(); + + /** + * 已知的设备 ID 集合(作用:用于检测已删除的设备) + * + * @see #cleanupRemovedDevices(List) + */ + private final Set knownDeviceIds = ConcurrentHashMap.newKeySet(); + + /** + * 刷新配置 + * + * @return 最新的配置列表;API 失败时返回 null(调用方应跳过 cleanup) + */ + public List refreshConfig() { + try { + // 1. 从远程获取配置 + CommonResult> result = deviceApi.getModbusDeviceConfigList( + new IotModbusDeviceConfigListReqDTO().setStatus(CommonStatusEnum.ENABLE.getStatus()) + .setMode(IotModbusModeEnum.POLLING.getMode()).setProtocolType(IotProtocolTypeEnum.MODBUS_TCP_CLIENT.getType())); + result.checkError(); + List configs = result.getData(); + + // 2. 更新缓存(注意:不在这里更新 knownDeviceIds,由 cleanupRemovedDevices 统一管理) + for (IotModbusDeviceConfigRespDTO config : configs) { + configCache.put(config.getDeviceId(), config); + } + return configs; + } catch (Exception e) { + log.error("[refreshConfig][刷新配置失败]", e); + return null; + } + } + + /** + * 获取设备配置 + * + * @param deviceId 设备 ID + * @return 配置 + */ + public IotModbusDeviceConfigRespDTO getConfig(Long deviceId) { + return configCache.get(deviceId); + } + + /** + * 计算已删除设备的 ID 集合,清理缓存,并更新已知设备 ID 集合 + * + * @param currentConfigs 当前有效的配置列表 + * @return 已删除的设备 ID 集合 + */ + public Set cleanupRemovedDevices(List currentConfigs) { + // 1.1 获取当前有效的设备 ID + Set currentDeviceIds = convertSet(currentConfigs, IotModbusDeviceConfigRespDTO::getDeviceId); + // 1.2 找出已删除的设备(基于旧的 knownDeviceIds) + Set removedDeviceIds = new HashSet<>(knownDeviceIds); + removedDeviceIds.removeAll(currentDeviceIds); + + // 2. 清理已删除设备的缓存 + for (Long deviceId : removedDeviceIds) { + log.info("[cleanupRemovedDevices][清理已删除设备: {}]", deviceId); + configCache.remove(deviceId); + } + + // 3. 更新已知设备 ID 集合为当前有效的设备 ID + knownDeviceIds.clear(); + knownDeviceIds.addAll(currentDeviceIds); + return removedDeviceIds; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientConnectionManager.java new file mode 100644 index 0000000000..bfdacd020d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientConnectionManager.java @@ -0,0 +1,317 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import com.ghgande.j2mod.modbus.net.TCPMasterConnection; +import io.vertx.core.Context; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * IoT Modbus TCP 连接管理器 + *

+ * 统一管理 Modbus TCP 连接: + * 1. 管理 TCP 连接(相同 ip:port 共用连接) + * 2. 分布式锁管理(连接级别),避免多节点重复创建连接 + * 3. 连接重试和故障恢复 + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpClientConnectionManager { + + private static final String LOCK_KEY_PREFIX = "iot:modbus-tcp:connection:"; + + private final RedissonClient redissonClient; + private final Vertx vertx; + private final IotDeviceMessageService messageService; + private final IotModbusTcpClientConfigCacheService configCacheService; + private final String serverId; + + /** + * 连接池:key = ip:port + */ + private final Map connectionPool = new ConcurrentHashMap<>(); + + /** + * 设备 ID 到连接 key 的映射 + */ + private final Map deviceConnectionMap = new ConcurrentHashMap<>(); + + public IotModbusTcpClientConnectionManager(RedissonClient redissonClient, Vertx vertx, + IotDeviceMessageService messageService, + IotModbusTcpClientConfigCacheService configCacheService, + String serverId) { + this.redissonClient = redissonClient; + this.vertx = vertx; + this.messageService = messageService; + this.configCacheService = configCacheService; + this.serverId = serverId; + } + + /** + * 确保连接存在 + *

+ * 首次建连成功时,直接发送设备上线消息 + * + * @param config 设备配置 + */ + public void ensureConnection(IotModbusDeviceConfigRespDTO config) { + // 1.1 检查设备是否切换了 IP/端口,若是则先清理旧连接 + String connectionKey = buildConnectionKey(config.getIp(), config.getPort()); + String oldConnectionKey = deviceConnectionMap.get(config.getDeviceId()); + if (oldConnectionKey != null && ObjUtil.notEqual(oldConnectionKey, connectionKey)) { + log.info("[ensureConnection][设备 {} IP/端口变更: {} -> {}, 清理旧连接]", + config.getDeviceId(), oldConnectionKey, connectionKey); + removeDevice(config.getDeviceId()); + } + // 1.2 记录设备与连接的映射 + deviceConnectionMap.put(config.getDeviceId(), connectionKey); + + // 2. 情况一:连接已存在,注册设备并发送上线消息 + ModbusConnection connection = connectionPool.get(connectionKey); + if (connection != null) { + addDeviceAndOnline(connection, config); + return; + } + + // 3. 情况二:连接不存在,加分布式锁创建新连接 + RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + connectionKey); + if (!lock.tryLock()) { + log.debug("[ensureConnection][获取锁失败, 由其他节点负责: {}]", connectionKey); + return; + } + try { + // 3.1 double-check:拿到锁后再次检查,避免并发创建重复连接 + connection = connectionPool.get(connectionKey); + if (connection != null) { + addDeviceAndOnline(connection, config); + lock.unlock(); + return; + } + // 3.2 创建新连接 + connection = createConnection(config); + connection.setLock(lock); + connectionPool.put(connectionKey, connection); + log.info("[ensureConnection][创建 Modbus 连接成功: {}]", connectionKey); + // 3.3 注册设备并发送上线消息 + addDeviceAndOnline(connection, config); + } catch (Exception e) { + log.error("[ensureConnection][创建 Modbus 连接失败: {}]", connectionKey, e); + // 建连失败,释放锁让其他节点可重试 + lock.unlock(); + } + } + + /** + * 创建 Modbus TCP 连接 + */ + private ModbusConnection createConnection(IotModbusDeviceConfigRespDTO config) throws Exception { + // 1. 创建 TCP 连接 + TCPMasterConnection tcpConnection = new TCPMasterConnection(InetAddress.getByName(config.getIp())); + tcpConnection.setPort(config.getPort()); + tcpConnection.setTimeout(config.getTimeout()); + tcpConnection.connect(); + + // 2. 创建 Modbus 连接对象 + return new ModbusConnection() + .setConnectionKey(buildConnectionKey(config.getIp(), config.getPort())) + .setTcpConnection(tcpConnection).setContext(vertx.getOrCreateContext()) + .setTimeout(config.getTimeout()).setRetryInterval(config.getRetryInterval()); + } + + /** + * 获取连接 + */ + public ModbusConnection getConnection(Long deviceId) { + String connectionKey = deviceConnectionMap.get(deviceId); + if (connectionKey == null) { + return null; + } + return connectionPool.get(connectionKey); + } + + /** + * 获取设备的 slave ID + */ + public Integer getSlaveId(Long deviceId) { + ModbusConnection connection = getConnection(deviceId); + if (connection == null) { + return null; + } + return connection.getSlaveId(deviceId); + } + + /** + * 移除设备 + *

+ * 移除时直接发送设备下线消息 + */ + public void removeDevice(Long deviceId) { + // 1.1 移除设备时,发送下线消息 + sendOfflineMessage(deviceId); + // 1.2 移除设备引用 + String connectionKey = deviceConnectionMap.remove(deviceId); + if (connectionKey == null) { + return; + } + + // 2.1 移除连接中的设备引用 + ModbusConnection connection = connectionPool.get(connectionKey); + if (connection == null) { + return; + } + connection.removeDevice(deviceId); + // 2.2 如果没有设备引用了,关闭连接 + if (connection.getDeviceCount() == 0) { + closeConnection(connectionKey); + } + } + + // ==================== 设备连接 & 上下线消息 ==================== + + /** + * 注册设备到连接,并发送上线消息 + */ + private void addDeviceAndOnline(ModbusConnection connection, + IotModbusDeviceConfigRespDTO config) { + Integer previous = connection.addDevice(config.getDeviceId(), config.getSlaveId()); + // 首次注册,发送上线消息 + if (previous == null) { + sendOnlineMessage(config); + } + } + + /** + * 发送设备上线消息 + */ + private void sendOnlineMessage(IotModbusDeviceConfigRespDTO config) { + try { + IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); + messageService.sendDeviceMessage(onlineMessage, + config.getProductKey(), config.getDeviceName(), serverId); + } catch (Exception ex) { + log.error("[sendOnlineMessage][发送设备上线消息失败, deviceId={}]", config.getDeviceId(), ex); + } + } + + /** + * 发送设备下线消息 + */ + private void sendOfflineMessage(Long deviceId) { + IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(deviceId); + if (config == null) { + return; + } + try { + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); + messageService.sendDeviceMessage(offlineMessage, + config.getProductKey(), config.getDeviceName(), serverId); + } catch (Exception ex) { + log.error("[sendOfflineMessage][发送设备下线消息失败, deviceId={}]", deviceId, ex); + } + } + + /** + * 关闭指定连接 + */ + private void closeConnection(String connectionKey) { + ModbusConnection connection = connectionPool.remove(connectionKey); + if (connection == null) { + return; + } + + try { + if (connection.getTcpConnection() != null) { + connection.getTcpConnection().close(); + } + // 释放分布式锁,让其他节点可接管 + RLock lock = connection.getLock(); + if (lock != null && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + log.info("[closeConnection][关闭 Modbus 连接: {}]", connectionKey); + } catch (Exception e) { + log.error("[closeConnection][关闭连接失败: {}]", connectionKey, e); + } + } + + /** + * 关闭所有连接 + */ + public void closeAll() { + // 先复制再遍历,避免 closeConnection 中 remove 导致并发修改 + List connectionKeys = new ArrayList<>(connectionPool.keySet()); + for (String connectionKey : connectionKeys) { + closeConnection(connectionKey); + } + deviceConnectionMap.clear(); + } + + private String buildConnectionKey(String ip, Integer port) { + return ip + ":" + port; + } + + /** + * Modbus 连接信息 + */ + @Data + public static class ModbusConnection { + + private String connectionKey; + private TCPMasterConnection tcpConnection; + private Integer timeout; + private Integer retryInterval; + /** + * 设备 ID 到 slave ID 的映射 + */ + private final Map deviceSlaveMap = new ConcurrentHashMap<>(); + + /** + * 分布式锁,锁住连接的创建和销毁,避免多节点重复连接同一从站 + */ + private RLock lock; + + /** + * Vert.x Context,用于 executeBlocking 执行 Modbus 操作,保证同一连接的操作串行执行 + */ + private Context context; + + public Integer addDevice(Long deviceId, Integer slaveId) { + return deviceSlaveMap.putIfAbsent(deviceId, slaveId); + } + + public void removeDevice(Long deviceId) { + deviceSlaveMap.remove(deviceId); + } + + public int getDeviceCount() { + return deviceSlaveMap.size(); + } + + public Integer getSlaveId(Long deviceId) { + return deviceSlaveMap.get(deviceId); + } + + /** + * 执行 Modbus 读取操作(阻塞方式,在 Vert.x worker 线程执行) + */ + public Future executeBlocking(java.util.function.Function operation) { + // ordered=true 保证同一 Context 的操作串行执行,不同连接之间可并行 + return context.executeBlocking(() -> operation.apply(tcpConnection), true); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollScheduler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollScheduler.java new file mode 100644 index 0000000000..946937d405 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/manager/IotModbusTcpClientPollScheduler.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.AbstractIotModbusPollScheduler; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusTcpClientUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.upstream.IotModbusTcpClientUpstreamHandler; +import io.vertx.core.Vertx; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT Modbus TCP Client 轮询调度器:管理点位的轮询定时器,调度读取任务并上报结果 + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpClientPollScheduler extends AbstractIotModbusPollScheduler { + + private final IotModbusTcpClientConnectionManager connectionManager; + private final IotModbusTcpClientUpstreamHandler upstreamHandler; + private final IotModbusTcpClientConfigCacheService configCacheService; + + public IotModbusTcpClientPollScheduler(Vertx vertx, + IotModbusTcpClientConnectionManager connectionManager, + IotModbusTcpClientUpstreamHandler upstreamHandler, + IotModbusTcpClientConfigCacheService configCacheService) { + super(vertx); + this.connectionManager = connectionManager; + this.upstreamHandler = upstreamHandler; + this.configCacheService = configCacheService; + } + + // ========== 轮询执行 ========== + + /** + * 轮询单个点位 + */ + @Override + protected void pollPoint(Long deviceId, Long pointId) { + // 1.1 从 configCache 获取最新配置 + IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(deviceId); + if (config == null || CollUtil.isEmpty(config.getPoints())) { + log.warn("[pollPoint][设备 {} 没有配置]", deviceId); + return; + } + // 1.2 查找点位 + IotModbusPointRespDTO point = IotModbusCommonUtils.findPointById(config, pointId); + if (point == null) { + log.warn("[pollPoint][设备 {} 点位 {} 未找到]", deviceId, pointId); + return; + } + + // 2.1 获取连接 + IotModbusTcpClientConnectionManager.ModbusConnection connection = connectionManager.getConnection(deviceId); + if (connection == null) { + log.warn("[pollPoint][设备 {} 没有连接]", deviceId); + return; + } + // 2.2 获取 slave ID + Integer slaveId = connectionManager.getSlaveId(deviceId); + Assert.notNull(slaveId, "设备 {} 没有配置 slaveId", deviceId); + + // 3. 执行 Modbus 读取 + IotModbusTcpClientUtils.read(connection, slaveId, point) + .onSuccess(rawValue -> upstreamHandler.handleReadResult(config, point, rawValue)) + .onFailure(e -> log.error("[pollPoint][读取点位失败, deviceId={}, identifier={}]", + deviceId, point.getIdentifier(), e)); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/package-info.java new file mode 100644 index 0000000000..3bd4fa95f4 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/package-info.java @@ -0,0 +1,6 @@ +/** + * Modbus TCP Client(主站)协议:网关主动连接并轮询 Modbus 从站设备 + *

+ * 基于 j2mod 实现,支持 FC01-04 读、FC05/06/15/16 写,定时轮询 + 下发属性设置 + */ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerConfig.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerConfig.java new file mode 100644 index 0000000000..5b4fd22362 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerConfig.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * IoT Modbus TCP Server 协议配置 + * + * @author 芋道源码 + */ +@Data +public class IotModbusTcpServerConfig { + + /** + * 配置刷新间隔(秒) + */ + @NotNull(message = "配置刷新间隔不能为空") + @Min(value = 1, message = "配置刷新间隔不能小于 1 秒") + private Integer configRefreshInterval = 30; + + /** + * 自定义功能码(用于认证等扩展交互) + * Modbus 协议保留 65-72 给用户自定义,默认 65 + */ + @NotNull(message = "自定义功能码不能为空") + @Min(value = 65, message = "自定义功能码不能小于 65") + @Max(value = 72, message = "自定义功能码不能大于 72") + private Integer customFunctionCode = 65; + + /** + * Pending Request 超时时间(毫秒) + */ + @NotNull(message = "请求超时时间不能为空") + private Integer requestTimeout = 5000; + + /** + * Pending Request 清理间隔(毫秒) + */ + @NotNull(message = "请求清理间隔不能为空") + private Integer requestCleanupInterval = 10000; + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerProtocol.java new file mode 100644 index 0000000000..80ce9eec08 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerProtocol.java @@ -0,0 +1,334 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties; +import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameDecoder; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream.IotModbusTcpServerDownstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream.IotModbusTcpServerDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.upstream.IotModbusTcpServerUpstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConfigCacheService; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPollScheduler; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.Vertx; +import io.vertx.core.net.NetServer; +import io.vertx.core.net.NetServerOptions; +import io.vertx.core.net.NetSocket; +import io.vertx.core.parsetools.RecordParser; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * IoT 网关 Modbus TCP Server 协议 + *

+ * 作为 TCP Server 接收设备主动连接: + * 1. 设备通过自定义功能码(FC 65)发送认证请求 + * 2. 认证成功后,网关主动发送 Modbus 读请求,设备响应(云端轮询模式) + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpServerProtocol implements IotProtocol { + + /** + * 协议配置 + */ + private final ProtocolProperties properties; + /** + * 服务器 ID(用于消息追踪,全局唯一) + */ + @Getter + private final String serverId; + + /** + * 运行状态 + */ + @Getter + private volatile boolean running = false; + + /** + * Vert.x 实例 + */ + private final Vertx vertx; + /** + * TCP Server + */ + private NetServer netServer; + /** + * 配置刷新定时器 ID + */ + private Long configRefreshTimerId; + /** + * Pending Request 清理定时器 ID + */ + private Long requestCleanupTimerId; + + /** + * 连接管理器 + */ + private final IotModbusTcpServerConnectionManager connectionManager; + /** + * 下行消息订阅者 + */ + private IotModbusTcpServerDownstreamSubscriber downstreamSubscriber; + + private final IotModbusFrameDecoder frameDecoder; + @SuppressWarnings("FieldCanBeLocal") + private final IotModbusFrameEncoder frameEncoder; + + private final IotModbusTcpServerConfigCacheService configCacheService; + private final IotModbusTcpServerPendingRequestManager pendingRequestManager; + private final IotModbusTcpServerUpstreamHandler upstreamHandler; + private final IotModbusTcpServerPollScheduler pollScheduler; + private final IotDeviceMessageService messageService; + + public IotModbusTcpServerProtocol(ProtocolProperties properties) { + IotModbusTcpServerConfig slaveConfig = properties.getModbusTcpServer(); + Assert.notNull(slaveConfig, "Modbus TCP Server 协议配置(modbusTcpServer)不能为空"); + this.properties = properties; + this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort()); + + // 初始化 Vertx + this.vertx = Vertx.vertx(); + + // 初始化 Manager + IotDeviceCommonApi deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); + this.connectionManager = new IotModbusTcpServerConnectionManager(); + this.configCacheService = new IotModbusTcpServerConfigCacheService(deviceApi); + this.pendingRequestManager = new IotModbusTcpServerPendingRequestManager(); + + // 初始化帧编解码器 + this.frameDecoder = new IotModbusFrameDecoder(slaveConfig.getCustomFunctionCode()); + this.frameEncoder = new IotModbusFrameEncoder(slaveConfig.getCustomFunctionCode()); + + // 初始化共享事务 ID 自增器(PollScheduler 和 DownstreamHandler 共用,避免 transactionId 冲突) + AtomicInteger transactionIdCounter = new AtomicInteger(0); + // 初始化轮询调度器 + this.pollScheduler = new IotModbusTcpServerPollScheduler( + vertx, connectionManager, frameEncoder, pendingRequestManager, + slaveConfig.getRequestTimeout(), transactionIdCounter, configCacheService); + + // 初始化 Handler + this.messageService = SpringUtil.getBean(IotDeviceMessageService.class); + IotDeviceService deviceService = SpringUtil.getBean(IotDeviceService.class); + this.upstreamHandler = new IotModbusTcpServerUpstreamHandler( + deviceApi, this.messageService, frameEncoder, + connectionManager, configCacheService, pendingRequestManager, + pollScheduler, deviceService, serverId); + } + + @Override + public String getId() { + return properties.getId(); + } + + @Override + public IotProtocolTypeEnum getType() { + return IotProtocolTypeEnum.MODBUS_TCP_SERVER; + } + + @Override + public void start() { + if (running) { + log.warn("[start][IoT Modbus TCP Server 协议 {} 已经在运行中]", getId()); + return; + } + + try { + // 1. 启动配置刷新定时器 + IotModbusTcpServerConfig slaveConfig = properties.getModbusTcpServer(); + configRefreshTimerId = vertx.setPeriodic( + TimeUnit.SECONDS.toMillis(slaveConfig.getConfigRefreshInterval()), + id -> refreshConfig()); + + // 2.1 启动 TCP Server + startTcpServer(); + + // 2.2 启动 PendingRequest 清理定时器 + requestCleanupTimerId = vertx.setPeriodic( + slaveConfig.getRequestCleanupInterval(), + id -> pendingRequestManager.cleanupExpired()); + running = true; + log.info("[start][IoT Modbus TCP Server 协议 {} 启动成功, serverId={}, port={}]", + getId(), serverId, properties.getPort()); + + // 3. 启动下行消息订阅 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + IotModbusTcpServerDownstreamHandler downstreamHandler = new IotModbusTcpServerDownstreamHandler( + connectionManager, configCacheService, frameEncoder, this.pollScheduler.getTransactionIdCounter()); + this.downstreamSubscriber = new IotModbusTcpServerDownstreamSubscriber( + this, downstreamHandler, messageBus); + downstreamSubscriber.start(); + } catch (Exception e) { + log.error("[start][IoT Modbus TCP Server 协议 {} 启动失败]", getId(), e); + stop0(); + throw e; + } + } + + @Override + public void stop() { + if (!running) { + return; + } + stop0(); + } + + private void stop0() { + // 1. 停止下行消息订阅 + if (downstreamSubscriber != null) { + try { + downstreamSubscriber.stop(); + } catch (Exception e) { + log.error("[stop][下行消息订阅器停止失败]", e); + } + downstreamSubscriber = null; + } + + // 2.1 取消定时器 + if (configRefreshTimerId != null) { + vertx.cancelTimer(configRefreshTimerId); + configRefreshTimerId = null; + } + if (requestCleanupTimerId != null) { + vertx.cancelTimer(requestCleanupTimerId); + requestCleanupTimerId = null; + } + // 2.2 停止轮询 + pollScheduler.stopAll(); + // 2.3 清理 PendingRequest + pendingRequestManager.clear(); + // 2.4 关闭所有连接 + connectionManager.closeAll(); + // 2.5 关闭 TCP Server + if (netServer != null) { + try { + netServer.close().result(); + log.info("[stop][TCP Server 已关闭]"); + } catch (Exception e) { + log.error("[stop][TCP Server 关闭失败]", e); + } + netServer = null; + } + + // 3. 关闭 Vertx + if (vertx != null) { + try { + vertx.close().result(); + } catch (Exception e) { + log.error("[stop][Vertx 关闭失败]", e); + } + } + running = false; + log.info("[stop][IoT Modbus TCP Server 协议 {} 已停止]", getId()); + } + + /** + * 启动 TCP Server + */ + private void startTcpServer() { + // 1. 创建 TCP Server + NetServerOptions options = new NetServerOptions() + .setPort(properties.getPort()); + netServer = vertx.createNetServer(options); + + // 2. 设置连接处理器 + netServer.connectHandler(this::handleConnection); + try { + netServer.listen().toCompletionStage().toCompletableFuture().get(); + log.info("[startTcpServer][TCP Server 启动成功, port={}]", properties.getPort()); + } catch (Exception e) { + throw new RuntimeException("[startTcpServer][TCP Server 启动失败]", e); + } + } + + /** + * 处理新连接 + */ + private void handleConnection(NetSocket socket) { + log.info("[handleConnection][新连接, remoteAddress={}]", socket.remoteAddress()); + + // 1. 创建 RecordParser 并设置为数据处理器 + RecordParser recordParser = frameDecoder.createRecordParser((frame, frameFormat) -> { + // 【重要】帧处理分发,即消息处理 + upstreamHandler.handleFrame(socket, frame, frameFormat); + }); + socket.handler(recordParser); + + // 2.1 连接关闭处理 + socket.closeHandler(v -> { + ConnectionInfo info = connectionManager.removeConnection(socket); + if (info == null || info.getDeviceId() == null) { + log.info("[handleConnection][未认证连接关闭, remoteAddress={}]", socket.remoteAddress()); + return; + } + pollScheduler.stopPolling(info.getDeviceId()); + pendingRequestManager.removeDevice(info.getDeviceId()); + configCacheService.removeConfig(info.getDeviceId()); + // 发送设备下线消息 + try { + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); + messageService.sendDeviceMessage(offlineMessage, info.getProductKey(), info.getDeviceName(), serverId); + } catch (Exception ex) { + log.error("[handleConnection][发送设备下线消息失败, deviceId={}]", info.getDeviceId(), ex); + } + log.info("[handleConnection][连接关闭, deviceId={}, remoteAddress={}]", + info.getDeviceId(), socket.remoteAddress()); + }); + // 2.2 异常处理 + socket.exceptionHandler(e -> { + log.error("[handleConnection][连接异常, remoteAddress={}]", socket.remoteAddress(), e); + socket.close(); + }); + } + + /** + * 刷新已连接设备的配置(定时调用) + */ + private synchronized void refreshConfig() { + try { + // 1. 只刷新已连接设备的配置 + Set connectedDeviceIds = connectionManager.getConnectedDeviceIds(); + if (CollUtil.isEmpty(connectedDeviceIds)) { + return; + } + List configs = + configCacheService.refreshConnectedDeviceConfigList(connectedDeviceIds); + if (configs == null) { + log.warn("[refreshConfig][刷新配置失败,跳过本次刷新]"); + return; + } + log.debug("[refreshConfig][刷新了 {} 个已连接设备的配置]", configs.size()); + + // 2. 更新已连接设备的轮询任务 + for (IotModbusDeviceConfigRespDTO config : configs) { + try { + pollScheduler.updatePolling(config); + } catch (Exception e) { + log.error("[refreshConfig][处理设备配置失败, deviceId={}]", config.getDeviceId(), e); + } + } + } catch (Exception e) { + log.error("[refreshConfig][刷新配置失败]", e); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrame.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrame.java new file mode 100644 index 0000000000..7aeca99182 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrame.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec; + +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * IoT Modbus 统一帧数据模型(TCP/RTU 公用) + * + * @author 芋道源码 + */ +@Data +@Accessors(chain = true) +public class IotModbusFrame { + + /** + * 从站地址 + */ + private int slaveId; + /** + * 功能码 + */ + private int functionCode; + /** + * PDU 数据(不含 slaveId) + */ + private byte[] pdu; + /** + * 事务标识符 + *

+ * 仅 {@link IotModbusFrameFormatEnum#MODBUS_TCP} 格式有值 + */ + private Integer transactionId; + + /** + * 异常码 + *

+ * 当功能码最高位为 1 时(异常响应),此字段存储异常码。 + * + * @see IotModbusCommonUtils#FC_EXCEPTION_MASK + */ + private Integer exceptionCode; + + /** + * 自定义功能码时的 JSON 字符串(用于 auth 认证等等) + */ + private String customData; + + /** + * 是否异常响应(基于 exceptionCode 是否有值判断) + */ + public boolean isException() { + return exceptionCode != null; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrameDecoder.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrameDecoder.java new file mode 100644 index 0000000000..5b92c3ea7f --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrameDecoder.java @@ -0,0 +1,477 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec; + +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import io.vertx.core.Handler; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.parsetools.RecordParser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.function.BiConsumer; + +/** + * IoT Modbus 帧解码器:集成 TCP 拆包 + 帧格式探测 + 帧解码,一条龙完成从 TCP 字节流到 IotModbusFrame 的转换。 + *

+ * 流程: + * 1. 首帧检测:读前 6 字节,判断 MODBUS_TCP(ProtocolId==0x0000 且 Length 合理)或 MODBUS_RTU + * 2. 检测后切换到对应的拆包 Handler,并将首包 6 字节通过 handleFirstBytes() 交给新 Handler 处理 + * 3. 拆包完成后解码为 IotModbusFrame,通过回调返回 + * - MODBUS_TCP:两阶段 RecordParser(MBAP length 字段驱动) + * - MODBUS_RTU:功能码驱动的状态机 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Slf4j +public class IotModbusFrameDecoder { + + private static final Boolean REQUEST_MODE_DEFAULT = false; + + /** + * 自定义功能码 + */ + private final int customFunctionCode; + + /** + * 创建带自动帧格式检测的 RecordParser(默认响应模式) + * + * @param frameHandler 完整帧回调(解码后的 IotModbusFrame + 检测到的帧格式) + * @return RecordParser 实例 + */ + public RecordParser createRecordParser(BiConsumer frameHandler) { + return createRecordParser(frameHandler, REQUEST_MODE_DEFAULT); + } + + /** + * 创建带自动帧格式检测的 RecordParser + * + * @param frameHandler 完整帧回调(解码后的 IotModbusFrame + 检测到的帧格式) + * @param requestMode 是否为请求模式(true:接收方收到的是 Modbus 请求帧,FC01-04 按固定 8 字节解析; + * false:接收方收到的是 Modbus 响应帧,FC01-04 按 byteCount 变长解析) + * @return RecordParser 实例 + */ + public RecordParser createRecordParser(BiConsumer frameHandler, + boolean requestMode) { + // 先创建一个 RecordParser:使用 fixedSizeMode(6) 读取首帧前 6 字节进行帧格式检测 + RecordParser parser = RecordParser.newFixed(6); + parser.handler(new DetectPhaseHandler(parser, customFunctionCode, frameHandler, requestMode)); + return parser; + } + + // ==================== 帧解码 ==================== + + /** + * 解码响应帧(拆包后的完整帧 byte[]) + * + * @param data 完整帧字节数组 + * @param format 帧格式 + * @return 解码后的 IotModbusFrame + */ + private IotModbusFrame decodeResponse(byte[] data, IotModbusFrameFormatEnum format) { + if (format == IotModbusFrameFormatEnum.MODBUS_TCP) { + return decodeTcpResponse(data); + } else { + return decodeRtuResponse(data); + } + } + + /** + * 解码 MODBUS_TCP 响应 + * 格式:[TransactionId(2)] [ProtocolId(2)] [Length(2)] [UnitId(1)] [FC(1)] [Data...] + */ + private IotModbusFrame decodeTcpResponse(byte[] data) { + if (data.length < 8) { + log.warn("[decodeTcpResponse][数据长度不足: {}]", data.length); + return null; + } + ByteBuffer buf = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN); + int transactionId = buf.getShort() & 0xFFFF; + buf.getShort(); // protocolId:固定 0x0000,Modbus 协议标识 + buf.getShort(); // length:后续字节数(UnitId + PDU),拆包阶段已使用 + int slaveId = buf.get() & 0xFF; + int functionCode = buf.get() & 0xFF; + // 提取 PDU 数据(从 functionCode 之后到末尾) + byte[] pdu = new byte[data.length - 8]; + System.arraycopy(data, 8, pdu, 0, pdu.length); + // 构建 IotModbusFrame + return buildFrame(slaveId, functionCode, pdu, transactionId); + } + + /** + * 解码 MODBUS_RTU 响应 + * 格式:[SlaveId(1)] [FC(1)] [Data...] [CRC(2)] + */ + private IotModbusFrame decodeRtuResponse(byte[] data) { + if (data.length < 4) { + log.warn("[decodeRtuResponse][数据长度不足: {}]", data.length); + return null; + } + // 校验 CRC + if (!IotModbusCommonUtils.verifyCrc16(data)) { + log.warn("[decodeRtuResponse][CRC 校验失败]"); + return null; + } + int slaveId = data[0] & 0xFF; + int functionCode = data[1] & 0xFF; + // PDU 数据(不含 slaveId、functionCode、CRC) + byte[] pdu = new byte[data.length - 4]; + System.arraycopy(data, 2, pdu, 0, pdu.length); + // 构建 IotModbusFrame + return buildFrame(slaveId, functionCode, pdu, null); + } + + /** + * 构建 IotModbusFrame + */ + private IotModbusFrame buildFrame(int slaveId, int functionCode, byte[] pdu, Integer transactionId) { + IotModbusFrame frame = new IotModbusFrame() + .setSlaveId(slaveId) + .setFunctionCode(functionCode) + .setPdu(pdu) + .setTransactionId(transactionId); + // 异常响应 + if (IotModbusCommonUtils.isExceptionResponse(functionCode)) { + frame.setFunctionCode(IotModbusCommonUtils.extractOriginalFunctionCode(functionCode)); + if (pdu.length >= 1) { + frame.setExceptionCode(pdu[0] & 0xFF); + } + return frame; + } + // 自定义功能码 + if (functionCode == customFunctionCode) { + // data 区格式:[byteCount(1)] [JSON data(N)] + if (pdu.length >= 1) { + int byteCount = pdu[0] & 0xFF; + if (pdu.length >= 1 + byteCount) { + frame.setCustomData(new String(pdu, 1, byteCount, StandardCharsets.UTF_8)); + } + } + } + return frame; + } + + // ==================== 拆包 Handler ==================== + + /** + * 帧格式检测阶段 Handler(仅处理首包,探测后切换到对应的拆包 Handler) + */ + @RequiredArgsConstructor + private class DetectPhaseHandler implements Handler { + + private final RecordParser parser; + private final int customFunctionCode; + private final BiConsumer frameHandler; + private final boolean requestMode; + + @Override + public void handle(Buffer buffer) { + // 检测帧格式:protocolId==0x0000 且 length 合法 → MODBUS_TCP,否则 → MODBUS_RTU + byte[] bytes = buffer.getBytes(); + int protocolId = ((bytes[2] & 0xFF) << 8) | (bytes[3] & 0xFF); + int length = ((bytes[4] & 0xFF) << 8) | (bytes[5] & 0xFF); + + // 分别处理 MODBUS_TCP、MODBUS_RTU 两种情况 + if (protocolId == 0x0000 && length >= 1 && length <= 253) { + // MODBUS_TCP:切换到 TCP 拆包 Handler + log.debug("[DetectPhaseHandler][检测到 MODBUS_TCP 帧格式]"); + TcpFrameHandler tcpHandler = new TcpFrameHandler(parser, frameHandler); + parser.handler(tcpHandler); + // 当前 bytes 就是 MBAP 的前 6 字节,直接交给 tcpHandler 处理 + tcpHandler.handleFirstBytes(bytes); + } else { + // MODBUS_RTU:切换到 RTU 拆包 Handler + log.debug("[DetectPhaseHandler][检测到 MODBUS_RTU 帧格式]"); + RtuFrameHandler rtuHandler = new RtuFrameHandler(parser, frameHandler, customFunctionCode, requestMode); + parser.handler(rtuHandler); + // 当前 bytes 包含前 6 字节(slaveId + FC + 部分数据),交给 rtuHandler 处理 + rtuHandler.handleFirstBytes(bytes); + } + } + } + + /** + * MODBUS_TCP 拆包 Handler(两阶段 RecordParser) + *

+ * Phase 1: fixedSizeMode(6) → 读 MBAP 前 6 字节,提取 length + * Phase 2: fixedSizeMode(length) → 读 unitId + PDU + */ + @RequiredArgsConstructor + private class TcpFrameHandler implements Handler { + + private final RecordParser parser; + private final BiConsumer frameHandler; + + private byte[] mbapHeader; + private boolean waitingForBody = false; + + /** + * 处理探测阶段传来的首帧 6 字节(即 MBAP 头) + * + * @param bytes 探测阶段消费的 6 字节 + */ + void handleFirstBytes(byte[] bytes) { + int length = ((bytes[4] & 0xFF) << 8) | (bytes[5] & 0xFF); + this.mbapHeader = bytes; + this.waitingForBody = true; + parser.fixedSizeMode(length); + } + + @Override + public void handle(Buffer buffer) { + if (waitingForBody) { + // Phase 2: 收到 body(unitId + PDU) + byte[] body = buffer.getBytes(); + // 拼接完整帧:MBAP(6) + body + byte[] fullFrame = new byte[mbapHeader.length + body.length]; + System.arraycopy(mbapHeader, 0, fullFrame, 0, mbapHeader.length); + System.arraycopy(body, 0, fullFrame, mbapHeader.length, body.length); + // 解码并回调 + IotModbusFrame frame = decodeResponse(fullFrame, IotModbusFrameFormatEnum.MODBUS_TCP); + if (frame != null) { + frameHandler.accept(frame, IotModbusFrameFormatEnum.MODBUS_TCP); + } + // 切回 Phase 1 + waitingForBody = false; + mbapHeader = null; + parser.fixedSizeMode(6); + } else { + // Phase 1: 收到 MBAP 头 6 字节 + byte[] header = buffer.getBytes(); + int length = ((header[4] & 0xFF) << 8) | (header[5] & 0xFF); + if (length < 1 || length > 253) { + log.warn("[TcpFrameHandler][MBAP Length 异常: {}]", length); + parser.fixedSizeMode(6); + return; + } + this.mbapHeader = header; + this.waitingForBody = true; + parser.fixedSizeMode(length); + } + } + } + + /** + * MODBUS_RTU 拆包 Handler(功能码驱动的状态机) + *

+ * 状态机流程: + * Phase 1: fixedSizeMode(2) → 读 slaveId + functionCode + * Phase 2: 根据 functionCode 确定剩余长度: + * - 异常响应 (FC & EXCEPTION_MASK):fixedSizeMode(3) → exceptionCode(1) + CRC(2) + * - 自定义 FC / FC01-04 响应:fixedSizeMode(1) → 读 byteCount → fixedSizeMode(byteCount + 2) + * - FC05/06 响应:fixedSizeMode(6) → addr(2) + value(2) + CRC(2) + * - FC15/16 响应:fixedSizeMode(6) → addr(2) + quantity(2) + CRC(2) + *

+ * 请求模式(requestMode=true)时,FC01-04 按固定 8 字节解析(与写响应相同路径), + * 因为读请求格式为 [SlaveId(1)][FC(1)][StartAddr(2)][Quantity(2)][CRC(2)] + */ + @RequiredArgsConstructor + private class RtuFrameHandler implements Handler { + + private static final int STATE_HEADER = 0; + private static final int STATE_EXCEPTION_BODY = 1; + private static final int STATE_READ_BYTE_COUNT = 2; + private static final int STATE_READ_DATA = 3; + private static final int STATE_WRITE_BODY = 4; + + private final RecordParser parser; + private final BiConsumer frameHandler; + private final int customFunctionCode; + /** + * 请求模式: + * - true 表示接收方收到的是 Modbus 请求帧(如设备端收到网关下发的读请求),FC01-04 按固定 8 字节帧解析 + * - false 表示接收方收到的是 Modbus 响应帧,FC01-04 按 byteCount 变长解析 + */ + private final boolean requestMode; + + private int state = STATE_HEADER; + private byte slaveId; + private byte functionCode; + private byte byteCount; + private Buffer pendingData; + private int expectedDataLen; + + /** + * 处理探测阶段传来的首帧 6 字节 + *

+ * 由于 RTU 首帧被探测阶段消费了 6 字节,这里需要从中提取 slaveId + FC 并根据 FC 处理剩余数据 + * + * @param bytes 探测阶段消费的 6 字节:[slaveId][FC][...4 bytes...] + */ + void handleFirstBytes(byte[] bytes) { + this.slaveId = bytes[0]; + this.functionCode = bytes[1]; + int fc = functionCode & 0xFF; + if (IotModbusCommonUtils.isExceptionResponse(fc)) { + // 异常响应:完整帧 = slaveId(1) + FC(1) + exceptionCode(1) + CRC(2) = 5 字节 + // 已有 6 字节(多 1 字节),取前 5 字节组装 + Buffer frame = Buffer.buffer(5); + frame.appendByte(slaveId); + frame.appendByte(functionCode); + frame.appendBytes(bytes, 2, 3); // exceptionCode + CRC + emitFrame(frame); + resetToHeader(); + } else if (IotModbusCommonUtils.isReadResponse(fc) && requestMode) { + // 请求模式下的读请求:固定 8 字节 [SlaveId(1)][FC(1)][StartAddr(2)][Quantity(2)][CRC(2)] + // 已有 6 字节,还需 2 字节(CRC) + state = STATE_WRITE_BODY; + this.pendingData = Buffer.buffer(); + this.pendingData.appendBytes(bytes, 2, 4); // 暂存已有的 4 字节(StartAddr + Quantity) + parser.fixedSizeMode(2); // 还需 2 字节(CRC) + } else if (IotModbusCommonUtils.isReadResponse(fc) || fc == customFunctionCode) { + // 读响应或自定义 FC:bytes[2] = byteCount + this.byteCount = bytes[2]; + int bc = byteCount & 0xFF; + // 已有数据:bytes[3..5] = 3 字节 + // 还需:byteCount + CRC(2) - 3 字节已有 + int remaining = bc + 2 - 3; + if (remaining <= 0) { + // 数据已足够,组装完整帧 + int totalLen = 2 + 1 + bc + 2; // slaveId + FC + byteCount + data + CRC + Buffer frame = Buffer.buffer(totalLen); + frame.appendByte(slaveId); + frame.appendByte(functionCode); + frame.appendByte(byteCount); + frame.appendBytes(bytes, 3, bc + 2); // data + CRC + emitFrame(frame); + resetToHeader(); + } else { + // 需要继续读 + state = STATE_READ_DATA; + this.pendingData = Buffer.buffer(); + this.pendingData.appendBytes(bytes, 3, 3); // 暂存已有的 3 字节 + this.expectedDataLen = bc + 2; // byteCount 个数据 + 2 CRC + parser.fixedSizeMode(remaining); + } + } else if (IotModbusCommonUtils.isWriteResponse(fc)) { + // 写响应:总长 = slaveId(1) + FC(1) + addr(2) + value/qty(2) + CRC(2) = 8 字节 + // 已有 6 字节,还需 2 字节 + state = STATE_WRITE_BODY; + this.pendingData = Buffer.buffer(); + this.pendingData.appendBytes(bytes, 2, 4); // 暂存已有的 4 字节 + parser.fixedSizeMode(2); // 还需 2 字节(CRC) + } else { + log.warn("[RtuFrameHandler][未知功能码: 0x{}]", Integer.toHexString(fc)); + resetToHeader(); + } + } + + @Override + public void handle(Buffer buffer) { + switch (state) { + case STATE_HEADER: + handleHeader(buffer); + break; + case STATE_EXCEPTION_BODY: + handleExceptionBody(buffer); + break; + case STATE_READ_BYTE_COUNT: + handleReadByteCount(buffer); + break; + case STATE_READ_DATA: + handleReadData(buffer); + break; + case STATE_WRITE_BODY: + handleWriteBody(buffer); + break; + default: + resetToHeader(); + } + } + + private void handleHeader(Buffer buffer) { + byte[] header = buffer.getBytes(); + this.slaveId = header[0]; + this.functionCode = header[1]; + int fc = functionCode & 0xFF; + if (IotModbusCommonUtils.isExceptionResponse(fc)) { + // 异常响应 + state = STATE_EXCEPTION_BODY; + parser.fixedSizeMode(3); // exceptionCode(1) + CRC(2) + } else if (IotModbusCommonUtils.isReadResponse(fc) && requestMode) { + // 请求模式下的读请求:固定 8 字节,已读 2 字节(slaveId + FC),还需 6 字节 + state = STATE_WRITE_BODY; + pendingData = Buffer.buffer(); + parser.fixedSizeMode(6); // StartAddr(2) + Quantity(2) + CRC(2) + } else if (IotModbusCommonUtils.isReadResponse(fc) || fc == customFunctionCode) { + // 读响应或自定义 FC + state = STATE_READ_BYTE_COUNT; + parser.fixedSizeMode(1); // byteCount + } else if (IotModbusCommonUtils.isWriteResponse(fc)) { + // 写响应 + state = STATE_WRITE_BODY; + pendingData = Buffer.buffer(); + parser.fixedSizeMode(6); // addr(2) + value(2) + CRC(2) + } else { + log.warn("[RtuFrameHandler][未知功能码: 0x{}]", Integer.toHexString(fc)); + resetToHeader(); + } + } + + private void handleExceptionBody(Buffer buffer) { + // buffer = exceptionCode(1) + CRC(2) + Buffer frame = Buffer.buffer(); + frame.appendByte(slaveId); + frame.appendByte(functionCode); + frame.appendBuffer(buffer); + emitFrame(frame); + resetToHeader(); + } + + private void handleReadByteCount(Buffer buffer) { + this.byteCount = buffer.getByte(0); + int bc = byteCount & 0xFF; + state = STATE_READ_DATA; + pendingData = Buffer.buffer(); + expectedDataLen = bc + 2; // data(bc) + CRC(2) + parser.fixedSizeMode(expectedDataLen); + } + + private void handleReadData(Buffer buffer) { + pendingData.appendBuffer(buffer); + if (pendingData.length() >= expectedDataLen) { + // 组装完整帧 + Buffer frame = Buffer.buffer(); + frame.appendByte(slaveId); + frame.appendByte(functionCode); + frame.appendByte(byteCount); + frame.appendBuffer(pendingData); + emitFrame(frame); + resetToHeader(); + } + // 否则继续等待(不应该发生,因为我们精确设置了 fixedSizeMode) + } + + private void handleWriteBody(Buffer buffer) { + pendingData.appendBuffer(buffer); + // 完整帧 + Buffer frame = Buffer.buffer(); + frame.appendByte(slaveId); + frame.appendByte(functionCode); + frame.appendBuffer(pendingData); + emitFrame(frame); + resetToHeader(); + } + + /** + * 发射完整帧:解码并回调 + */ + private void emitFrame(Buffer frameBuffer) { + IotModbusFrame frame = decodeResponse(frameBuffer.getBytes(), IotModbusFrameFormatEnum.MODBUS_RTU); + if (frame != null) { + frameHandler.accept(frame, IotModbusFrameFormatEnum.MODBUS_RTU); + } + } + + private void resetToHeader() { + state = STATE_HEADER; + pendingData = null; + parser.fixedSizeMode(2); // slaveId + FC + } + + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrameEncoder.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrameEncoder.java new file mode 100644 index 0000000000..b5c48b225b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/codec/IotModbusFrameEncoder.java @@ -0,0 +1,210 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec; + +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; + +/** + * IoT Modbus 帧编码器:负责将 Modbus 请求/响应编码为字节数组,支持 MODBUS_TCP(MBAP)和 MODBUS_RTU(CRC16)两种帧格式。 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Slf4j +public class IotModbusFrameEncoder { + + private final int customFunctionCode; + + // ==================== 编码 ==================== + + /** + * 编码读请求 + * + * @param slaveId 从站地址 + * @param functionCode 功能码 + * @param startAddress 起始寄存器地址 + * @param quantity 寄存器数量 + * @param format 帧格式 + * @param transactionId 事务 ID(TCP 模式下使用,RTU 模式传 null) + * @return 编码后的字节数组 + */ + public byte[] encodeReadRequest(int slaveId, int functionCode, int startAddress, int quantity, + IotModbusFrameFormatEnum format, Integer transactionId) { + // PDU: [FC(1)] [StartAddress(2)] [Quantity(2)] + byte[] pdu = new byte[5]; + pdu[0] = (byte) functionCode; + pdu[1] = (byte) ((startAddress >> 8) & 0xFF); + pdu[2] = (byte) (startAddress & 0xFF); + pdu[3] = (byte) ((quantity >> 8) & 0xFF); + pdu[4] = (byte) (quantity & 0xFF); + return wrapFrame(slaveId, pdu, format, transactionId); + } + + /** + * 编码写请求(单个寄存器 FC06 / 单个线圈 FC05) + * + * @param slaveId 从站地址 + * @param functionCode 功能码 + * @param address 寄存器地址 + * @param value 值 + * @param format 帧格式 + * @param transactionId 事务 ID(TCP 模式下使用,RTU 模式传 null) + * @return 编码后的字节数组 + */ + public byte[] encodeWriteSingleRequest(int slaveId, int functionCode, int address, int value, + IotModbusFrameFormatEnum format, Integer transactionId) { + // FC05 单写线圈:Modbus 标准要求 value 为 0xFF00(ON)或 0x0000(OFF) + if (functionCode == IotModbusCommonUtils.FC_WRITE_SINGLE_COIL) { + value = (value != 0) ? 0xFF00 : 0x0000; + } + // PDU: [FC(1)] [Address(2)] [Value(2)] + byte[] pdu = new byte[5]; + pdu[0] = (byte) functionCode; + pdu[1] = (byte) ((address >> 8) & 0xFF); + pdu[2] = (byte) (address & 0xFF); + pdu[3] = (byte) ((value >> 8) & 0xFF); + pdu[4] = (byte) (value & 0xFF); + return wrapFrame(slaveId, pdu, format, transactionId); + } + + /** + * 编码写多个寄存器请求(FC16) + * + * @param slaveId 从站地址 + * @param address 起始地址 + * @param values 值数组 + * @param format 帧格式 + * @param transactionId 事务 ID(TCP 模式下使用,RTU 模式传 null) + * @return 编码后的字节数组 + */ + public byte[] encodeWriteMultipleRegistersRequest(int slaveId, int address, int[] values, + IotModbusFrameFormatEnum format, Integer transactionId) { + // PDU: [FC(1)] [Address(2)] [Quantity(2)] [ByteCount(1)] [Values(N*2)] + int quantity = values.length; + int byteCount = quantity * 2; + byte[] pdu = new byte[6 + byteCount]; + pdu[0] = (byte) 16; // FC16 + pdu[1] = (byte) ((address >> 8) & 0xFF); + pdu[2] = (byte) (address & 0xFF); + pdu[3] = (byte) ((quantity >> 8) & 0xFF); + pdu[4] = (byte) (quantity & 0xFF); + pdu[5] = (byte) byteCount; + for (int i = 0; i < quantity; i++) { + pdu[6 + i * 2] = (byte) ((values[i] >> 8) & 0xFF); + pdu[6 + i * 2 + 1] = (byte) (values[i] & 0xFF); + } + return wrapFrame(slaveId, pdu, format, transactionId); + } + + /** + * 编码写多个线圈请求(FC15) + *

+ * 按 Modbus FC15 标准,线圈值按 bit 打包(每个 byte 包含 8 个线圈状态)。 + * + * @param slaveId 从站地址 + * @param address 起始地址 + * @param values 线圈值数组(int[],非0 表示 ON,0 表示 OFF) + * @param format 帧格式 + * @param transactionId 事务 ID(TCP 模式下使用,RTU 模式传 null) + * @return 编码后的字节数组 + */ + public byte[] encodeWriteMultipleCoilsRequest(int slaveId, int address, int[] values, + IotModbusFrameFormatEnum format, Integer transactionId) { + // PDU: [FC(1)] [Address(2)] [Quantity(2)] [ByteCount(1)] [CoilValues(N)] + int quantity = values.length; + int byteCount = (quantity + 7) / 8; // 向上取整 + byte[] pdu = new byte[6 + byteCount]; + pdu[0] = (byte) IotModbusCommonUtils.FC_WRITE_MULTIPLE_COILS; // FC15 + pdu[1] = (byte) ((address >> 8) & 0xFF); + pdu[2] = (byte) (address & 0xFF); + pdu[3] = (byte) ((quantity >> 8) & 0xFF); + pdu[4] = (byte) (quantity & 0xFF); + pdu[5] = (byte) byteCount; + // 按 bit 打包:每个 byte 的 bit0 对应最低地址的线圈 + for (int i = 0; i < quantity; i++) { + if (values[i] != 0) { + pdu[6 + i / 8] |= (byte) (1 << (i % 8)); + } + } + return wrapFrame(slaveId, pdu, format, transactionId); + } + + /** + * 编码自定义功能码帧(认证响应等) + * + * @param slaveId 从站地址 + * @param jsonData JSON 数据 + * @param format 帧格式 + * @param transactionId 事务 ID(TCP 模式下使用,RTU 模式传 null) + * @return 编码后的字节数组 + */ + public byte[] encodeCustomFrame(int slaveId, String jsonData, + IotModbusFrameFormatEnum format, Integer transactionId) { + byte[] jsonBytes = jsonData.getBytes(StandardCharsets.UTF_8); + // PDU: [FC(1)] [ByteCount(1)] [JSON data(N)] + byte[] pdu = new byte[2 + jsonBytes.length]; + pdu[0] = (byte) customFunctionCode; + pdu[1] = (byte) jsonBytes.length; + System.arraycopy(jsonBytes, 0, pdu, 2, jsonBytes.length); + return wrapFrame(slaveId, pdu, format, transactionId); + } + + // ==================== 帧封装 ==================== + + /** + * 将 PDU 封装为完整帧 + * + * @param slaveId 从站地址 + * @param pdu PDU 数据(含 functionCode) + * @param format 帧格式 + * @param transactionId 事务 ID(TCP 模式下使用,RTU 模式可为 null) + * @return 完整帧字节数组 + */ + private byte[] wrapFrame(int slaveId, byte[] pdu, IotModbusFrameFormatEnum format, Integer transactionId) { + if (format == IotModbusFrameFormatEnum.MODBUS_TCP) { + return wrapTcpFrame(slaveId, pdu, transactionId != null ? transactionId : 0); + } else { + return wrapRtuFrame(slaveId, pdu); + } + } + + /** + * 封装 MODBUS_TCP 帧 + * [TransactionId(2)] [ProtocolId(2,=0x0000)] [Length(2)] [UnitId(1)] [PDU...] + */ + private byte[] wrapTcpFrame(int slaveId, byte[] pdu, int transactionId) { + int length = 1 + pdu.length; // UnitId + PDU + byte[] frame = new byte[6 + length]; // MBAP(6) + UnitId(1) + PDU + // MBAP Header + frame[0] = (byte) ((transactionId >> 8) & 0xFF); + frame[1] = (byte) (transactionId & 0xFF); + frame[2] = 0; // Protocol ID high + frame[3] = 0; // Protocol ID low + frame[4] = (byte) ((length >> 8) & 0xFF); + frame[5] = (byte) (length & 0xFF); + // Unit ID + frame[6] = (byte) slaveId; + // PDU + System.arraycopy(pdu, 0, frame, 7, pdu.length); + return frame; + } + + /** + * 封装 MODBUS_RTU 帧 + * [SlaveId(1)] [PDU...] [CRC(2)] + */ + private byte[] wrapRtuFrame(int slaveId, byte[] pdu) { + byte[] frame = new byte[1 + pdu.length + 2]; // SlaveId + PDU + CRC + frame[0] = (byte) slaveId; + System.arraycopy(pdu, 0, frame, 1, pdu.length); + // 计算并追加 CRC16 + int crc = IotModbusCommonUtils.calculateCrc16(frame, frame.length - 2); + frame[frame.length - 2] = (byte) (crc & 0xFF); // CRC Low + frame[frame.length - 1] = (byte) ((crc >> 8) & 0xFF); // CRC High + return frame; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamHandler.java new file mode 100644 index 0000000000..eb4683fc85 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamHandler.java @@ -0,0 +1,152 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConfigCacheService; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * IoT Modbus TCP Server 下行消息处理器 + *

+ * 负责: + * 1. 处理下行消息(如属性设置 thing.service.property.set) + * 2. 将属性值转换为 Modbus 写指令,通过 TCP 连接发送给设备 + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpServerDownstreamHandler { + + private final IotModbusTcpServerConnectionManager connectionManager; + private final IotModbusTcpServerConfigCacheService configCacheService; + private final IotModbusFrameEncoder frameEncoder; + + /** + * TCP 事务 ID 自增器(与 PollScheduler 共享) + */ + private final AtomicInteger transactionIdCounter; + + public IotModbusTcpServerDownstreamHandler(IotModbusTcpServerConnectionManager connectionManager, + IotModbusTcpServerConfigCacheService configCacheService, + IotModbusFrameEncoder frameEncoder, + AtomicInteger transactionIdCounter) { + this.connectionManager = connectionManager; + this.configCacheService = configCacheService; + this.frameEncoder = frameEncoder; + this.transactionIdCounter = transactionIdCounter; + } + + /** + * 处理下行消息 + */ + @SuppressWarnings({"unchecked", "DuplicatedCode"}) + public void handle(IotDeviceMessage message) { + // 1.1 检查是否是属性设置消息 + if (ObjUtil.equals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), message.getMethod())) { + return; + } + if (ObjUtil.notEqual(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), message.getMethod())) { + log.debug("[handle][忽略非属性设置消息: {}]", message.getMethod()); + return; + } + // 1.2 获取设备配置 + IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(message.getDeviceId()); + if (config == null) { + log.warn("[handle][设备 {} 没有 Modbus 配置]", message.getDeviceId()); + return; + } + // 1.3 获取连接信息 + ConnectionInfo connInfo = connectionManager.getConnectionInfoByDeviceId(message.getDeviceId()); + if (connInfo == null) { + log.warn("[handle][设备 {} 没有连接]", message.getDeviceId()); + return; + } + + // 2. 解析属性值并写入 + Object params = message.getParams(); + if (!(params instanceof Map)) { + log.warn("[handle][params 不是 Map 类型: {}]", params); + return; + } + Map propertyMap = (Map) params; + for (Map.Entry entry : propertyMap.entrySet()) { + String identifier = entry.getKey(); + Object value = entry.getValue(); + // 2.1 查找对应的点位配置 + IotModbusPointRespDTO point = IotModbusCommonUtils.findPoint(config, identifier); + if (point == null) { + log.warn("[handle][设备 {} 没有点位配置: {}]", message.getDeviceId(), identifier); + continue; + } + // 2.2 检查是否支持写操作 + if (!IotModbusCommonUtils.isWritable(point.getFunctionCode())) { + log.warn("[handle][点位 {} 不支持写操作, 功能码={}]", identifier, point.getFunctionCode()); + continue; + } + + // 2.3 执行写入 + writeProperty(config.getDeviceId(), connInfo, point, value); + } + } + + /** + * 写入属性值 + */ + private void writeProperty(Long deviceId, ConnectionInfo connInfo, + IotModbusPointRespDTO point, Object value) { + // 1.1 转换属性值为原始值 + int[] rawValues = IotModbusCommonUtils.convertToRawValues(value, point); + + // 1.2 确定帧格式和事务 ID + IotModbusFrameFormatEnum frameFormat = connInfo.getFrameFormat(); + Assert.notNull(frameFormat, "连接帧格式不能为空"); + Integer transactionId = frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP + ? (transactionIdCounter.incrementAndGet() & 0xFFFF) + : null; + int slaveId = connInfo.getSlaveId() != null ? connInfo.getSlaveId() : 1; + // 1.3 编码写请求 + byte[] data; + int readFunctionCode = point.getFunctionCode(); + Integer writeSingleCode = IotModbusCommonUtils.getWriteSingleFunctionCode(readFunctionCode); + Integer writeMultipleCode = IotModbusCommonUtils.getWriteMultipleFunctionCode(readFunctionCode); + if (rawValues.length == 1 && writeSingleCode != null) { + // 单个值:使用单写功能码(FC05/FC06) + data = frameEncoder.encodeWriteSingleRequest(slaveId, writeSingleCode, + point.getRegisterAddress(), rawValues[0], frameFormat, transactionId); + } else if (writeMultipleCode != null) { + // 多个值:使用多写功能码(FC15/FC16) + if (writeMultipleCode == IotModbusCommonUtils.FC_WRITE_MULTIPLE_COILS) { + data = frameEncoder.encodeWriteMultipleCoilsRequest(slaveId, + point.getRegisterAddress(), rawValues, frameFormat, transactionId); + } else { + data = frameEncoder.encodeWriteMultipleRegistersRequest(slaveId, + point.getRegisterAddress(), rawValues, frameFormat, transactionId); + } + } else { + log.warn("[writeProperty][点位 {} 不支持写操作, 功能码={}]", point.getIdentifier(), readFunctionCode); + return; + } + + // 2. 发送消息 + connectionManager.sendToDevice(deviceId, data).onSuccess(v -> + log.info("[writeProperty][写入成功, deviceId={}, identifier={}, value={}]", + deviceId, point.getIdentifier(), value) + ).onFailure(e -> + log.error("[writeProperty][写入失败, deviceId={}, identifier={}, value={}]", + deviceId, point.getIdentifier(), value, e) + ); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriber.java new file mode 100644 index 0000000000..1d36b69eee --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/downstream/IotModbusTcpServerDownstreamSubscriber.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.downstream; + +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.IotModbusTcpServerProtocol; +import lombok.extern.slf4j.Slf4j; + +/** + * IoT Modbus TCP Server 下行消息订阅器:订阅消息总线的下行消息并转发给处理器 + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpServerDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { + + private final IotModbusTcpServerDownstreamHandler downstreamHandler; + + public IotModbusTcpServerDownstreamSubscriber(IotModbusTcpServerProtocol protocol, + IotModbusTcpServerDownstreamHandler downstreamHandler, + IotMessageBus messageBus) { + super(protocol, messageBus); + this.downstreamHandler = downstreamHandler; + } + + @Override + protected void handleMessage(IotDeviceMessage message) { + downstreamHandler.handle(message); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandler.java new file mode 100644 index 0000000000..db8a9fdfa2 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/handler/upstream/IotModbusTcpServerUpstreamHandler.java @@ -0,0 +1,280 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.handler.upstream; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; +import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConfigCacheService; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPollScheduler; +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import io.vertx.core.net.NetSocket; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; +import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException; + +/** + * IoT Modbus TCP Server 上行数据处理器 + *

+ * 处理: + * 1. 自定义 FC 认证 + * 2. 轮询响应 → 点位翻译 → thing.property.post + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpServerUpstreamHandler { + + private static final String METHOD_AUTH = "auth"; + + private final IotDeviceCommonApi deviceApi; + private final IotDeviceMessageService messageService; + private final IotModbusFrameEncoder frameEncoder; + private final IotModbusTcpServerConnectionManager connectionManager; + private final IotModbusTcpServerConfigCacheService configCacheService; + private final IotModbusTcpServerPendingRequestManager pendingRequestManager; + private final IotModbusTcpServerPollScheduler pollScheduler; + private final IotDeviceService deviceService; + + private final String serverId; + + public IotModbusTcpServerUpstreamHandler(IotDeviceCommonApi deviceApi, + IotDeviceMessageService messageService, + IotModbusFrameEncoder frameEncoder, + IotModbusTcpServerConnectionManager connectionManager, + IotModbusTcpServerConfigCacheService configCacheService, + IotModbusTcpServerPendingRequestManager pendingRequestManager, + IotModbusTcpServerPollScheduler pollScheduler, + IotDeviceService deviceService, + String serverId) { + this.deviceApi = deviceApi; + this.messageService = messageService; + this.frameEncoder = frameEncoder; + this.connectionManager = connectionManager; + this.configCacheService = configCacheService; + this.pendingRequestManager = pendingRequestManager; + this.pollScheduler = pollScheduler; + this.deviceService = deviceService; + this.serverId = serverId; + } + + // ========== 帧处理入口 ========== + + /** + * 处理帧 + */ + public void handleFrame(NetSocket socket, IotModbusFrame frame, IotModbusFrameFormatEnum frameFormat) { + if (frame == null) { + return; + } + // 1. 异常响应 + if (frame.isException()) { + log.warn("[handleFrame][设备异常响应, slaveId={}, FC={}, exceptionCode={}]", + frame.getSlaveId(), frame.getFunctionCode(), frame.getExceptionCode()); + return; + } + + // 2. 情况一:自定义功能码(认证等扩展) + if (StrUtil.isNotEmpty(frame.getCustomData())) { + handleCustomFrame(socket, frame, frameFormat); + return; + } + + // 3. 情况二:标准 Modbus 响应 → 轮询响应处理 + handlePollingResponse(socket, frame, frameFormat); + } + + // ========== 自定义 FC 处理(认证等) ========== + + /** + * 处理自定义功能码帧 + *

+ * 异常分层翻译,参考 {@link cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream.IotHttpAbstractHandler} + */ + private void handleCustomFrame(NetSocket socket, IotModbusFrame frame, IotModbusFrameFormatEnum frameFormat) { + String method = null; + try { + IotDeviceMessage message = JsonUtils.parseObject(frame.getCustomData(), IotDeviceMessage.class); + if (message == null) { + throw invalidParamException("自定义 FC 数据解析失败"); + } + method = message.getMethod(); + if (METHOD_AUTH.equals(method)) { + handleAuth(socket, frame, frameFormat, message.getParams()); + return; + } + log.warn("[handleCustomFrame][未知 method: {}, frame: slaveId={}, FC={}, customData={}]", + method, frame.getSlaveId(), frame.getFunctionCode(), frame.getCustomData()); + } catch (ServiceException e) { + // 已知业务异常,返回对应的错误码和错误信息 + sendCustomResponse(socket, frame, frameFormat, method, e.getCode(), e.getMessage()); + } catch (IllegalArgumentException e) { + // 参数校验异常,返回 400 错误 + sendCustomResponse(socket, frame, frameFormat, method, BAD_REQUEST.getCode(), e.getMessage()); + } catch (Exception e) { + // 其他未知异常,返回 500 错误 + log.error("[handleCustomFrame][解析自定义 FC 数据失败, frame: slaveId={}, FC={}, customData={}]", + frame.getSlaveId(), frame.getFunctionCode(), frame.getCustomData(), e); + sendCustomResponse(socket, frame, frameFormat, method, + INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); + } + } + + /** + * 处理认证请求 + */ + @SuppressWarnings("DataFlowIssue") + private void handleAuth(NetSocket socket, IotModbusFrame frame, IotModbusFrameFormatEnum frameFormat, Object params) { + // 1. 解析认证参数 + IotDeviceAuthReqDTO request = JsonUtils.convertObject(params, IotDeviceAuthReqDTO.class); + Assert.notNull(request, "认证参数不能为空"); + Assert.notBlank(request.getUsername(), "username 不能为空"); + Assert.notBlank(request.getPassword(), "password 不能为空"); + // 特殊:考虑到 modbus 消息体积较小,默认 clientId 传递空串 + if (StrUtil.isBlank(request.getClientId())) { + request.setClientId(IotDeviceAuthUtils.buildClientIdFromUsername(request.getUsername())); + } + Assert.notBlank(request.getClientId(), "clientId 不能为空"); + + // 2.1 调用认证 API + CommonResult result = deviceApi.authDevice(request); + result.checkError(); + if (BooleanUtil.isFalse(result.getData())) { + log.warn("[handleAuth][认证失败, clientId={}, username={}]", request.getClientId(), request.getUsername()); + sendCustomResponse(socket, frame, frameFormat, METHOD_AUTH, BAD_REQUEST.getCode(), "认证失败"); + return; + } + // 2.2 解析设备信息 + IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(request.getUsername()); + Assert.notNull(deviceInfo, "解析设备信息失败"); + // 2.3 获取设备信息 + IotDeviceRespDTO device = deviceService.getDeviceFromCache(deviceInfo.getProductKey(), deviceInfo.getDeviceName()); + Assert.notNull(device, "设备不存在"); + // 2.4 加载设备 Modbus 配置,无配置则阻断认证 + IotModbusDeviceConfigRespDTO modbusConfig = configCacheService.loadDeviceConfig(device.getId()); + if (modbusConfig == null) { + log.warn("[handleAuth][设备 {} 没有 Modbus 点位配置, 拒绝认证]", device.getId()); + sendCustomResponse(socket, frame, frameFormat, METHOD_AUTH, BAD_REQUEST.getCode(), "设备无 Modbus 配置"); + return; + } + // 2.5 协议不一致,阻断认证 + if (ObjUtil.notEqual(frameFormat.getFormat(), modbusConfig.getFrameFormat())) { + log.warn("[handleAuth][设备 {} frameFormat 不一致, 连接协议={}, 设备配置={},拒绝认证]", + device.getId(), frameFormat.getFormat(), modbusConfig.getFrameFormat()); + sendCustomResponse(socket, frame, frameFormat, METHOD_AUTH, BAD_REQUEST.getCode(), + "frameFormat 协议不一致"); + return; + } + + // 3.1 注册连接 + ConnectionInfo connectionInfo = new ConnectionInfo() + .setDeviceId(device.getId()) + .setProductKey(deviceInfo.getProductKey()) + .setDeviceName(deviceInfo.getDeviceName()) + .setSlaveId(frame.getSlaveId()) + .setFrameFormat(frameFormat); + connectionManager.registerConnection(socket, connectionInfo); + // 3.2 发送上线消息 + IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); + messageService.sendDeviceMessage(onlineMessage, deviceInfo.getProductKey(), deviceInfo.getDeviceName(), serverId); + // 3.3 发送成功响应 + sendCustomResponse(socket, frame, frameFormat, METHOD_AUTH, + GlobalErrorCodeConstants.SUCCESS.getCode(), "success"); + log.info("[handleAuth][认证成功, clientId={}, deviceId={}]", request.getClientId(), device.getId()); + + // 4. 启动轮询 + pollScheduler.updatePolling(modbusConfig); + } + + /** + * 发送自定义功能码响应 + */ + private void sendCustomResponse(NetSocket socket, IotModbusFrame frame, + IotModbusFrameFormatEnum frameFormat, + String method, int code, String message) { + Map response = MapUtil.builder() + .put("method", method) + .put("code", code) + .put("message", message) + .build(); + byte[] data = frameEncoder.encodeCustomFrame(frame.getSlaveId(), JsonUtils.toJsonString(response), + frameFormat, frame.getTransactionId()); + connectionManager.sendToSocket(socket, data); + } + + // ========== 轮询响应处理 ========== + + /** + * 处理轮询响应(云端轮询模式) + */ + private void handlePollingResponse(NetSocket socket, IotModbusFrame frame, + IotModbusFrameFormatEnum frameFormat) { + // 1. 获取连接信息(未认证连接丢弃) + ConnectionInfo info = connectionManager.getConnectionInfo(socket); + if (info == null) { + log.warn("[handlePollingResponse][未认证连接, 丢弃数据, remoteAddress={}]", socket.remoteAddress()); + return; + } + + // 2.1 匹配 PendingRequest + PendingRequest request = pendingRequestManager.matchResponse( + info.getDeviceId(), frame, frameFormat); + if (request == null) { + log.debug("[handlePollingResponse][未匹配到 PendingRequest, deviceId={}, FC={}]", + info.getDeviceId(), frame.getFunctionCode()); + return; + } + // 2.2 提取寄存器值 + int[] rawValues = IotModbusCommonUtils.extractValues(frame); + if (rawValues == null) { + log.warn("[handlePollingResponse][提取寄存器值失败, deviceId={}, identifier={}]", + info.getDeviceId(), request.getIdentifier()); + return; + } + // 2.3 查找点位配置 + IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(info.getDeviceId()); + IotModbusPointRespDTO point = IotModbusCommonUtils.findPointById(config, request.getPointId()); + if (point == null) { + return; + } + + // 3.1 转换原始值为物模型属性值(点位翻译) + Object convertedValue = IotModbusCommonUtils.convertToPropertyValue(rawValues, point); + // 3.2 构造属性上报消息 + Map params = MapUtil.of(request.getIdentifier(), convertedValue); + IotDeviceMessage message = IotDeviceMessage.requestOf( + IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), params); + + // 4. 发送到消息总线 + messageService.sendDeviceMessage(message, info.getProductKey(), info.getDeviceName(), serverId); + log.debug("[handlePollingResponse][设备={}, 属性={}, 原始值={}, 转换值={}]", + info.getDeviceId(), request.getIdentifier(), rawValues, convertedValue); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerConfigCacheService.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerConfigCacheService.java new file mode 100644 index 0000000000..19ba6e900b --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerConfigCacheService.java @@ -0,0 +1,118 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusModeEnum; +import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * IoT Modbus TCP Server 配置缓存:认证时按需加载,断连时清理,定时刷新已连接设备 + * + * @author 芋道源码 + */ +@RequiredArgsConstructor +@Slf4j +public class IotModbusTcpServerConfigCacheService { + + private final IotDeviceCommonApi deviceApi; + + /** + * 配置缓存:deviceId -> 配置 + */ + private final Map configCache = new ConcurrentHashMap<>(); + + /** + * 加载单个设备的配置(认证成功后调用) + * + * @param deviceId 设备 ID + * @return 设备配置 + */ + public IotModbusDeviceConfigRespDTO loadDeviceConfig(Long deviceId) { + try { + // 1. 从远程 API 获取配置 + IotModbusDeviceConfigListReqDTO reqDTO = new IotModbusDeviceConfigListReqDTO() + .setStatus(CommonStatusEnum.ENABLE.getStatus()) + .setMode(IotModbusModeEnum.POLLING.getMode()) + .setProtocolType(IotProtocolTypeEnum.MODBUS_TCP_SERVER.getType()) + .setDeviceIds(Collections.singleton(deviceId)); + CommonResult> result = deviceApi.getModbusDeviceConfigList(reqDTO); + result.checkError(); + IotModbusDeviceConfigRespDTO modbusConfig = CollUtil.getFirst(result.getData()); + if (modbusConfig == null) { + log.warn("[loadDeviceConfig][远程获取配置失败,未找到数据, deviceId={}]", deviceId); + return null; + } + + // 2. 更新缓存并返回 + configCache.put(modbusConfig.getDeviceId(), modbusConfig); + return modbusConfig; + } catch (Exception e) { + log.error("[loadDeviceConfig][从远程获取配置失败, deviceId={}]", deviceId, e); + return null; + } + } + + /** + * 刷新已连接设备的配置缓存 + *

+ * 定时调用,从远程 API 拉取最新配置,只更新已连接设备的缓存。 + * + * @param connectedDeviceIds 当前已连接的设备 ID 集合 + * @return 已连接设备的最新配置列表 + */ + public List refreshConnectedDeviceConfigList(Set connectedDeviceIds) { + if (CollUtil.isEmpty(connectedDeviceIds)) { + return Collections.emptyList(); + } + try { + // 1. 从远程获取已连接设备的配置 + CommonResult> result = deviceApi.getModbusDeviceConfigList( + new IotModbusDeviceConfigListReqDTO().setStatus(CommonStatusEnum.ENABLE.getStatus()) + .setMode(IotModbusModeEnum.POLLING.getMode()) + .setProtocolType(IotProtocolTypeEnum.MODBUS_TCP_SERVER.getType()) + .setDeviceIds(connectedDeviceIds)); + List modbusConfigs = result.getCheckedData(); + + // 2. 更新缓存并返回 + for (IotModbusDeviceConfigRespDTO config : modbusConfigs) { + configCache.put(config.getDeviceId(), config); + } + return modbusConfigs; + } catch (Exception e) { + log.error("[refreshConnectedDeviceConfigList][刷新配置失败]", e); + return null; + } + } + + /** + * 获取设备配置 + */ + public IotModbusDeviceConfigRespDTO getConfig(Long deviceId) { + IotModbusDeviceConfigRespDTO config = configCache.get(deviceId); + if (config != null) { + return config; + } + // 缓存未命中,从远程 API 获取 + return loadDeviceConfig(deviceId); + } + + /** + * 移除设备配置缓存(设备断连时调用) + */ + public void removeConfig(Long deviceId) { + configCache.remove(deviceId); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerConnectionManager.java new file mode 100644 index 0000000000..781d8ac549 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerConnectionManager.java @@ -0,0 +1,174 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; + +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import io.vertx.core.Future; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetSocket; +import lombok.Data; +import lombok.experimental.Accessors; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * IoT Modbus TCP Server 连接管理器 + *

+ * 管理设备 TCP 连接:socket ↔ 设备双向映射 + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpServerConnectionManager { + + /** + * socket → 连接信息 + */ + private final Map connectionMap = new ConcurrentHashMap<>(); + + /** + * deviceId → socket + */ + private final Map deviceSocketMap = new ConcurrentHashMap<>(); + + /** + * 连接信息 + */ + @Data + @Accessors(chain = true) + public static class ConnectionInfo { + + /** + * 设备编号 + */ + private Long deviceId; + /** + * 产品标识 + */ + private String productKey; + /** + * 设备名称 + */ + private String deviceName; + /** + * 从站地址 + */ + private Integer slaveId; + + /** + * 帧格式(首帧自动检测得到) + */ + private IotModbusFrameFormatEnum frameFormat; + + } + + /** + * 注册已认证的连接 + */ + public void registerConnection(NetSocket socket, ConnectionInfo info) { + // 先检查该设备是否有旧连接,若有且不是同一个 socket,关闭旧 socket + NetSocket oldSocket = deviceSocketMap.get(info.getDeviceId()); + if (oldSocket != null && oldSocket != socket) { + log.info("[registerConnection][设备 {} 存在旧连接, 关闭旧 socket, oldRemote={}, newRemote={}]", + info.getDeviceId(), oldSocket.remoteAddress(), socket.remoteAddress()); + connectionMap.remove(oldSocket); + try { + oldSocket.close(); + } catch (Exception e) { + log.warn("[registerConnection][关闭旧 socket 失败, deviceId={}, oldRemote={}]", + info.getDeviceId(), oldSocket.remoteAddress(), e); + } + } + + // 注册新连接 + connectionMap.put(socket, info); + deviceSocketMap.put(info.getDeviceId(), socket); + log.info("[registerConnection][设备 {} 连接已注册, remoteAddress={}]", + info.getDeviceId(), socket.remoteAddress()); + } + + /** + * 获取连接信息 + */ + public ConnectionInfo getConnectionInfo(NetSocket socket) { + return connectionMap.get(socket); + } + + /** + * 根据设备 ID 获取连接信息 + */ + public ConnectionInfo getConnectionInfoByDeviceId(Long deviceId) { + NetSocket socket = deviceSocketMap.get(deviceId); + return socket != null ? connectionMap.get(socket) : null; + } + + /** + * 获取所有已连接设备的 ID 集合 + */ + public Set getConnectedDeviceIds() { + return deviceSocketMap.keySet(); + } + + /** + * 移除连接 + */ + public ConnectionInfo removeConnection(NetSocket socket) { + ConnectionInfo info = connectionMap.remove(socket); + if (info != null && info.getDeviceId() != null) { + // 使用两参数 remove:只有当 deviceSocketMap 中对应的 socket 就是当前 socket 时才删除, + // 避免新 socket 已注册后旧 socket 关闭时误删新映射 + boolean removed = deviceSocketMap.remove(info.getDeviceId(), socket); + if (removed) { + log.info("[removeConnection][设备 {} 连接已移除]", info.getDeviceId()); + } else { + log.info("[removeConnection][设备 {} 旧连接关闭, 新连接仍在线, 跳过清理]", info.getDeviceId()); + } + } + return info; + } + + /** + * 发送数据到设备 + * + * @return 发送结果 Future + */ + public Future sendToDevice(Long deviceId, byte[] data) { + NetSocket socket = deviceSocketMap.get(deviceId); + if (socket == null) { + log.warn("[sendToDevice][设备 {} 没有连接]", deviceId); + return Future.failedFuture("设备 " + deviceId + " 没有连接"); + } + return sendToSocket(socket, data); + } + + /** + * 发送数据到指定 socket + * + * @return 发送结果 Future + */ + public Future sendToSocket(NetSocket socket, byte[] data) { + return socket.write(Buffer.buffer(data)); + } + + /** + * 关闭所有连接 + */ + public void closeAll() { + // 1. 先复制再清空,避免 closeHandler 回调时并发修改 + List sockets = new ArrayList<>(connectionMap.keySet()); + connectionMap.clear(); + deviceSocketMap.clear(); + // 2. 关闭所有 socket(closeHandler 中 removeConnection 发现 map 为空会安全跳过) + for (NetSocket socket : sockets) { + try { + socket.close(); + } catch (Exception e) { + log.error("[closeAll][关闭连接失败]", e); + } + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManager.java new file mode 100644 index 0000000000..9bac7f86d5 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPendingRequestManager.java @@ -0,0 +1,154 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.Deque; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedDeque; + +/** + * IoT Modbus TCP Server 待响应请求管理器 + *

+ * 管理轮询下发的请求,用于匹配设备响应: + * - TCP 模式:按 transactionId 精确匹配 + * - RTU 模式:按 slaveId + functionCode FIFO 匹配 + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpServerPendingRequestManager { + + /** + * deviceId → 有序队列 + */ + private final Map> pendingRequests = new ConcurrentHashMap<>(); + + /** + * 待响应请求信息 + */ + @Data + @AllArgsConstructor + public static class PendingRequest { + + private Long deviceId; + private Long pointId; + private String identifier; + private int slaveId; + private int functionCode; + private int registerAddress; + private int registerCount; + private Integer transactionId; + private long expireAt; + + } + + /** + * 添加待响应请求 + */ + public void addRequest(PendingRequest request) { + pendingRequests.computeIfAbsent(request.getDeviceId(), k -> new ConcurrentLinkedDeque<>()) + .addLast(request); + } + + /** + * 匹配响应(TCP 模式按 transactionId,RTU 模式按 FIFO) + * + * @param deviceId 设备 ID + * @param frame 收到的响应帧 + * @param frameFormat 帧格式 + * @return 匹配到的 PendingRequest,没有匹配返回 null + */ + public PendingRequest matchResponse(Long deviceId, IotModbusFrame frame, + IotModbusFrameFormatEnum frameFormat) { + Deque queue = pendingRequests.get(deviceId); + if (CollUtil.isEmpty(queue)) { + return null; + } + + // TCP 模式:按 transactionId 精确匹配 + if (frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP && frame.getTransactionId() != null) { + return matchByTransactionId(queue, frame.getTransactionId()); + } + // RTU 模式:FIFO,匹配 slaveId + functionCode + registerCount + int responseRegisterCount = IotModbusCommonUtils.extractRegisterCountFromResponse(frame); + return matchByFifo(queue, frame.getSlaveId(), frame.getFunctionCode(), responseRegisterCount); + } + + /** + * 按 transactionId 匹配 + */ + private PendingRequest matchByTransactionId(Deque queue, int transactionId) { + Iterator it = queue.iterator(); + while (it.hasNext()) { + PendingRequest req = it.next(); + if (req.getTransactionId() != null && req.getTransactionId() == transactionId) { + it.remove(); + return req; + } + } + return null; + } + + /** + * 按 FIFO 匹配(slaveId + functionCode + registerCount) + */ + private PendingRequest matchByFifo(Deque queue, int slaveId, int functionCode, + int responseRegisterCount) { + Iterator it = queue.iterator(); + while (it.hasNext()) { + PendingRequest req = it.next(); + if (req.getSlaveId() == slaveId + && req.getFunctionCode() == functionCode + && (responseRegisterCount <= 0 || req.getRegisterCount() == responseRegisterCount)) { + it.remove(); + return req; + } + } + return null; + } + + /** + * 清理过期请求 + */ + public void cleanupExpired() { + long now = System.currentTimeMillis(); + for (Map.Entry> entry : pendingRequests.entrySet()) { + Deque queue = entry.getValue(); + int removed = 0; + Iterator it = queue.iterator(); + while (it.hasNext()) { + PendingRequest req = it.next(); + if (req.getExpireAt() < now) { + it.remove(); + removed++; + } + } + if (removed > 0) { + log.debug("[cleanupExpired][设备 {} 清理了 {} 个过期请求]", entry.getKey(), removed); + } + } + } + + /** + * 清理指定设备的所有待响应请求 + */ + public void removeDevice(Long deviceId) { + pendingRequests.remove(deviceId); + } + + /** + * 清理所有待响应请求 + */ + public void clear() { + pendingRequests.clear(); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPollScheduler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPollScheduler.java new file mode 100644 index 0000000000..4660f8e7e7 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/manager/IotModbusTcpServerPollScheduler.java @@ -0,0 +1,111 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.lang.Assert; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager.AbstractIotModbusPollScheduler; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerConnectionManager.ConnectionInfo; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.manager.IotModbusTcpServerPendingRequestManager.PendingRequest; +import io.vertx.core.Vertx; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * IoT Modbus TCP Server 轮询调度器:编码读请求帧,通过 TCP 连接发送到设备,注册 PendingRequest 等待响应 + * + * @author 芋道源码 + */ +@Slf4j +public class IotModbusTcpServerPollScheduler extends AbstractIotModbusPollScheduler { + + private final IotModbusTcpServerConnectionManager connectionManager; + private final IotModbusFrameEncoder frameEncoder; + private final IotModbusTcpServerPendingRequestManager pendingRequestManager; + private final IotModbusTcpServerConfigCacheService configCacheService; + private final int requestTimeout; + /** + * TCP 事务 ID 自增器(与 DownstreamHandler 共享) + */ + @Getter + private final AtomicInteger transactionIdCounter; + + public IotModbusTcpServerPollScheduler(Vertx vertx, + IotModbusTcpServerConnectionManager connectionManager, + IotModbusFrameEncoder frameEncoder, + IotModbusTcpServerPendingRequestManager pendingRequestManager, + int requestTimeout, + AtomicInteger transactionIdCounter, + IotModbusTcpServerConfigCacheService configCacheService) { + super(vertx); + this.connectionManager = connectionManager; + this.frameEncoder = frameEncoder; + this.pendingRequestManager = pendingRequestManager; + this.requestTimeout = requestTimeout; + this.transactionIdCounter = transactionIdCounter; + this.configCacheService = configCacheService; + } + + // ========== 轮询执行 ========== + + /** + * 轮询单个点位 + */ + @Override + @SuppressWarnings("DuplicatedCode") + protected void pollPoint(Long deviceId, Long pointId) { + // 1.1 从 configCache 获取最新配置 + IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(deviceId); + if (config == null || CollUtil.isEmpty(config.getPoints())) { + log.warn("[pollPoint][设备 {} 没有配置]", deviceId); + return; + } + // 1.2 查找点位 + IotModbusPointRespDTO point = IotModbusCommonUtils.findPointById(config, pointId); + if (point == null) { + log.warn("[pollPoint][设备 {} 点位 {} 未找到]", deviceId, pointId); + return; + } + + // 2.1 获取连接 + ConnectionInfo connection = connectionManager.getConnectionInfoByDeviceId(deviceId); + if (connection == null) { + log.debug("[pollPoint][设备 {} 没有连接,跳过轮询]", deviceId); + return; + } + // 2.2 获取 slave ID + IotModbusFrameFormatEnum frameFormat = connection.getFrameFormat(); + Assert.notNull(frameFormat, "设备 {} 的帧格式不能为空", deviceId); + Integer slaveId = connection.getSlaveId(); + Assert.notNull(connection.getSlaveId(), "设备 {} 的 slaveId 不能为空", deviceId); + + // 3.1 编码读请求 + Integer transactionId = frameFormat == IotModbusFrameFormatEnum.MODBUS_TCP + ? (transactionIdCounter.incrementAndGet() & 0xFFFF) + : null; + byte[] data = frameEncoder.encodeReadRequest(slaveId, point.getFunctionCode(), + point.getRegisterAddress(), point.getRegisterCount(), frameFormat, transactionId); + // 3.2 注册 PendingRequest + PendingRequest pendingRequest = new PendingRequest( + deviceId, point.getId(), point.getIdentifier(), + slaveId, point.getFunctionCode(), + point.getRegisterAddress(), point.getRegisterCount(), + transactionId, + System.currentTimeMillis() + requestTimeout); + pendingRequestManager.addRequest(pendingRequest); + // 3.3 发送读请求 + connectionManager.sendToDevice(deviceId, data).onSuccess(v -> + log.debug("[pollPoint][设备={}, 点位={}, FC={}, 地址={}, 数量={}]", + deviceId, point.getIdentifier(), point.getFunctionCode(), + point.getRegisterAddress(), point.getRegisterCount()) + ).onFailure(e -> + log.warn("[pollPoint][发送失败, 设备={}, 点位={}]", deviceId, point.getIdentifier(), e) + ); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/package-info.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/package-info.java new file mode 100644 index 0000000000..c15087027d --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/package-info.java @@ -0,0 +1,6 @@ +/** + * Modbus TCP Server(从站)协议:设备主动连接网关,自定义 FC65 认证后由网关云端轮询 + *

+ * TCP Server 模式,支持 MODBUS_TCP / MODBUS_RTU 帧格式自动检测 + */ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttProtocol.java index 1201fd1a42..354cd1b452 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotMqttProtocol.java @@ -81,7 +81,7 @@ public class IotMqttProtocol implements IotProtocol { /** * 下行消息订阅者 */ - private final IotMqttDownstreamSubscriber downstreamSubscriber; + private IotMqttDownstreamSubscriber downstreamSubscriber; private final IotDeviceMessageService deviceMessageService; @@ -104,11 +104,6 @@ public class IotMqttProtocol implements IotProtocol { this.authHandler = new IotMqttAuthHandler(connectionManager, deviceMessageService, deviceApi, serverId); this.registerHandler = new IotMqttRegisterHandler(connectionManager, deviceMessageService); this.upstreamHandler = new IotMqttUpstreamHandler(connectionManager, deviceMessageService, serverId); - - // 初始化下行消息订阅者 - IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); - IotMqttDownstreamHandler downstreamHandler = new IotMqttDownstreamHandler(deviceMessageService, connectionManager); - this.downstreamSubscriber = new IotMqttDownstreamSubscriber(this, downstreamHandler, messageBus); } @Override @@ -157,18 +152,13 @@ public class IotMqttProtocol implements IotProtocol { getId(), properties.getPort(), serverId); // 2. 启动下行消息订阅者 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + IotMqttDownstreamHandler downstreamHandler = new IotMqttDownstreamHandler(deviceMessageService, connectionManager); + this.downstreamSubscriber = new IotMqttDownstreamSubscriber(this, downstreamHandler, messageBus); this.downstreamSubscriber.start(); } catch (Exception e) { log.error("[start][IoT MQTT 协议 {} 启动失败]", getId(), e); - // 启动失败时关闭资源 - if (mqttServer != null) { - mqttServer.close(); - mqttServer = null; - } - if (vertx != null) { - vertx.close(); - vertx = null; - } + stop0(); throw e; } } @@ -178,15 +168,24 @@ public class IotMqttProtocol implements IotProtocol { if (!running) { return; } + stop0(); + } + + private void stop0() { // 1. 停止下行消息订阅者 - try { - downstreamSubscriber.stop(); - log.info("[stop][IoT MQTT 协议 {} 下行消息订阅者已停止]", getId()); - } catch (Exception e) { - log.error("[stop][IoT MQTT 协议 {} 下行消息订阅者停止失败]", getId(), e); + if (downstreamSubscriber != null) { + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT MQTT 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT MQTT 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + downstreamSubscriber = null; } - // 2.1 关闭 MQTT 服务器 + // 2.1 关闭所有连接 + connectionManager.closeAll(); + // 2.2 关闭 MQTT 服务器 if (mqttServer != null) { try { mqttServer.close().result(); @@ -196,7 +195,7 @@ public class IotMqttProtocol implements IotProtocol { } mqttServer = null; } - // 2.2 关闭 Vertx 实例 + // 2.3 关闭 Vertx 实例 if (vertx != null) { try { vertx.close().result(); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamSubscriber.java index c8aa29906a..5f0b547f1a 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/downstream/IotMqttDownstreamSubscriber.java @@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.downstream; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; -import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttProtocol; import lombok.extern.slf4j.Slf4j; @@ -12,7 +12,7 @@ import lombok.extern.slf4j.Slf4j; * @author 芋道源码 */ @Slf4j -public class IotMqttDownstreamSubscriber extends IotProtocolDownstreamSubscriber { +public class IotMqttDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { private final IotMqttDownstreamHandler downstreamHandler; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java index 00a0c4b849..81a6ece1a4 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/handler/upstream/IotMqttUpstreamHandler.java @@ -2,15 +2,13 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.handler.upstream; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.ArrayUtil; -import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; import io.vertx.mqtt.MqttEndpoint; import lombok.extern.slf4j.Slf4j; -import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST; -import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; /** * IoT 网关 MQTT 上行消息处理器:处理业务消息(属性上报、事件上报等) @@ -38,10 +36,6 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { */ public void handleBusinessRequest(MqttEndpoint endpoint, String topic, byte[] payload) { String clientId = endpoint.clientIdentifier(); - IotDeviceMessage message = null; - String productKey = null; - String deviceName = null; - try { // 1.1 基础检查 if (ArrayUtil.isEmpty(payload)) { @@ -49,8 +43,8 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { } // 1.2 解析主题,获取 productKey 和 deviceName String[] topicParts = topic.split("/"); - productKey = ArrayUtil.get(topicParts, 2); - deviceName = ArrayUtil.get(topicParts, 3); + String productKey = ArrayUtil.get(topicParts, 2); + String deviceName = ArrayUtil.get(topicParts, 3); Assert.notBlank(productKey, "产品 Key 不能为空"); Assert.notBlank(deviceName, "设备名称不能为空"); // 1.3 校验设备信息,防止伪造设备消息 @@ -58,39 +52,27 @@ public class IotMqttUpstreamHandler extends IotMqttAbstractHandler { Assert.notNull(connectionInfo, "无法获取连接信息"); Assert.equals(productKey, connectionInfo.getProductKey(), "产品 Key 不匹配"); Assert.equals(deviceName, connectionInfo.getDeviceName(), "设备名称不匹配"); - - // 2. 反序列化消息 - message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); - if (message == null) { - log.warn("[handleBusinessRequest][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic); - sendErrorResponse(endpoint, productKey, deviceName, null, null, - BAD_REQUEST.getCode(), "消息解码失败"); + // 1.4 校验 topic 是否允许发布 + if (!IotMqttTopicUtils.isTopicPublishAllowed(topic, productKey, deviceName)) { + log.warn("[handleBusinessRequest][topic 不允许发布,客户端 ID: {},主题: {}]", clientId, topic); return; } + // 2.1 反序列化消息 + IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName); + if (message == null) { + log.warn("[handleBusinessRequest][消息解码失败,客户端 ID: {},主题: {}]", clientId, topic); + return; + } + // 2.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀) + IotMqttTopicUtils.normalizeReplyMethod(message); + // 3. 处理业务消息 deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId); log.debug("[handleBusinessRequest][消息处理成功,客户端 ID: {},主题: {}]", clientId, topic); - } catch (ServiceException e) { - log.warn("[handleBusinessRequest][业务异常,客户端 ID: {},主题: {},错误: {}]", - clientId, topic, e.getMessage()); - String requestId = message != null ? message.getRequestId() : null; - String method = message != null ? message.getMethod() : null; - sendErrorResponse(endpoint, productKey, deviceName, requestId, method, e.getCode(), e.getMessage()); - } catch (IllegalArgumentException e) { - log.warn("[handleBusinessRequest][参数校验失败,客户端 ID: {},主题: {},错误: {}]", - clientId, topic, e.getMessage()); - String requestId = message != null ? message.getRequestId() : null; - String method = message != null ? message.getMethod() : null; - sendErrorResponse(endpoint, productKey, deviceName, requestId, method, - BAD_REQUEST.getCode(), e.getMessage()); } catch (Exception e) { log.error("[handleBusinessRequest][消息处理异常,客户端 ID: {},主题: {},错误: {}]", clientId, topic, e.getMessage(), e); - String requestId = message != null ? message.getRequestId() : null; - String method = message != null ? message.getMethod() : null; - sendErrorResponse(endpoint, productKey, deviceName, requestId, method, - INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg()); } } diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java index ccb9fa5a60..9bd3ec4934 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/manager/IotMqttConnectionManager.java @@ -8,6 +8,8 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -166,6 +168,24 @@ public class IotMqttConnectionManager { return deviceEndpointMap.get(deviceId); } + /** + * 关闭所有连接 + */ + public void closeAll() { + // 1. 先复制再清空,避免 closeHandler 回调时并发修改 + List endpoints = new ArrayList<>(connectionMap.keySet()); + connectionMap.clear(); + deviceEndpointMap.clear(); + // 2. 关闭所有连接(closeHandler 中 unregisterConnection 发现 map 为空会安全跳过) + for (MqttEndpoint endpoint : endpoints) { + try { + endpoint.close(); + } catch (Exception ignored) { + // 连接可能已关闭,忽略异常 + } + } + } + /** * 连接信息 */ diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java index e864df543e..8660e87f7e 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/IotTcpProtocol.java @@ -66,7 +66,7 @@ public class IotTcpProtocol implements IotProtocol { /** * 下行消息订阅者 */ - private final IotTcpDownstreamSubscriber downstreamSubscriber; + private IotTcpDownstreamSubscriber downstreamSubscriber; /** * 消息序列化器 @@ -94,11 +94,6 @@ public class IotTcpProtocol implements IotProtocol { // 初始化连接管理器 this.connectionManager = new IotTcpConnectionManager(tcpConfig.getMaxConnections()); - - // 初始化下行消息订阅者 - IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); - IotTcpDownstreamHandler downstreamHandler = new IotTcpDownstreamHandler(connectionManager, frameCodec, serializer); - this.downstreamSubscriber = new IotTcpDownstreamSubscriber(this, downstreamHandler, messageBus); } @Override @@ -152,18 +147,13 @@ public class IotTcpProtocol implements IotProtocol { getId(), properties.getPort(), serverId); // 2. 启动下行消息订阅者 + IotTcpDownstreamHandler downstreamHandler = new IotTcpDownstreamHandler(connectionManager, frameCodec, serializer); + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + this.downstreamSubscriber = new IotTcpDownstreamSubscriber(this, downstreamHandler, messageBus); this.downstreamSubscriber.start(); } catch (Exception e) { log.error("[start][IoT TCP 协议 {} 启动失败]", getId(), e); - // 启动失败时关闭资源 - if (tcpServer != null) { - tcpServer.close(); - tcpServer = null; - } - if (vertx != null) { - vertx.close(); - vertx = null; - } + stop0(); throw e; } } @@ -173,15 +163,24 @@ public class IotTcpProtocol implements IotProtocol { if (!running) { return; } + stop0(); + } + + private void stop0() { // 1. 停止下行消息订阅者 - try { - downstreamSubscriber.stop(); - log.info("[stop][IoT TCP 协议 {} 下行消息订阅者已停止]", getId()); - } catch (Exception e) { - log.error("[stop][IoT TCP 协议 {} 下行消息订阅者停止失败]", getId(), e); + if (downstreamSubscriber != null) { + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT TCP 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT TCP 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + downstreamSubscriber = null; } - // 2.1 关闭 TCP 服务器 + // 2.1 关闭所有连接 + connectionManager.closeAll(); + // 2.2 关闭 TCP 服务器 if (tcpServer != null) { try { tcpServer.close().result(); @@ -191,7 +190,7 @@ public class IotTcpProtocol implements IotProtocol { } tcpServer = null; } - // 2.2 关闭 Vertx 实例 + // 2.3 关闭 Vertx 实例 if (vertx != null) { try { vertx.close().result(); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java index b3ae6a0ca4..933d56ade7 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamHandler.java @@ -1,5 +1,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.downstream; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.codec.IotTcpFrameCodec; import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; @@ -33,9 +35,17 @@ public class IotTcpDownstreamHandler { */ public void handle(IotDeviceMessage message) { try { + // 1.1 检查是否是属性设置消息 + if (ObjUtil.equals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), message.getMethod())) { + return; + } + if (ObjUtil.notEqual(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), message.getMethod())) { + log.warn("[handle][忽略非属性设置消息: {}]", message.getMethod()); + return; + } log.info("[handle][处理下行消息,设备 ID: {},方法: {},消息 ID: {}]", message.getDeviceId(), message.getMethod(), message.getId()); - // 1. 检查设备连接 + // 1.2 检查设备连接 IotTcpConnectionManager.ConnectionInfo connectionInfo = connectionManager.getConnectionInfoByDeviceId( message.getDeviceId()); if (connectionInfo == null) { diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java index 7a29e6c00c..39a73849fb 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/handler/downstream/IotTcpDownstreamSubscriber.java @@ -3,7 +3,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.tcp.handler.downstream; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; -import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; import lombok.extern.slf4j.Slf4j; /** @@ -12,7 +12,7 @@ import lombok.extern.slf4j.Slf4j; * @author 芋道源码 */ @Slf4j -public class IotTcpDownstreamSubscriber extends IotProtocolDownstreamSubscriber { +public class IotTcpDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { private final IotTcpDownstreamHandler downstreamHandler; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java index b7b72a370b..20065f1b40 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/tcp/manager/IotTcpConnectionManager.java @@ -5,6 +5,8 @@ import io.vertx.core.net.NetSocket; import lombok.Data; import lombok.extern.slf4j.Slf4j; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -122,6 +124,24 @@ public class IotTcpConnectionManager { } } + /** + * 关闭所有连接 + */ + public void closeAll() { + // 1. 先复制再清空,避免 closeHandler 回调时并发修改 + List sockets = new ArrayList<>(connectionMap.keySet()); + connectionMap.clear(); + deviceSocketMap.clear(); + // 2. 关闭所有连接(closeHandler 中 unregisterConnection 发现 map 为空会安全跳过) + for (NetSocket socket : sockets) { + try { + socket.close(); + } catch (Exception ignored) { + // 连接可能已关闭,忽略异常 + } + } + } + /** * 连接信息(包含认证信息) */ diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java index bfed2d9c32..9df3c8bce7 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/IotUdpProtocol.java @@ -63,7 +63,7 @@ public class IotUdpProtocol implements IotProtocol { /** * 下行消息订阅者 */ - private final IotUdpDownstreamSubscriber downstreamSubscriber; + private IotUdpDownstreamSubscriber downstreamSubscriber; /** * 消息序列化器 @@ -85,10 +85,6 @@ public class IotUdpProtocol implements IotProtocol { // 初始化会话管理器 this.sessionManager = new IotUdpSessionManager(udpConfig.getMaxSessions(), udpConfig.getSessionTimeoutMs()); - // 初始化下行消息订阅者 - IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); - IotUdpDownstreamHandler downstreamHandler = new IotUdpDownstreamHandler(this, sessionManager, serializer); - this.downstreamSubscriber = new IotUdpDownstreamSubscriber(this, downstreamHandler, messageBus); } @Override @@ -108,8 +104,11 @@ public class IotUdpProtocol implements IotProtocol { return; } - // 1.1 创建 Vertx 实例 + // 1.1 创建 Vertx 实例 和 下行消息订阅者 this.vertx = Vertx.vertx(); + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + IotUdpDownstreamHandler downstreamHandler = new IotUdpDownstreamHandler(this, sessionManager, serializer); + this.downstreamSubscriber = new IotUdpDownstreamSubscriber(this, downstreamHandler, messageBus); // 1.2 创建 UDP Socket 选项 IotUdpConfig udpConfig = properties.getUdp(); @@ -137,15 +136,7 @@ public class IotUdpProtocol implements IotProtocol { this.downstreamSubscriber.start(); } catch (Exception e) { log.error("[start][IoT UDP 协议 {} 启动失败]", getId(), e); - // 启动失败时关闭资源 - if (udpSocket != null) { - udpSocket.close(); - udpSocket = null; - } - if (vertx != null) { - vertx.close(); - vertx = null; - } + stop0(); throw e; } } @@ -155,12 +146,19 @@ public class IotUdpProtocol implements IotProtocol { if (!running) { return; } + stop0(); + } + + private void stop0() { // 1. 停止下行消息订阅者 - try { - downstreamSubscriber.stop(); - log.info("[stop][IoT UDP 协议 {} 下行消息订阅者已停止]", getId()); - } catch (Exception e) { - log.error("[stop][IoT UDP 协议 {} 下行消息订阅者停止失败]", getId(), e); + if (downstreamSubscriber != null) { + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT UDP 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT UDP 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + downstreamSubscriber = null; } // 2.1 关闭 UDP Socket diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java index ea0bc99b39..cc21df60e3 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/downstream/IotUdpDownstreamSubscriber.java @@ -3,7 +3,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.udp.handler.downstream; import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; -import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; import lombok.extern.slf4j.Slf4j; /** @@ -12,7 +12,7 @@ import lombok.extern.slf4j.Slf4j; * @author 芋道源码 */ @Slf4j -public class IotUdpDownstreamSubscriber extends IotProtocolDownstreamSubscriber { +public class IotUdpDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { private final IotUdpDownstreamHandler downstreamHandler; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java index 7b248ab7c9..4c83a4a256 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/udp/handler/upstream/IotUdpUpstreamHandler.java +++ b/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 { this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class); } - // TODO done @AI:vertx 有 udp 的实现么?当前已使用 Vert.x DatagramSocket 实现 /** * 处理 UDP 数据包 * diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java index b416900db5..083dc32369 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/IotWebSocketProtocol.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.websocket; +import cn.hutool.core.lang.Assert; import cn.hutool.core.util.ObjUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum; @@ -9,8 +10,8 @@ import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties; import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol; -import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.downstream.IotWebSocketDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.downstream.IotWebSocketDownstreamHandler; +import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.downstream.IotWebSocketDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.upstream.IotWebSocketUpstreamHandler; import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager; import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializer; @@ -21,7 +22,6 @@ import io.vertx.core.http.HttpServerOptions; import io.vertx.core.net.PemKeyCertOptions; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import cn.hutool.core.lang.Assert; /** * IoT WebSocket 协议实现 @@ -65,7 +65,7 @@ public class IotWebSocketProtocol implements IotProtocol { /** * 下行消息订阅者 */ - private final IotWebSocketDownstreamSubscriber downstreamSubscriber; + private IotWebSocketDownstreamSubscriber downstreamSubscriber; /** * 消息序列化器 @@ -87,10 +87,6 @@ public class IotWebSocketProtocol implements IotProtocol { // 初始化连接管理器 this.connectionManager = new IotWebSocketConnectionManager(); - // 初始化下行消息订阅者 - IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); - IotWebSocketDownstreamHandler downstreamHandler = new IotWebSocketDownstreamHandler(serializer, connectionManager); - this.downstreamSubscriber = new IotWebSocketDownstreamSubscriber(this, downstreamHandler, messageBus); } @Override @@ -152,17 +148,13 @@ public class IotWebSocketProtocol implements IotProtocol { getId(), properties.getPort(), wsConfig.getPath(), serverId); // 2. 启动下行消息订阅者 + IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class); + IotWebSocketDownstreamHandler downstreamHandler = new IotWebSocketDownstreamHandler(serializer, connectionManager); + this.downstreamSubscriber = new IotWebSocketDownstreamSubscriber(this, downstreamHandler, messageBus); downstreamSubscriber.start(); } catch (Exception e) { log.error("[start][IoT WebSocket 协议 {} 启动失败]", getId(), e); - if (httpServer != null) { - httpServer.close(); - httpServer = null; - } - if (vertx != null) { - vertx.close(); - vertx = null; - } + stop0(); throw e; } } @@ -172,15 +164,24 @@ public class IotWebSocketProtocol implements IotProtocol { if (!running) { return; } + stop0(); + } + + private void stop0() { // 1. 停止下行消息订阅者 - try { - downstreamSubscriber.stop(); - log.info("[stop][IoT WebSocket 协议 {} 下行消息订阅者已停止]", getId()); - } catch (Exception e) { - log.error("[stop][IoT WebSocket 协议 {} 下行消息订阅者停止失败]", getId(), e); + if (downstreamSubscriber != null) { + try { + downstreamSubscriber.stop(); + log.info("[stop][IoT WebSocket 协议 {} 下行消息订阅者已停止]", getId()); + } catch (Exception e) { + log.error("[stop][IoT WebSocket 协议 {} 下行消息订阅者停止失败]", getId(), e); + } + downstreamSubscriber = null; } - // 2.1 关闭 WebSocket 服务器 + // 2.1 关闭所有连接 + connectionManager.closeAll(); + // 2.2 关闭 WebSocket 服务器 if (httpServer != null) { try { httpServer.close().result(); @@ -190,7 +191,7 @@ public class IotWebSocketProtocol implements IotProtocol { } httpServer = null; } - // 2.2 关闭 Vertx 实例 + // 2.3 关闭 Vertx 实例 if (vertx != null) { try { vertx.close().result(); diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java index efe5f437e8..c565be2c95 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/handler/downstream/IotWebSocketDownstreamSubscriber.java @@ -2,7 +2,7 @@ package cn.iocoder.yudao.module.iot.gateway.protocol.websocket.handler.downstrea import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; -import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolDownstreamSubscriber; +import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber; import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketProtocol; import lombok.extern.slf4j.Slf4j; @@ -12,7 +12,7 @@ import lombok.extern.slf4j.Slf4j; * @author 芋道源码 */ @Slf4j -public class IotWebSocketDownstreamSubscriber extends IotProtocolDownstreamSubscriber { +public class IotWebSocketDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber { private final IotWebSocketDownstreamHandler downstreamHandler; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java index 8b09da0f98..92019ffadd 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/websocket/manager/IotWebSocketConnectionManager.java @@ -5,6 +5,8 @@ import lombok.Data; import lombok.experimental.Accessors; import lombok.extern.slf4j.Slf4j; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -114,6 +116,24 @@ public class IotWebSocketConnectionManager { } } + /** + * 关闭所有连接 + */ + public void closeAll() { + // 1. 先复制再清空,避免 closeHandler 回调时并发修改 + List sockets = new ArrayList<>(connectionMap.keySet()); + connectionMap.clear(); + deviceSocketMap.clear(); + // 2. 关闭所有连接(closeHandler 中 unregisterConnection 发现 map 为空会安全跳过) + for (ServerWebSocket socket : sockets) { + try { + socket.close(); + } catch (Exception ignored) { + // 连接可能已关闭,忽略异常 + } + } + } + /** * 连接信息(包含认证信息) */ diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java index 97312559b9..702876db91 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/service/device/remote/IotDeviceApiImpl.java @@ -6,6 +6,8 @@ import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO; import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO; import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO; @@ -24,6 +26,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; +import java.util.List; + import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR; /** @@ -44,7 +48,7 @@ public class IotDeviceApiImpl implements IotDeviceCommonApi { public void init() { IotGatewayProperties.RpcProperties rpc = gatewayProperties.getRpc(); restTemplate = new RestTemplateBuilder() - .rootUri(rpc.getUrl() + "/rpc-api/iot/device") + .rootUri(rpc.getUrl()) .readTimeout(rpc.getReadTimeout()) .connectTimeout(rpc.getConnectTimeout()) .build(); @@ -52,12 +56,17 @@ public class IotDeviceApiImpl implements IotDeviceCommonApi { @Override public CommonResult authDevice(IotDeviceAuthReqDTO authReqDTO) { - return doPost("/auth", authReqDTO, new ParameterizedTypeReference<>() { }); + return doPost("/rpc-api/iot/device/auth", authReqDTO, new ParameterizedTypeReference<>() { }); } @Override public CommonResult getDevice(IotDeviceGetReqDTO getReqDTO) { - return doPost("/get", getReqDTO, new ParameterizedTypeReference<>() { }); + return doPost("/rpc-api/iot/device/get", getReqDTO, new ParameterizedTypeReference<>() { }); + } + + @Override + public CommonResult> getModbusDeviceConfigList(IotModbusDeviceConfigListReqDTO listReqDTO) { + return doPost("/rpc-api/iot/modbus/config-list", listReqDTO, new ParameterizedTypeReference<>() { }); } @Override diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java index 249b31544f..60e8d6c7be 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/util/IotMqttTopicUtils.java @@ -1,6 +1,8 @@ package cn.iocoder.yudao.module.iot.gateway.util; import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants; +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; /** * IoT 网关 MQTT 主题工具类 @@ -44,6 +46,32 @@ public final class IotMqttTopicUtils { */ public static final String MQTT_ACL_PATH = "/mqtt/acl"; + // ========== 消息方法标准化 ========== + + /** + * 标准化设备回复消息的 method + *

+ * MQTT 协议中,设备回复下行指令时,topic 和 method 会携带 _reply 后缀 + * (如 thing.service.invoke_reply)。平台内部统一使用基础 method(如 thing.service.invoke), + * 通过 {@link IotDeviceMessage#getCode()} 非空来识别回复消息。 + *

+ * 此方法剥离 _reply 后缀,并确保 code 字段被设置。 + * + * @param message 设备消息 + */ + public static void normalizeReplyMethod(IotDeviceMessage message) { + String method = message.getMethod(); + if (!StrUtil.endWith(method, REPLY_TOPIC_SUFFIX)) { + return; + } + // 1. 剥离 _reply 后缀 + message.setMethod(method.substring(0, method.length() - REPLY_TOPIC_SUFFIX.length())); + // 2. 确保 code 被设置,使 isReplyMessage() 能正确识别 + if (message.getCode() == null) { + message.setCode(GlobalErrorCodeConstants.SUCCESS.getCode()); + } + } + // ========== 工具方法 ========== /** @@ -101,8 +129,6 @@ public final class IotMqttTopicUtils { * @param deviceName 设备名称 * @return 是否允许发布 */ - // TODO DONE @AI:这个逻辑,是不是 mqtt 协议,也要使用???答:是通用工具方法,MQTT 协议可按需调用; - // TODO @AI:那你改下 mqtt,也调用!!! public static boolean isTopicPublishAllowed(String topic, String productKey, String deviceName) { if (!StrUtil.isAllNotBlank(topic, productKey, deviceName)) { return false; diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml b/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml index 9c6b500d5b..71ee6f3361 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml +++ b/yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml @@ -51,8 +51,6 @@ yudao: protocol: http port: 8092 enabled: false - http: - ssl-enabled: false # ==================================== # 针对引入的 TCP 组件的配置 # ==================================== @@ -64,7 +62,6 @@ yudao: tcp: max-connections: 1000 keep-alive-timeout-ms: 30000 - ssl-enabled: false codec: type: delimiter # 拆包类型:length_field / delimiter / fixed_length delimiter: "\\n" # 分隔符(支持转义:\\n=换行, \\r=回车, \\t=制表符) @@ -101,7 +98,6 @@ yudao: max-message-size: 65536 # 最大消息大小(字节,默认 64KB) max-frame-size: 65536 # 最大帧大小(字节,默认 64KB) idle-timeout-seconds: 60 # 空闲超时时间(秒,默认 60) - ssl-enabled: false # 是否启用 SSL(wss://) # ==================================== # 针对引入的 CoAP 组件的配置 # ==================================== @@ -117,14 +113,13 @@ yudao: # 针对引入的 MQTT 组件的配置 # ==================================== - id: mqtt-json - enabled: true + enabled: false protocol: mqtt port: 1883 serialize: json mqtt: max-message-size: 8192 # 最大消息大小(字节) connect-timeout-seconds: 60 # 连接超时时间(秒) - ssl-enabled: false # 是否启用 SSL # ==================================== # 针对引入的 EMQX 组件的配置 # ==================================== @@ -168,6 +163,27 @@ yudao: key-store-password: "your-keystore-password" # 客户端证书库密码 trust-store-path: "classpath:certs/trust.jks" # 信任的 CA 证书库路径 trust-store-password: "your-truststore-password" # 信任的 CA 证书库密码 + # ==================================== + # 针对引入的 Modbus TCP Client 组件的配置 + # ==================================== + - id: modbus-tcp-client-1 + enabled: false + protocol: modbus_tcp_client + port: 502 + modbus-tcp-client: + config-refresh-interval: 30 # 配置刷新间隔(秒) + # ==================================== + # 针对引入的 Modbus TCP Server 组件的配置 + # ==================================== + - id: modbus-tcp-server-1 + enabled: false + protocol: modbus_tcp_server + port: 503 + modbus-tcp-server: + config-refresh-interval: 30 # 配置刷新间隔(秒) + custom-function-code: 65 # 自定义功能码(用于认证等扩展交互) + request-timeout: 5000 # Pending Request 超时时间(毫秒) + request-cleanup-interval: 10000 # Pending Request 清理间隔(毫秒) --- #################### 日志相关配置 #################### @@ -189,6 +205,7 @@ logging: cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG cn.iocoder.yudao.module.iot.gateway.protocol.coap: DEBUG cn.iocoder.yudao.module.iot.gateway.protocol.websocket: DEBUG + cn.iocoder.yudao.module.iot.gateway.protocol.modbus: DEBUG # 根日志级别 root: INFO diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/ModbusRtuOverTcpDemo.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/ModbusRtuOverTcpDemo.java new file mode 100644 index 0000000000..80d4c91199 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/ModbusRtuOverTcpDemo.java @@ -0,0 +1,304 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus; + +import com.ghgande.j2mod.modbus.io.ModbusRTUTCPTransport; +import com.ghgande.j2mod.modbus.msg.*; +import com.ghgande.j2mod.modbus.procimg.*; +import com.ghgande.j2mod.modbus.slave.ModbusSlave; +import com.ghgande.j2mod.modbus.slave.ModbusSlaveFactory; + +import java.net.ServerSocket; +import java.net.Socket; + +/** + * Modbus RTU over TCP 完整 Demo + * + * 架构:Master(主站)启动 TCP Server 监听 → Slave(从站)主动 TCP 连接上来 + * 通信协议:RTU 帧格式(带 CRC)通过 TCP 传输,而非标准 MBAP 头 + * + * 流程: + * 1. Master 启动 TCP ServerSocket 监听端口 + * 2. Slave(从站模拟器)作为 TCP Client 连接到 Master + * 3. Master 通过 accept 得到的 Socket,使用 {@link ModbusRTUTCPTransport} 发送读写请求 + * + * 实现说明: + * 因为 j2mod 的 ModbusSlave 只能以 TCP Server 模式运行(监听端口等待 Master 连接), + * 不支持"Slave 作为 TCP Client 主动连接 Master"的模式。 + * 所以这里用一个 TCP 桥接(bridge)来模拟: + * - Slave 在本地内部端口启动(RTU over TCP 模式) + * - 一个桥接线程同时连接 Master Server 和 Slave 内部端口,做双向数据转发 + * - Master 视角:看到的是 Slave 主动连上来 + * + * 依赖:j2mod 3.2.1(pom.xml 中已声明) + * + * @author 芋道源码 + */ +@Deprecated // 仅技术演示,非是必须的 +public class ModbusRtuOverTcpDemo { + + /** + * Master(主站)TCP Server 监听端口 + */ + private static final int PORT = 5021; + /** + * Slave 内部端口(仅本地中转用,不对外暴露) + */ + private static final int SLAVE_INTERNAL_PORT = PORT + 100; + /** + * Modbus 从站地址 + */ + private static final int SLAVE_ID = 1; + + public static void main(String[] args) throws Exception { + // ===================== 第一步:Master 启动 TCP Server 监听 ===================== + ServerSocket serverSocket = new ServerSocket(PORT); + System.out.println("==================================================="); + System.out.println("[Master] TCP Server 已启动,监听端口: " + PORT); + System.out.println("[Master] 等待 Slave 连接..."); + System.out.println("==================================================="); + + // ===================== 第二步:后台启动 Slave,它会主动连接 Master ===================== + ModbusSlave slave = startSlaveInBackground(); + + // Master accept Slave 的连接 + Socket slaveSocket = serverSocket.accept(); + System.out.println("[Master] Slave 已连接: " + slaveSocket.getRemoteSocketAddress()); + + // ===================== 第三步:Master 通过 RTU over TCP 发送读写请求 ===================== + // 使用 ModbusRTUTCPTransport 包装 Socket(RTU 帧 = SlaveID + 功能码 + 数据 + CRC,无 MBAP 头) + ModbusRTUTCPTransport transport = new ModbusRTUTCPTransport(slaveSocket); + + try { + System.out.println("[Master] RTU over TCP 通道已建立\n"); + + // 1. 读操作演示:4 种功能码 + demoReadCoils(transport); // 功能码 01:读线圈 + demoReadDiscreteInputs(transport); // 功能码 02:读离散输入 + demoReadHoldingRegisters(transport); // 功能码 03:读保持寄存器 + demoReadInputRegisters(transport); // 功能码 04:读输入寄存器 + + // 2. 写操作演示 + 读回验证 + demoWriteCoil(transport); // 功能码 05:写单个线圈 + demoWriteRegister(transport); // 功能码 06:写单个保持寄存器 + + System.out.println("\n==================================================="); + System.out.println("所有 RTU over TCP 读写操作执行成功!"); + System.out.println("==================================================="); + } finally { + // 清理资源 + transport.close(); + slaveSocket.close(); + serverSocket.close(); + slave.close(); + System.out.println("[Master] 资源已关闭"); + } + } + + // ===================== Slave 设备模拟(作为 TCP Client 连接 Master) ===================== + + /** + * 在后台启动从站模拟器,并通过 TCP 桥接连到 Master Server + * + * @return ModbusSlave 实例(用于最后关闭资源) + */ + private static ModbusSlave startSlaveInBackground() throws Exception { + // 1. 创建进程映像,初始化寄存器数据 + SimpleProcessImage spi = new SimpleProcessImage(SLAVE_ID); + // 1.1 线圈(Coil,功能码 01/05)- 可读写,地址 0~9 + for (int i = 0; i < 10; i++) { + spi.addDigitalOut(new SimpleDigitalOut(i % 2 == 0)); + } + // 1.2 离散输入(Discrete Input,功能码 02)- 只读,地址 0~9 + for (int i = 0; i < 10; i++) { + spi.addDigitalIn(new SimpleDigitalIn(i % 3 == 0)); + } + // 1.3 保持寄存器(Holding Register,功能码 03/06/16)- 可读写,地址 0~19 + for (int i = 0; i < 20; i++) { + spi.addRegister(new SimpleRegister(i * 100)); + } + // 1.4 输入寄存器(Input Register,功能码 04)- 只读,地址 0~19 + for (int i = 0; i < 20; i++) { + spi.addInputRegister(new SimpleInputRegister(i * 10 + 1)); + } + + // 2. 启动 Slave(RTU over TCP 模式,在本地内部端口监听) + ModbusSlave slave = ModbusSlaveFactory.createTCPSlave(SLAVE_INTERNAL_PORT, 5, true); + slave.addProcessImage(SLAVE_ID, spi); + slave.open(); + System.out.println("[Slave] 从站模拟器已启动(内部端口: " + SLAVE_INTERNAL_PORT + ")"); + + // 3. 启动桥接线程:TCP Client 连接 Master Server,同时连接 Slave 内部端口,双向转发 + Thread bridgeThread = new Thread(() -> { + try { + Socket toMaster = new Socket("127.0.0.1", PORT); + Socket toSlave = new Socket("127.0.0.1", SLAVE_INTERNAL_PORT); + System.out.println("[Bridge] 已建立桥接: Master(" + PORT + ") <-> Slave(" + SLAVE_INTERNAL_PORT + ")"); + + // 双向桥接:Master ↔ Bridge ↔ Slave + Thread forward = new Thread(() -> bridge(toMaster, toSlave), "bridge-master→slave"); + Thread backward = new Thread(() -> bridge(toSlave, toMaster), "bridge-slave→master"); + forward.setDaemon(true); + backward.setDaemon(true); + forward.start(); + backward.start(); + } catch (Exception e) { + e.printStackTrace(); + } + }, "bridge-setup"); + bridgeThread.setDaemon(true); + bridgeThread.start(); + + return slave; + } + + /** + * TCP 双向桥接:从 src 读取数据,写入 dst + */ + private static void bridge(Socket src, Socket dst) { + try { + byte[] buf = new byte[1024]; + var in = src.getInputStream(); + var out = dst.getOutputStream(); + int len; + while ((len = in.read(buf)) != -1) { + out.write(buf, 0, len); + out.flush(); + } + } catch (Exception ignored) { + // 连接关闭时正常退出 + } + } + + // ===================== Master 读写操作 ===================== + + /** + * 发送请求并接收响应(通用方法) + */ + private static ModbusResponse sendRequest(ModbusRTUTCPTransport transport, ModbusRequest request) throws Exception { + request.setUnitID(SLAVE_ID); + transport.writeRequest(request); + return transport.readResponse(); + } + + /** + * 功能码 01:读线圈(Read Coils) + */ + private static void demoReadCoils(ModbusRTUTCPTransport transport) throws Exception { + ReadCoilsRequest request = new ReadCoilsRequest(0, 5); + ReadCoilsResponse response = (ReadCoilsResponse) sendRequest(transport, request); + + StringBuilder sb = new StringBuilder("[功能码 01] 读线圈(0~4): "); + for (int i = 0; i < 5; i++) { + sb.append(response.getCoilStatus(i) ? "ON" : "OFF"); + if (i < 4) { + sb.append(", "); + } + } + System.out.println(sb); + } + + /** + * 功能码 02:读离散输入(Read Discrete Inputs) + */ + private static void demoReadDiscreteInputs(ModbusRTUTCPTransport transport) throws Exception { + ReadInputDiscretesRequest request = new ReadInputDiscretesRequest(0, 5); + ReadInputDiscretesResponse response = (ReadInputDiscretesResponse) sendRequest(transport, request); + + StringBuilder sb = new StringBuilder("[功能码 02] 读离散输入(0~4): "); + for (int i = 0; i < 5; i++) { + sb.append(response.getDiscreteStatus(i) ? "ON" : "OFF"); + if (i < 4) { + sb.append(", "); + } + } + System.out.println(sb); + } + + /** + * 功能码 03:读保持寄存器(Read Holding Registers) + */ + private static void demoReadHoldingRegisters(ModbusRTUTCPTransport transport) throws Exception { + ReadMultipleRegistersRequest request = new ReadMultipleRegistersRequest(0, 5); + ReadMultipleRegistersResponse response = (ReadMultipleRegistersResponse) sendRequest(transport, request); + + StringBuilder sb = new StringBuilder("[功能码 03] 读保持寄存器(0~4): "); + for (int i = 0; i < response.getWordCount(); i++) { + sb.append(response.getRegisterValue(i)); + if (i < response.getWordCount() - 1) { + sb.append(", "); + } + } + System.out.println(sb); + } + + /** + * 功能码 04:读输入寄存器(Read Input Registers) + */ + private static void demoReadInputRegisters(ModbusRTUTCPTransport transport) throws Exception { + ReadInputRegistersRequest request = new ReadInputRegistersRequest(0, 5); + ReadInputRegistersResponse response = (ReadInputRegistersResponse) sendRequest(transport, request); + + StringBuilder sb = new StringBuilder("[功能码 04] 读输入寄存器(0~4): "); + for (int i = 0; i < response.getWordCount(); i++) { + sb.append(response.getRegisterValue(i)); + if (i < response.getWordCount() - 1) { + sb.append(", "); + } + } + System.out.println(sb); + } + + /** + * 功能码 05:写单个线圈(Write Single Coil)+ 读回验证 + */ + private static void demoWriteCoil(ModbusRTUTCPTransport transport) throws Exception { + int address = 0; + + // 1. 先读取当前值 + ReadCoilsRequest readReq = new ReadCoilsRequest(address, 1); + ReadCoilsResponse readResp = (ReadCoilsResponse) sendRequest(transport, readReq); + boolean beforeValue = readResp.getCoilStatus(0); + + // 2. 写入相反的值 + boolean writeValue = !beforeValue; + WriteCoilRequest writeReq = new WriteCoilRequest(address, writeValue); + sendRequest(transport, writeReq); + + // 3. 读回验证 + ReadCoilsResponse verifyResp = (ReadCoilsResponse) sendRequest(transport, readReq); + boolean afterValue = verifyResp.getCoilStatus(0); + + System.out.println("[功能码 05] 写线圈: 地址=" + address + + ", 写入前=" + (beforeValue ? "ON" : "OFF") + + ", 写入值=" + (writeValue ? "ON" : "OFF") + + ", 读回值=" + (afterValue ? "ON" : "OFF") + + (afterValue == writeValue ? " ✓ 验证通过" : " ✗ 验证失败")); + } + + /** + * 功能码 06:写单个保持寄存器(Write Single Register)+ 读回验证 + */ + private static void demoWriteRegister(ModbusRTUTCPTransport transport) throws Exception { + int address = 0; + int writeValue = 12345; + + // 1. 先读取当前值 + ReadMultipleRegistersRequest readReq = new ReadMultipleRegistersRequest(address, 1); + ReadMultipleRegistersResponse readResp = (ReadMultipleRegistersResponse) sendRequest(transport, readReq); + int beforeValue = readResp.getRegisterValue(0); + + // 2. 写入新值 + WriteSingleRegisterRequest writeReq = new WriteSingleRegisterRequest(address, new SimpleRegister(writeValue)); + sendRequest(transport, writeReq); + + // 3. 读回验证 + ReadMultipleRegistersResponse verifyResp = (ReadMultipleRegistersResponse) sendRequest(transport, readReq); + int afterValue = verifyResp.getRegisterValue(0); + + System.out.println("[功能码 06] 写保持寄存器: 地址=" + address + + ", 写入前=" + beforeValue + + ", 写入值=" + writeValue + + ", 读回值=" + afterValue + + (afterValue == writeValue ? " ✓ 验证通过" : " ✗ 验证失败")); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IoTModbusTcpClientIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IoTModbusTcpClientIntegrationTest.java new file mode 100644 index 0000000000..62724b2f42 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpclient/IoTModbusTcpClientIntegrationTest.java @@ -0,0 +1,114 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient; + +import com.ghgande.j2mod.modbus.procimg.*; +import com.ghgande.j2mod.modbus.slave.ModbusSlave; +import com.ghgande.j2mod.modbus.slave.ModbusSlaveFactory; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +/** + * Modbus TCP 从站模拟器(手动测试) + * + *

测试场景:模拟一个标准 Modbus TCP 从站设备,供 Modbus TCP Client 网关连接和读写数据 + * + *

使用步骤: + *

    + *
  1. 运行 {@link #testStartSlaveSimulator()} 启动模拟从站(默认端口 5020,从站地址 1)
  2. + *
  3. 启动 yudao-module-iot-gateway 服务(需开启 modbus-tcp-client 协议)
  4. + *
  5. 确保数据库有对应的 Modbus Client 设备配置(ip=127.0.0.1, port=5020, slaveId=1)
  6. + *
  7. 网关会自动连接模拟从站并开始轮询读取寄存器数据
  8. + *
  9. 模拟器每 5 秒自动更新输入寄存器和保持寄存器的值,模拟传感器数据变化
  10. + *
+ * + *

可用寄存器: + *

    + *
  • 线圈 (Coil, 功能码 01/05): 地址 0-9,交替 true/false
  • + *
  • 离散输入 (Discrete Input, 功能码 02): 地址 0-9,每 3 个一个 true
  • + *
  • 保持寄存器 (Holding Register, 功能码 03/06/16): 地址 0-19,初始值 0,100,200,...
  • + *
  • 输入寄存器 (Input Register, 功能码 04): 地址 0-19,初始值 1,11,21,...
  • + *
+ * + * @author 芋道源码 + */ +@Slf4j +@Disabled +public class IoTModbusTcpClientIntegrationTest { + + private static final int PORT = 5020; + private static final int SLAVE_ID = 1; + + /** + * 启动 Modbus TCP 从站模拟器 + * + *

模拟器会持续运行,每 5 秒更新一次寄存器数据,直到手动停止 + */ + @SuppressWarnings({"InfiniteLoopStatement", "BusyWait"}) + @Test + public void testStartSlaveSimulator() throws Exception { + // 1. 创建进程映像(Process Image),存储寄存器数据 + SimpleProcessImage spi = new SimpleProcessImage(SLAVE_ID); + + // 2.1 初始化线圈(Coil,功能码 01/05)- 离散输出,可读写 + // 地址 0-9,共 10 个线圈 + for (int i = 0; i < 10; i++) { + spi.addDigitalOut(new SimpleDigitalOut(i % 2 == 0)); // 交替 true/false + } + + // 2.2 初始化离散输入(Discrete Input,功能码 02)- 只读 + // 地址 0-9,共 10 个离散输入 + for (int i = 0; i < 10; i++) { + spi.addDigitalIn(new SimpleDigitalIn(i % 3 == 0)); // 每 3 个一个 true + } + + // 2.3 初始化保持寄存器(Holding Register,功能码 03/06/16)- 可读写 + // 地址 0-19,共 20 个寄存器 + for (int i = 0; i < 20; i++) { + spi.addRegister(new SimpleRegister(i * 100)); // 值为 0, 100, 200, ... + } + + // 2.4 初始化输入寄存器(Input Register,功能码 04)- 只读 + // 地址 0-19,共 20 个寄存器 + SimpleInputRegister[] inputRegisters = new SimpleInputRegister[20]; + for (int i = 0; i < 20; i++) { + inputRegisters[i] = new SimpleInputRegister(i * 10 + 1); // 值为 1, 11, 21, ... + spi.addInputRegister(inputRegisters[i]); + } + + // 3.1 创建 Modbus TCP 从站 + ModbusSlave slave = ModbusSlaveFactory.createTCPSlave(PORT, 5); + slave.addProcessImage(SLAVE_ID, spi); + // 3.2 启动从站 + slave.open(); + + log.info("[testStartSlaveSimulator][Modbus TCP 从站模拟器已启动, 端口: {}, 从站地址: {}]", PORT, SLAVE_ID); + log.info("[testStartSlaveSimulator][可用寄存器: 线圈(01/05) 0-9, 离散输入(02) 0-9, " + + "保持寄存器(03/06/16) 0-19, 输入寄存器(04) 0-19]"); + + // 4. 添加关闭钩子 + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + log.info("[testStartSlaveSimulator][正在关闭模拟器...]"); + slave.close(); + log.info("[testStartSlaveSimulator][模拟器已关闭]"); + })); + + // 5. 保持运行,定时更新输入寄存器模拟数据变化 + int counter = 0; + while (true) { + Thread.sleep(5000); // 每 5 秒更新一次 + counter++; + + // 更新输入寄存器的值,模拟传感器数据变化 + for (int i = 0; i < 20; i++) { + int newValue = (i * 10 + 1) + counter; + inputRegisters[i].setValue(newValue); + } + + // 更新保持寄存器的第一个值 + spi.getRegister(0).setValue(counter * 100); + log.info("[testStartSlaveSimulator][数据已更新, counter={}, 保持寄存器[0]={}, 输入寄存器[0]={}]", + counter, counter * 100, 1 + counter); + } + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerRtuIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerRtuIntegrationTest.java new file mode 100644 index 0000000000..24029a19e6 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerRtuIntegrationTest.java @@ -0,0 +1,302 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver; + +import cn.hutool.core.util.HexUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameDecoder; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetClient; +import io.vertx.core.net.NetClientOptions; +import io.vertx.core.net.NetSocket; +import io.vertx.core.parsetools.RecordParser; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * IoT Modbus TCP Server 协议集成测试 — MODBUS_RTU 帧格式(手动测试) + * + *

测试场景:设备(TCP Client)连接到网关(TCP Server),使用 MODBUS_RTU(CRC16)帧格式通信 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(需开启 modbus-tcp-server 协议,默认端口 503)
  2. + *
  3. 确保数据库有对应的 Modbus 设备配置(mode=1, frameFormat=modbus_rtu)
  4. + *
  5. 运行以下测试方法: + *
      + *
    • {@link #testAuth()} - 自定义功能码认证
    • + *
    • {@link #testPollingResponse()} - 轮询响应
    • + *
    • {@link #testPropertySetWrite()} - 属性设置(接收写指令)
    • + *
    + *
  6. + *
+ * + * @author 芋道源码 + */ +@Slf4j +@Disabled +public class IotModbusTcpServerRtuIntegrationTest { + + private static final String SERVER_HOST = "127.0.0.1"; + private static final int SERVER_PORT = 503; + private static final int TIMEOUT_MS = 5000; + + private static final int CUSTOM_FC = 65; + private static final int SLAVE_ID = 1; + + private static Vertx vertx; + private static NetClient netClient; + + // ===================== 编解码器 ===================== + + private static final IotModbusFrameDecoder FRAME_DECODER = new IotModbusFrameDecoder(CUSTOM_FC); + private static final IotModbusFrameEncoder FRAME_ENCODER = new IotModbusFrameEncoder(CUSTOM_FC); + + // ===================== 设备信息(根据实际情况修改,从 iot_device 表查询) ===================== + + private static final String PRODUCT_KEY = "modbus_tcp_server_product_demo"; + private static final String DEVICE_NAME = "modbus_tcp_server_device_demo_rtu"; + private static final String DEVICE_SECRET = "af01c55eb8e3424bb23fc6c783936b2e"; + + @BeforeAll + static void setUp() { + vertx = Vertx.vertx(); + NetClientOptions options = new NetClientOptions() + .setConnectTimeout(TIMEOUT_MS) + .setIdleTimeout(TIMEOUT_MS); + netClient = vertx.createNetClient(options); + } + + @AfterAll + static void tearDown() { + if (netClient != null) { + netClient.close(); + } + if (vertx != null) { + vertx.close(); + } + } + + // ===================== 认证测试 ===================== + + /** + * 认证测试:发送自定义功能码 FC65 认证帧(RTU 格式),验证认证成功响应 + */ + @Test + public void testAuth() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 构造并发送认证帧 + IotModbusFrame response = authenticate(socket); + + // 2. 验证响应 + log.info("[testAuth][认证响应帧: slaveId={}, FC={}, customData={}]", + response.getSlaveId(), response.getFunctionCode(), response.getCustomData()); + JSONObject json = JSONUtil.parseObj(response.getCustomData()); + assertEquals(0, json.getInt("code")); + log.info("[testAuth][认证结果: code={}, message={}]", json.getInt("code"), json.getStr("message")); + } finally { + socket.close(); + } + } + + // ===================== 轮询响应测试 ===================== + + /** + * 轮询响应测试:认证后持续监听网关下发的读请求(RTU 格式),每次收到都自动构造读响应帧发回 + */ + @Test + public void testPollingResponse() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先认证 + IotModbusFrame authResponse = authenticate(socket); + log.info("[testPollingResponse][认证响应: {}]", authResponse.getCustomData()); + JSONObject authJson = JSONUtil.parseObj(authResponse.getCustomData()); + assertEquals(0, authJson.getInt("code")); + + // 2. 设置持续监听:每收到一个读请求,自动回复 + log.info("[testPollingResponse][开始持续监听网关下发的读请求...]"); + CompletableFuture done = new CompletableFuture<>(); + // 注意:使用 requestMode=true,因为设备端收到的是网关下发的读请求(非响应) + RecordParser parser = FRAME_DECODER.createRecordParser((frame, frameFormat) -> { + log.info("[testPollingResponse][收到请求: slaveId={}, FC={}]", + frame.getSlaveId(), frame.getFunctionCode()); + // 解析读请求中的起始地址和数量 + byte[] pdu = frame.getPdu(); + int startAddress = ((pdu[0] & 0xFF) << 8) | (pdu[1] & 0xFF); + int quantity = ((pdu[2] & 0xFF) << 8) | (pdu[3] & 0xFF); + log.info("[testPollingResponse][读请求参数: startAddress={}, quantity={}]", startAddress, quantity); + + // 构造读响应帧(模拟寄存器数据,RTU 格式) + int[] registerValues = new int[quantity]; + for (int i = 0; i < quantity; i++) { + registerValues[i] = 100 + i * 100; // 模拟值: 100, 200, 300, ... + } + byte[] responseData = buildReadResponse(frame.getSlaveId(), + frame.getFunctionCode(), registerValues); + socket.write(Buffer.buffer(responseData)); + log.info("[testPollingResponse][已发送读响应, registerValues={}]", registerValues); + }, true); + socket.handler(parser); + + // 3. 持续等待(200 秒),期间会自动回复所有收到的读请求 + Thread.sleep(200000); + } finally { + socket.close(); + } + } + + // ===================== 属性设置测试 ===================== + + /** + * 属性设置测试:认证后等待接收网关下发的 FC06/FC16 写请求(RTU 格式) + *

+ * 注意:需手动在平台触发 property.set + */ + @Test + public void testPropertySetWrite() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先认证 + IotModbusFrame authResponse = authenticate(socket); + log.info("[testPropertySetWrite][认证响应: {}]", authResponse.getCustomData()); + + // 2. 等待网关下发写请求(需手动在平台触发 property.set) + log.info("[testPropertySetWrite][等待网关下发写请求(请在平台触发 property.set)...]"); + IotModbusFrame writeRequest = waitForRequest(socket); + log.info("[testPropertySetWrite][收到写请求: slaveId={}, FC={}, pdu={}]", + writeRequest.getSlaveId(), writeRequest.getFunctionCode(), + HexUtil.encodeHexStr(writeRequest.getPdu())); + } finally { + socket.close(); + } + } + + // ===================== 辅助方法 ===================== + + /** + * 建立 TCP 连接 + */ + private CompletableFuture connect() { + CompletableFuture future = new CompletableFuture<>(); + netClient.connect(SERVER_PORT, SERVER_HOST) + .onSuccess(future::complete) + .onFailure(future::completeExceptionally); + return future; + } + + /** + * 执行认证并返回响应帧 + */ + private IotModbusFrame authenticate(NetSocket socket) throws Exception { + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); + authInfo.setClientId(""); + byte[] authFrame = buildAuthFrame(authInfo.getClientId(), authInfo.getUsername(), authInfo.getPassword()); + return sendAndReceive(socket, authFrame); + } + + /** + * 发送帧并等待响应(使用 IotModbusFrameDecoder 自动检测帧格式并解码) + */ + private IotModbusFrame sendAndReceive(NetSocket socket, byte[] frameData) throws Exception { + CompletableFuture responseFuture = new CompletableFuture<>(); + // 使用 FrameDecoder 创建拆包器(自动检测帧格式 + 解码,直接回调 IotModbusFrame) + RecordParser parser = FRAME_DECODER.createRecordParser( + (frame, frameFormat) -> { + try { + log.info("[sendAndReceive][检测到帧格式: {}]", frameFormat); + responseFuture.complete(frame); + } catch (Exception e) { + responseFuture.completeExceptionally(e); + } + }); + socket.handler(parser); + + // 发送请求 + log.info("[sendAndReceive][发送帧, 长度={}]", frameData.length); + socket.write(Buffer.buffer(frameData)); + + // 等待响应 + return responseFuture.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + } + + /** + * 等待接收网关下发的请求帧(不发送,只等待接收) + */ + private IotModbusFrame waitForRequest(NetSocket socket) throws Exception { + CompletableFuture requestFuture = new CompletableFuture<>(); + // 使用 FrameDecoder 创建拆包器(直接回调 IotModbusFrame) + RecordParser parser = FRAME_DECODER.createRecordParser( + (frame, frameFormat) -> { + try { + log.info("[waitForRequest][检测到帧格式: {}]", frameFormat); + requestFuture.complete(frame); + } catch (Exception e) { + requestFuture.completeExceptionally(e); + } + }); + socket.handler(parser); + + // 等待(超时 30 秒,因为轮询间隔可能比较长) + return requestFuture.get(30000, TimeUnit.MILLISECONDS); + } + + /** + * 构造认证帧(MODBUS_RTU 格式) + *

+ * JSON: {"method":"auth","params":{"clientId":"...","username":"...","password":"..."}} + *

+ * RTU 帧格式:[SlaveId(1)] [FC=0x41(1)] [ByteCount(1)] [JSON(N)] [CRC16(2)] + */ + private byte[] buildAuthFrame(String clientId, String username, String password) { + JSONObject params = new JSONObject(); + params.set("clientId", clientId); + params.set("username", username); + params.set("password", password); + JSONObject json = new JSONObject(); + json.set("method", "auth"); + json.set("params", params); + return FRAME_ENCODER.encodeCustomFrame(SLAVE_ID, json.toString(), + IotModbusFrameFormatEnum.MODBUS_RTU, 0); + } + + /** + * 构造 FC03/FC01-04 读响应帧(MODBUS_RTU 格式) + *

+ * RTU 帧格式:[SlaveId(1)] [FC(1)] [ByteCount(1)] [RegisterData(N*2)] [CRC16(2)] + */ + private byte[] buildReadResponse(int slaveId, int functionCode, int[] registerValues) { + int byteCount = registerValues.length * 2; + // 帧长度:SlaveId(1) + FC(1) + ByteCount(1) + Data(N*2) + CRC(2) + int totalLength = 1 + 1 + 1 + byteCount + 2; + byte[] frame = new byte[totalLength]; + frame[0] = (byte) slaveId; + frame[1] = (byte) functionCode; + frame[2] = (byte) byteCount; + for (int i = 0; i < registerValues.length; i++) { + frame[3 + i * 2] = (byte) ((registerValues[i] >> 8) & 0xFF); + frame[3 + i * 2 + 1] = (byte) (registerValues[i] & 0xFF); + } + // 计算 CRC16 + int crc = IotModbusCommonUtils.calculateCrc16(frame, totalLength - 2); + frame[totalLength - 2] = (byte) (crc & 0xFF); // CRC Low + frame[totalLength - 1] = (byte) ((crc >> 8) & 0xFF); // CRC High + return frame; + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerTcpIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerTcpIntegrationTest.java new file mode 100644 index 0000000000..d00da5fe87 --- /dev/null +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/modbus/tcpserver/IotModbusTcpServerTcpIntegrationTest.java @@ -0,0 +1,302 @@ +package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver; + +import cn.hutool.core.util.HexUtil; +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; +import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusFrameFormatEnum; +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameDecoder; +import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrameEncoder; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetClient; +import io.vertx.core.net.NetClientOptions; +import io.vertx.core.net.NetSocket; +import io.vertx.core.parsetools.RecordParser; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * IoT Modbus TCP Server 协议集成测试 — MODBUS_TCP 帧格式(手动测试) + * + *

测试场景:设备(TCP Client)连接到网关(TCP Server),使用 MODBUS_TCP(MBAP 头)帧格式通信 + * + *

使用步骤: + *

    + *
  1. 启动 yudao-module-iot-gateway 服务(需开启 modbus-tcp-server 协议,默认端口 503)
  2. + *
  3. 确保数据库有对应的 Modbus 设备配置(mode=1, frameFormat=modbus_tcp)
  4. + *
  5. 运行以下测试方法: + *
      + *
    • {@link #testAuth()} - 自定义功能码认证
    • + *
    • {@link #testPollingResponse()} - 轮询响应
    • + *
    • {@link #testPropertySetWrite()} - 属性设置(接收写指令)
    • + *
    + *
  6. + *
+ * + * @author 芋道源码 + */ +@Slf4j +@Disabled +public class IotModbusTcpServerTcpIntegrationTest { + + private static final String SERVER_HOST = "127.0.0.1"; + private static final int SERVER_PORT = 503; + private static final int TIMEOUT_MS = 5000; + + private static final int CUSTOM_FC = 65; + private static final int SLAVE_ID = 1; + + private static Vertx vertx; + private static NetClient netClient; + + // ===================== 编解码器 ===================== + + private static final IotModbusFrameDecoder FRAME_DECODER = new IotModbusFrameDecoder(CUSTOM_FC); + private static final IotModbusFrameEncoder FRAME_ENCODER = new IotModbusFrameEncoder(CUSTOM_FC); + + // ===================== 设备信息(根据实际情况修改,从 iot_device 表查询) ===================== + + private static final String PRODUCT_KEY = "modbus_tcp_server_product_demo"; + private static final String DEVICE_NAME = "modbus_tcp_server_device_demo_tcp"; + private static final String DEVICE_SECRET = "8e4adeb3d25342ab88643421d3fba3f6"; + + @BeforeAll + static void setUp() { + vertx = Vertx.vertx(); + NetClientOptions options = new NetClientOptions() + .setConnectTimeout(TIMEOUT_MS) + .setIdleTimeout(TIMEOUT_MS); + netClient = vertx.createNetClient(options); + } + + @AfterAll + static void tearDown() { + if (netClient != null) { + netClient.close(); + } + if (vertx != null) { + vertx.close(); + } + } + + // ===================== 认证测试 ===================== + + /** + * 认证测试:发送自定义功能码 FC65 认证帧,验证认证成功响应 + */ + @Test + public void testAuth() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 构造并发送认证帧 + IotModbusFrame response = authenticate(socket); + + // 2. 验证响应 + log.info("[testAuth][认证响应帧: slaveId={}, FC={}, customData={}]", + response.getSlaveId(), response.getFunctionCode(), response.getCustomData()); + JSONObject json = JSONUtil.parseObj(response.getCustomData()); + assertEquals(0, json.getInt("code")); + log.info("[testAuth][认证结果: code={}, message={}]", json.getInt("code"), json.getStr("message")); + } finally { + socket.close(); + } + } + + // ===================== 轮询响应测试 ===================== + + /** + * 轮询响应测试:认证后持续监听网关下发的读请求,每次收到都自动构造读响应帧发回 + */ + @Test + public void testPollingResponse() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先认证 + IotModbusFrame authResponse = authenticate(socket); + log.info("[testPollingResponse][认证响应: {}]", authResponse.getCustomData()); + JSONObject authJson = JSONUtil.parseObj(authResponse.getCustomData()); + assertEquals(0, authJson.getInt("code")); + + // 2. 设置持续监听:每收到一个读请求,自动回复 + log.info("[testPollingResponse][开始持续监听网关下发的读请求...]"); + RecordParser parser = FRAME_DECODER.createRecordParser((frame, frameFormat) -> { + log.info("[testPollingResponse][收到请求: slaveId={}, FC={}, transactionId={}]", + frame.getSlaveId(), frame.getFunctionCode(), frame.getTransactionId()); + // 解析读请求中的起始地址和数量 + byte[] pdu = frame.getPdu(); + int startAddress = ((pdu[0] & 0xFF) << 8) | (pdu[1] & 0xFF); + int quantity = ((pdu[2] & 0xFF) << 8) | (pdu[3] & 0xFF); + log.info("[testPollingResponse][读请求参数: startAddress={}, quantity={}]", startAddress, quantity); + + // 构造读响应帧(模拟寄存器数据) + int[] registerValues = new int[quantity]; + for (int i = 0; i < quantity; i++) { + registerValues[i] = 100 + i * 100; // 模拟值: 100, 200, 300, ... + } + byte[] responseData = buildReadResponse(frame.getTransactionId(), + frame.getSlaveId(), frame.getFunctionCode(), registerValues); + socket.write(Buffer.buffer(responseData)); + log.info("[testPollingResponse][已发送读响应, registerValues={}]", registerValues); + }); + socket.handler(parser); + + // 3. 持续等待(200 秒),期间会自动回复所有收到的读请求 + Thread.sleep(200000); + } finally { + socket.close(); + } + } + + // ===================== 属性设置测试 ===================== + + /** + * 属性设置测试:认证后等待接收网关下发的 FC06/FC16 写请求 + *

+ * 注意:需手动在平台触发 property.set + */ + @Test + public void testPropertySetWrite() throws Exception { + NetSocket socket = connect().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + try { + // 1. 先认证 + IotModbusFrame authResponse = authenticate(socket); + log.info("[testPropertySetWrite][认证响应: {}]", authResponse.getCustomData()); + + // 2. 等待网关下发写请求(需手动在平台触发 property.set) + log.info("[testPropertySetWrite][等待网关下发写请求(请在平台触发 property.set)...]"); + IotModbusFrame writeRequest = waitForRequest(socket); + log.info("[testPropertySetWrite][收到写请求: slaveId={}, FC={}, transactionId={}, pdu={}]", + writeRequest.getSlaveId(), writeRequest.getFunctionCode(), + writeRequest.getTransactionId(), HexUtil.encodeHexStr(writeRequest.getPdu())); + } finally { + socket.close(); + } + } + + // ===================== 辅助方法 ===================== + + /** + * 建立 TCP 连接 + */ + private CompletableFuture connect() { + CompletableFuture future = new CompletableFuture<>(); + netClient.connect(SERVER_PORT, SERVER_HOST) + .onSuccess(future::complete) + .onFailure(future::completeExceptionally); + return future; + } + + /** + * 执行认证并返回响应帧 + */ + private IotModbusFrame authenticate(NetSocket socket) throws Exception { + IotDeviceAuthReqDTO authInfo = IotDeviceAuthUtils.getAuthInfo(PRODUCT_KEY, DEVICE_NAME, DEVICE_SECRET); + authInfo.setClientId(""); // 特殊:考虑到 modbus 消息长度限制,默认 clientId 不发送 + byte[] authFrame = buildAuthFrame(authInfo.getClientId(), authInfo.getUsername(), authInfo.getPassword()); + return sendAndReceive(socket, authFrame); + } + + /** + * 发送帧并等待响应(使用 IotModbusFrameDecoder 自动检测帧格式并解码) + */ + private IotModbusFrame sendAndReceive(NetSocket socket, byte[] frameData) throws Exception { + CompletableFuture responseFuture = new CompletableFuture<>(); + // 使用 FrameDecoder 创建拆包器(自动检测帧格式 + 解码,直接回调 IotModbusFrame) + RecordParser parser = FRAME_DECODER.createRecordParser( + (frame, frameFormat) -> { + try { + log.info("[sendAndReceive][检测到帧格式: {}]", frameFormat); + responseFuture.complete(frame); + } catch (Exception e) { + responseFuture.completeExceptionally(e); + } + }); + socket.handler(parser); + + // 发送请求 + log.info("[sendAndReceive][发送帧, 长度={}]", frameData.length); + socket.write(Buffer.buffer(frameData)); + + // 等待响应 + return responseFuture.get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + } + + /** + * 等待接收网关下发的请求帧(不发送,只等待接收) + */ + private IotModbusFrame waitForRequest(NetSocket socket) throws Exception { + CompletableFuture requestFuture = new CompletableFuture<>(); + // 使用 FrameDecoder 创建拆包器(直接回调 IotModbusFrame) + RecordParser parser = FRAME_DECODER.createRecordParser( + (frame, frameFormat) -> { + try { + log.info("[waitForRequest][检测到帧格式: {}]", frameFormat); + requestFuture.complete(frame); + } catch (Exception e) { + requestFuture.completeExceptionally(e); + } + }); + socket.handler(parser); + + // 等待(超时 30 秒,因为轮询间隔可能比较长) + return requestFuture.get(30000, TimeUnit.MILLISECONDS); + } + + /** + * 构造认证帧(MODBUS_TCP 格式) + *

+ * JSON: {"method":"auth","params":{"clientId":"...","username":"...","password":"..."}} + */ + private byte[] buildAuthFrame(String clientId, String username, String password) { + JSONObject params = new JSONObject(); + params.set("clientId", clientId); + params.set("username", username); + params.set("password", password); + JSONObject json = new JSONObject(); + json.set("method", "auth"); + json.set("params", params); + return FRAME_ENCODER.encodeCustomFrame(SLAVE_ID, json.toString(), + IotModbusFrameFormatEnum.MODBUS_TCP, 1); + } + + /** + * 构造 FC03/FC01-04 读响应帧(MODBUS_TCP 格式) + *

+ * 格式:[MBAP(6)] [UnitId(1)] [FC(1)] [ByteCount(1)] [RegisterData(N*2)] + */ + private byte[] buildReadResponse(int transactionId, int slaveId, int functionCode, int[] registerValues) { + int byteCount = registerValues.length * 2; + // PDU: FC(1) + ByteCount(1) + Data(N*2) + int pduLength = 1 + 1 + byteCount; + // 完整帧:MBAP(6) + UnitId(1) + PDU + int totalLength = 6 + 1 + pduLength; + ByteBuffer buf = ByteBuffer.allocate(totalLength).order(ByteOrder.BIG_ENDIAN); + // MBAP Header + buf.putShort((short) transactionId); // Transaction ID + buf.putShort((short) 0); // Protocol ID + buf.putShort((short) (1 + pduLength)); // Length (UnitId + PDU) + // UnitId + buf.put((byte) slaveId); + // PDU + buf.put((byte) functionCode); + buf.put((byte) byteCount); + for (int value : registerValues) { + buf.putShort((short) value); + } + return buf.array(); + } + +} diff --git a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotDirectDeviceMqttProtocolIntegrationTest.java b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotDirectDeviceMqttProtocolIntegrationTest.java index 45cb7ca450..3333af6a71 100644 --- a/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotDirectDeviceMqttProtocolIntegrationTest.java +++ b/yudao-module-iot/yudao-module-iot-gateway/src/test/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqtt/IotDirectDeviceMqttProtocolIntegrationTest.java @@ -239,12 +239,28 @@ public class IotDirectDeviceMqttProtocolIntegrationTest { log.info("[testSubscribe][连接认证成功]"); try { - // 2. 设置消息处理器 - client.publishHandler(message -> log.info("[testSubscribe][收到消息: topic={}, payload={}]", - message.topicName(), message.payload().toString())); + // 2. 设置消息处理器:收到属性设置时,回复 _reply 消息 + client.publishHandler(message -> { + log.info("[testSubscribe][收到消息: topic={}, payload={}]", + message.topicName(), message.payload().toString()); + // 收到属性设置消息时,回复 _reply + if (message.topicName().endsWith("/thing/property/set")) { + try { + IotDeviceMessage received = SERIALIZER.deserialize(message.payload().getBytes()); + IotDeviceMessage reply = IotDeviceMessage.replyOf( + received.getRequestId(), "thing.property.set_reply", null, 0, null); + String replyTopic = String.format("/sys/%s/%s/thing/property/set_reply", PRODUCT_KEY, DEVICE_NAME); + byte[] replyPayload = SERIALIZER.serialize(reply); + client.publish(replyTopic, Buffer.buffer(replyPayload), MqttQoS.AT_LEAST_ONCE, false, false); + log.info("[testSubscribe][已回复属性设置: topic={}]", replyTopic); + } catch (Exception e) { + log.error("[testSubscribe][回复属性设置异常]", e); + } + } + }); - // 3. 订阅下行主题 - String topic = String.format("/sys/%s/%s/thing/service/#", PRODUCT_KEY, DEVICE_NAME); + // 3. 订阅下行主题(属性设置 + 服务调用) + String topic = String.format("/sys/%s/%s/#", PRODUCT_KEY, DEVICE_NAME); log.info("[testSubscribe][订阅主题: {}]", topic); subscribe(client, topic); log.info("[testSubscribe][订阅成功,等待下行消息... (30秒后自动断开)]"); diff --git a/yudao-module-mp/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountServiceImpl.java b/yudao-module-mp/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountServiceImpl.java index 14e91b6612..6f59f8d321 100644 --- a/yudao-module-mp/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountServiceImpl.java +++ b/yudao-module-mp/src/main/java/cn/iocoder/yudao/module/mp/service/account/MpAccountServiceImpl.java @@ -60,7 +60,7 @@ public class MpAccountServiceImpl implements MpAccountService { private MpAccountMapper mpAccountMapper; @Resource - @Lazy // 延迟加载,解决循环依赖的问题 + @Lazy // 延迟加载,解决循环依赖 private MpServiceFactory mpServiceFactory; @Override