Commit 853afd218479ee0630cb8c52a399ce4e133ee258
Committed by
Gitee
!1501 IOT:同步最新代码
Merge pull request !1501 from 芋道源码/feature/iot
Showing
33 changed files
with
3059 additions
and
47 deletions
yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java
| ... | ... | @@ -116,6 +116,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { |
| 116 | 116 | .taskAssignee(String.valueOf(userId)) // 分配给自己 |
| 117 | 117 | .active() |
| 118 | 118 | .includeProcessVariables() |
| 119 | + .taskTenantId(FlowableUtils.getTenantId()) | |
| 119 | 120 | .orderByTaskCreateTime().desc(); // 创建时间倒序 |
| 120 | 121 | if (StrUtil.isNotBlank(pageVO.getName())) { |
| 121 | 122 | taskQuery.taskNameLike("%" + pageVO.getName() + "%"); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/device/vo/device/IotDevicePageReqVO.java
| ... | ... | @@ -31,4 +31,7 @@ public class IotDevicePageReqVO extends PageParam { |
| 31 | 31 | @Schema(description = "设备分组编号", example = "1024") |
| 32 | 32 | private Long groupId; |
| 33 | 33 | |
| 34 | + @Schema(description = "网关设备 ID", example = "16380") | |
| 35 | + private Long gatewayId; | |
| 36 | + | |
| 34 | 37 | } |
| 35 | 38 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkPageReqVO.java
| ... | ... | @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink; |
| 3 | 3 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 4 | 4 | import cn.iocoder.yudao.framework.common.pojo.PageParam; |
| 5 | 5 | import cn.iocoder.yudao.framework.common.validation.InEnum; |
| 6 | +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; | |
| 6 | 7 | import io.swagger.v3.oas.annotations.media.Schema; |
| 7 | 8 | import lombok.Data; |
| 8 | 9 | import org.springframework.format.annotation.DateTimeFormat; |
| ... | ... | @@ -22,6 +23,10 @@ public class IotDataSinkPageReqVO extends PageParam { |
| 22 | 23 | @InEnum(CommonStatusEnum.class) |
| 23 | 24 | private Integer status; |
| 24 | 25 | |
| 26 | + @Schema(description = "数据目的类型", example = "1") | |
| 27 | + @InEnum(IotDataSinkTypeEnum.class) | |
| 28 | + private Integer type; | |
| 29 | + | |
| 25 | 30 | @Schema(description = "创建时间") |
| 26 | 31 | @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) |
| 27 | 32 | private LocalDateTime[] createTime; | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/IotSceneRuleDO.java
| ... | ... | @@ -21,6 +21,7 @@ import lombok.Builder; |
| 21 | 21 | import lombok.Data; |
| 22 | 22 | import lombok.NoArgsConstructor; |
| 23 | 23 | |
| 24 | +import java.time.LocalDateTime; | |
| 24 | 25 | import java.util.List; |
| 25 | 26 | |
| 26 | 27 | /** |
| ... | ... | @@ -57,6 +58,11 @@ public class IotSceneRuleDO extends TenantBaseDO { |
| 57 | 58 | private Integer status; |
| 58 | 59 | |
| 59 | 60 | /** |
| 61 | + * 最后触发时间 | |
| 62 | + */ | |
| 63 | + private LocalDateTime lastTriggerTime; | |
| 64 | + | |
| 65 | + /** | |
| 60 | 66 | * 场景定义配置 |
| 61 | 67 | */ |
| 62 | 68 | @TableField(typeHandler = JacksonTypeHandler.class) | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkTcpConfig.java
| ... | ... | @@ -11,6 +11,35 @@ import lombok.Data; |
| 11 | 11 | public class IotDataSinkTcpConfig extends IotAbstractDataSinkConfig { |
| 12 | 12 | |
| 13 | 13 | /** |
| 14 | + * 默认连接超时时间(毫秒) | |
| 15 | + */ | |
| 16 | + public static final int DEFAULT_CONNECT_TIMEOUT_MS = 5000; | |
| 17 | + /** | |
| 18 | + * 默认读取超时时间(毫秒) | |
| 19 | + */ | |
| 20 | + public static final int DEFAULT_READ_TIMEOUT_MS = 10000; | |
| 21 | + /** | |
| 22 | + * 默认是否启用 SSL | |
| 23 | + */ | |
| 24 | + public static final boolean DEFAULT_SSL = false; | |
| 25 | + /** | |
| 26 | + * 默认数据格式 | |
| 27 | + */ | |
| 28 | + public static final String DEFAULT_DATA_FORMAT = "JSON"; | |
| 29 | + /** | |
| 30 | + * 默认心跳间隔时间(毫秒) | |
| 31 | + */ | |
| 32 | + public static final long DEFAULT_HEARTBEAT_INTERVAL_MS = 30000L; | |
| 33 | + /** | |
| 34 | + * 默认重连间隔时间(毫秒) | |
| 35 | + */ | |
| 36 | + public static final long DEFAULT_RECONNECT_INTERVAL_MS = 5000L; | |
| 37 | + /** | |
| 38 | + * 默认最大重连次数 | |
| 39 | + */ | |
| 40 | + public static final int DEFAULT_MAX_RECONNECT_ATTEMPTS = 3; | |
| 41 | + | |
| 42 | + /** | |
| 14 | 43 | * TCP 服务器地址 |
| 15 | 44 | */ |
| 16 | 45 | private String host; |
| ... | ... | @@ -23,17 +52,17 @@ public class IotDataSinkTcpConfig extends IotAbstractDataSinkConfig { |
| 23 | 52 | /** |
| 24 | 53 | * 连接超时时间(毫秒) |
| 25 | 54 | */ |
| 26 | - private Integer connectTimeoutMs = 5000; | |
| 55 | + private Integer connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS; | |
| 27 | 56 | |
| 28 | 57 | /** |
| 29 | 58 | * 读取超时时间(毫秒) |
| 30 | 59 | */ |
| 31 | - private Integer readTimeoutMs = 10000; | |
| 60 | + private Integer readTimeoutMs = DEFAULT_READ_TIMEOUT_MS; | |
| 32 | 61 | |
| 33 | 62 | /** |
| 34 | 63 | * 是否启用 SSL |
| 35 | 64 | */ |
| 36 | - private Boolean ssl = false; | |
| 65 | + private Boolean ssl = DEFAULT_SSL; | |
| 37 | 66 | |
| 38 | 67 | /** |
| 39 | 68 | * SSL 证书路径(当 ssl=true 时需要) |
| ... | ... | @@ -43,21 +72,21 @@ public class IotDataSinkTcpConfig extends IotAbstractDataSinkConfig { |
| 43 | 72 | /** |
| 44 | 73 | * 数据格式:JSON 或 BINARY |
| 45 | 74 | */ |
| 46 | - private String dataFormat = "JSON"; | |
| 75 | + private String dataFormat = DEFAULT_DATA_FORMAT; | |
| 47 | 76 | |
| 48 | 77 | /** |
| 49 | 78 | * 心跳间隔时间(毫秒),0 表示不启用心跳 |
| 50 | 79 | */ |
| 51 | - private Long heartbeatIntervalMs = 30000L; | |
| 80 | + private Long heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS; | |
| 52 | 81 | |
| 53 | 82 | /** |
| 54 | 83 | * 重连间隔时间(毫秒) |
| 55 | 84 | */ |
| 56 | - private Long reconnectIntervalMs = 5000L; | |
| 85 | + private Long reconnectIntervalMs = DEFAULT_RECONNECT_INTERVAL_MS; | |
| 57 | 86 | |
| 58 | 87 | /** |
| 59 | 88 | * 最大重连次数 |
| 60 | 89 | */ |
| 61 | - private Integer maxReconnectAttempts = 3; | |
| 90 | + private Integer maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS; | |
| 62 | 91 | |
| 63 | 92 | } |
| 64 | 93 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkWebSocketConfig.java
| ... | ... | @@ -14,6 +14,51 @@ import lombok.Data; |
| 14 | 14 | public class IotDataSinkWebSocketConfig extends IotAbstractDataSinkConfig { |
| 15 | 15 | |
| 16 | 16 | /** |
| 17 | + * 默认连接超时时间(毫秒) | |
| 18 | + */ | |
| 19 | + public static final int DEFAULT_CONNECT_TIMEOUT_MS = 5000; | |
| 20 | + /** | |
| 21 | + * 默认发送超时时间(毫秒) | |
| 22 | + */ | |
| 23 | + public static final int DEFAULT_SEND_TIMEOUT_MS = 10000; | |
| 24 | + /** | |
| 25 | + * 默认心跳间隔时间(毫秒) | |
| 26 | + */ | |
| 27 | + public static final long DEFAULT_HEARTBEAT_INTERVAL_MS = 30000L; | |
| 28 | + /** | |
| 29 | + * 默认心跳消息内容 | |
| 30 | + */ | |
| 31 | + public static final String DEFAULT_HEARTBEAT_MESSAGE = "{\"type\":\"heartbeat\"}"; | |
| 32 | + /** | |
| 33 | + * 默认是否启用 SSL 证书验证 | |
| 34 | + */ | |
| 35 | + public static final boolean DEFAULT_VERIFY_SSL_CERT = true; | |
| 36 | + /** | |
| 37 | + * 默认数据格式 | |
| 38 | + */ | |
| 39 | + public static final String DEFAULT_DATA_FORMAT = "JSON"; | |
| 40 | + /** | |
| 41 | + * 默认重连间隔时间(毫秒) | |
| 42 | + */ | |
| 43 | + public static final long DEFAULT_RECONNECT_INTERVAL_MS = 5000L; | |
| 44 | + /** | |
| 45 | + * 默认最大重连次数 | |
| 46 | + */ | |
| 47 | + public static final int DEFAULT_MAX_RECONNECT_ATTEMPTS = 3; | |
| 48 | + /** | |
| 49 | + * 默认是否启用压缩 | |
| 50 | + */ | |
| 51 | + public static final boolean DEFAULT_ENABLE_COMPRESSION = false; | |
| 52 | + /** | |
| 53 | + * 默认消息发送重试次数 | |
| 54 | + */ | |
| 55 | + public static final int DEFAULT_SEND_RETRY_COUNT = 1; | |
| 56 | + /** | |
| 57 | + * 默认消息发送重试间隔(毫秒) | |
| 58 | + */ | |
| 59 | + public static final long DEFAULT_SEND_RETRY_INTERVAL_MS = 1000L; | |
| 60 | + | |
| 61 | + /** | |
| 17 | 62 | * WebSocket 服务器地址 |
| 18 | 63 | * 例如:ws://localhost:8080/ws 或 wss://example.com/ws |
| 19 | 64 | */ |
| ... | ... | @@ -22,22 +67,22 @@ public class IotDataSinkWebSocketConfig extends IotAbstractDataSinkConfig { |
| 22 | 67 | /** |
| 23 | 68 | * 连接超时时间(毫秒) |
| 24 | 69 | */ |
| 25 | - private Integer connectTimeoutMs = 5000; | |
| 70 | + private Integer connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS; | |
| 26 | 71 | |
| 27 | 72 | /** |
| 28 | 73 | * 发送超时时间(毫秒) |
| 29 | 74 | */ |
| 30 | - private Integer sendTimeoutMs = 10000; | |
| 75 | + private Integer sendTimeoutMs = DEFAULT_SEND_TIMEOUT_MS; | |
| 31 | 76 | |
| 32 | 77 | /** |
| 33 | 78 | * 心跳间隔时间(毫秒),0 表示不启用心跳 |
| 34 | 79 | */ |
| 35 | - private Long heartbeatIntervalMs = 30000L; | |
| 80 | + private Long heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS; | |
| 36 | 81 | |
| 37 | 82 | /** |
| 38 | 83 | * 心跳消息内容(JSON 格式) |
| 39 | 84 | */ |
| 40 | - private String heartbeatMessage = "{\"type\":\"heartbeat\"}"; | |
| 85 | + private String heartbeatMessage = DEFAULT_HEARTBEAT_MESSAGE; | |
| 41 | 86 | |
| 42 | 87 | /** |
| 43 | 88 | * 子协议列表(逗号分隔) |
| ... | ... | @@ -52,36 +97,36 @@ public class IotDataSinkWebSocketConfig extends IotAbstractDataSinkConfig { |
| 52 | 97 | /** |
| 53 | 98 | * 是否启用 SSL 证书验证(仅对 wss:// 生效) |
| 54 | 99 | */ |
| 55 | - private Boolean verifySslCert = true; | |
| 100 | + private Boolean verifySslCert = DEFAULT_VERIFY_SSL_CERT; | |
| 56 | 101 | |
| 57 | 102 | /** |
| 58 | 103 | * 数据格式:JSON 或 TEXT |
| 59 | 104 | */ |
| 60 | - private String dataFormat = "JSON"; | |
| 105 | + private String dataFormat = DEFAULT_DATA_FORMAT; | |
| 61 | 106 | |
| 62 | 107 | /** |
| 63 | 108 | * 重连间隔时间(毫秒) |
| 64 | 109 | */ |
| 65 | - private Long reconnectIntervalMs = 5000L; | |
| 110 | + private Long reconnectIntervalMs = DEFAULT_RECONNECT_INTERVAL_MS; | |
| 66 | 111 | |
| 67 | 112 | /** |
| 68 | 113 | * 最大重连次数 |
| 69 | 114 | */ |
| 70 | - private Integer maxReconnectAttempts = 3; | |
| 115 | + private Integer maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS; | |
| 71 | 116 | |
| 72 | 117 | /** |
| 73 | 118 | * 是否启用压缩 |
| 74 | 119 | */ |
| 75 | - private Boolean enableCompression = false; | |
| 120 | + private Boolean enableCompression = DEFAULT_ENABLE_COMPRESSION; | |
| 76 | 121 | |
| 77 | 122 | /** |
| 78 | 123 | * 消息发送重试次数 |
| 79 | 124 | */ |
| 80 | - private Integer sendRetryCount = 1; | |
| 125 | + private Integer sendRetryCount = DEFAULT_SEND_RETRY_COUNT; | |
| 81 | 126 | |
| 82 | 127 | /** |
| 83 | 128 | * 消息发送重试间隔(毫秒) |
| 84 | 129 | */ |
| 85 | - private Long sendRetryIntervalMs = 1000L; | |
| 130 | + private Long sendRetryIntervalMs = DEFAULT_SEND_RETRY_INTERVAL_MS; | |
| 86 | 131 | |
| 87 | 132 | } |
| 88 | 133 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/device/IotDeviceMapper.java
| ... | ... | @@ -31,6 +31,7 @@ public interface IotDeviceMapper extends BaseMapperX<IotDeviceDO> { |
| 31 | 31 | .eqIfPresent(IotDeviceDO::getDeviceType, reqVO.getDeviceType()) |
| 32 | 32 | .likeIfPresent(IotDeviceDO::getNickname, reqVO.getNickname()) |
| 33 | 33 | .eqIfPresent(IotDeviceDO::getState, reqVO.getStatus()) |
| 34 | + .eqIfPresent(IotDeviceDO::getGatewayId, reqVO.getGatewayId()) | |
| 34 | 35 | .apply(ObjectUtil.isNotNull(reqVO.getGroupId()), "FIND_IN_SET(" + reqVO.getGroupId() + ",group_ids) > 0") |
| 35 | 36 | .orderByDesc(IotDeviceDO::getId)); |
| 36 | 37 | } | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataRuleMapper.java
| ... | ... | @@ -35,4 +35,8 @@ public interface IotDataRuleMapper extends BaseMapperX<IotDataRuleDO> { |
| 35 | 35 | return selectList(IotDataRuleDO::getStatus, status); |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | + default IotDataRuleDO selectByName(String name) { | |
| 39 | + return selectOne(IotDataRuleDO::getName, name); | |
| 40 | + } | |
| 41 | + | |
| 38 | 42 | } |
| 39 | 43 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/mysql/rule/IotDataSinkMapper.java
| ... | ... | @@ -21,6 +21,7 @@ public interface IotDataSinkMapper extends BaseMapperX<IotDataSinkDO> { |
| 21 | 21 | return selectPage(reqVO, new LambdaQueryWrapperX<IotDataSinkDO>() |
| 22 | 22 | .likeIfPresent(IotDataSinkDO::getName, reqVO.getName()) |
| 23 | 23 | .eqIfPresent(IotDataSinkDO::getStatus, reqVO.getStatus()) |
| 24 | + .eqIfPresent(IotDataSinkDO::getType, reqVO.getType()) | |
| 24 | 25 | .betweenIfPresent(IotDataSinkDO::getCreateTime, reqVO.getCreateTime()) |
| 25 | 26 | .orderByDesc(IotDataSinkDO::getId)); |
| 26 | 27 | } |
| ... | ... | @@ -29,4 +30,8 @@ public interface IotDataSinkMapper extends BaseMapperX<IotDataSinkDO> { |
| 29 | 30 | return selectList(IotDataSinkDO::getStatus, status); |
| 30 | 31 | } |
| 31 | 32 | |
| 33 | + default IotDataSinkDO selectByName(String name) { | |
| 34 | + return selectOne(IotDataSinkDO::getName, name); | |
| 35 | + } | |
| 36 | + | |
| 32 | 37 | } |
| 33 | 38 | \ No newline at end of file | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/ErrorCodeConstants.java
| ... | ... | @@ -65,10 +65,12 @@ public interface ErrorCodeConstants { |
| 65 | 65 | |
| 66 | 66 | // ========== IoT 数据流转规则 1-050-010-000 ========== |
| 67 | 67 | ErrorCode DATA_RULE_NOT_EXISTS = new ErrorCode(1_050_010_000, "数据流转规则不存在"); |
| 68 | + ErrorCode DATA_RULE_NAME_EXISTS = new ErrorCode(1_050_010_001, "数据流转规则名称已存在"); | |
| 68 | 69 | |
| 69 | 70 | // ========== IoT 数据流转目的 1-050-011-000 ========== |
| 70 | 71 | ErrorCode DATA_SINK_NOT_EXISTS = new ErrorCode(1_050_011_000, "数据桥梁不存在"); |
| 71 | 72 | ErrorCode DATA_SINK_DELETE_FAIL_USED_BY_RULE = new ErrorCode(1_050_011_001, "数据流转目的正在被数据流转规则使用,无法删除"); |
| 73 | + ErrorCode DATA_SINK_NAME_EXISTS = new ErrorCode(1_050_011_002, "数据流转目的名称已存在"); | |
| 72 | 74 | |
| 73 | 75 | // ========== IoT 场景联动 1-050-012-000 ========== |
| 74 | 76 | ErrorCode RULE_SCENE_NOT_EXISTS = new ErrorCode(1_050_012_000, "场景联动不存在"); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/enums/rule/IotDataSinkTypeEnum.java
| ... | ... | @@ -16,8 +16,8 @@ import java.util.Arrays; |
| 16 | 16 | public enum IotDataSinkTypeEnum implements ArrayValuable<Integer> { |
| 17 | 17 | |
| 18 | 18 | HTTP(1, "HTTP"), |
| 19 | - TCP(2, "TCP"), // TODO @puhui999:待实现; | |
| 20 | - WEBSOCKET(3, "WebSocket"), // TODO @puhui999:待实现; | |
| 19 | + TCP(2, "TCP"), | |
| 20 | + WEBSOCKET(3, "WebSocket"), | |
| 21 | 21 | |
| 22 | 22 | MQTT(10, "MQTT"), // TODO 待实现; |
| 23 | 23 | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/IotDeviceServiceImpl.java
| ... | ... | @@ -382,7 +382,7 @@ public class IotDeviceServiceImpl implements IotDeviceService { |
| 382 | 382 | return; |
| 383 | 383 | } |
| 384 | 384 | // 2.2.2 如果存在,判断是否允许更新 |
| 385 | - if (updateSupport) { | |
| 385 | + if (!updateSupport) { | |
| 386 | 386 | throw exception(DEVICE_KEY_EXISTS); |
| 387 | 387 | } |
| 388 | 388 | updateDevice(new IotDeviceSaveReqVO().setId(existDevice.getId()) | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/device/property/IotDevicePropertyServiceImpl.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.service.device.property; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | +import cn.hutool.core.convert.Convert; | |
| 4 | 5 | import cn.hutool.core.date.LocalDateTimeUtil; |
| 5 | 6 | import cn.hutool.core.map.MapUtil; |
| 6 | 7 | import cn.hutool.core.util.StrUtil; |
| ... | ... | @@ -145,6 +146,12 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService { |
| 145 | 146 | IotDataSpecsDataTypeEnum.STRUCT.getDataType(), IotDataSpecsDataTypeEnum.ARRAY.getDataType())) { |
| 146 | 147 | // 特殊:STRUCT 和 ARRAY 类型,在 TDengine 里,有没对应数据类型,只能通过 JSON 来存储 |
| 147 | 148 | properties.put((String) key, JsonUtils.toJsonString(value)); |
| 149 | + } else if (IotDataSpecsDataTypeEnum.DOUBLE.getDataType().equals(thingModel.getProperty().getDataType())) { | |
| 150 | + properties.put((String) key, Convert.toDouble(value)); | |
| 151 | + } else if (IotDataSpecsDataTypeEnum.FLOAT.getDataType().equals(thingModel.getProperty().getDataType())) { | |
| 152 | + properties.put((String) key, Convert.toFloat(value)); | |
| 153 | + } else if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(thingModel.getProperty().getDataType())) { | |
| 154 | + properties.put((String) key, Convert.toByte(value)); | |
| 148 | 155 | } else { |
| 149 | 156 | properties.put((String) key, value); |
| 150 | 157 | } | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataRuleServiceImpl.java
| ... | ... | @@ -32,6 +32,7 @@ import java.util.*; |
| 32 | 32 | |
| 33 | 33 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 34 | 34 | import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; |
| 35 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_RULE_NAME_EXISTS; | |
| 35 | 36 | import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_RULE_NOT_EXISTS; |
| 36 | 37 | |
| 37 | 38 | /** |
| ... | ... | @@ -62,6 +63,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { |
| 62 | 63 | @Override |
| 63 | 64 | @CacheEvict(value = RedisKeyConstants.DATA_RULE_LIST, allEntries = true) |
| 64 | 65 | public Long createDataRule(IotDataRuleSaveReqVO createReqVO) { |
| 66 | + // 校验名称唯一 | |
| 67 | + validateDataRuleNameUnique(null, createReqVO.getName()); | |
| 65 | 68 | // 校验数据源配置和数据目的 |
| 66 | 69 | validateDataRuleConfig(createReqVO); |
| 67 | 70 | // 新增 |
| ... | ... | @@ -75,6 +78,8 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { |
| 75 | 78 | public void updateDataRule(IotDataRuleSaveReqVO updateReqVO) { |
| 76 | 79 | // 校验存在 |
| 77 | 80 | validateDataRuleExists(updateReqVO.getId()); |
| 81 | + // 校验名称唯一 | |
| 82 | + validateDataRuleNameUnique(updateReqVO.getId(), updateReqVO.getName()); | |
| 78 | 83 | // 校验数据源配置和数据目的 |
| 79 | 84 | validateDataRuleConfig(updateReqVO); |
| 80 | 85 | |
| ... | ... | @@ -99,6 +104,29 @@ public class IotDataRuleServiceImpl implements IotDataRuleService { |
| 99 | 104 | } |
| 100 | 105 | |
| 101 | 106 | /** |
| 107 | + * 校验数据流转规则名称唯一性 | |
| 108 | + * | |
| 109 | + * @param id 数据流转规则编号(用于更新时排除自身) | |
| 110 | + * @param name 数据流转规则名称 | |
| 111 | + */ | |
| 112 | + private void validateDataRuleNameUnique(Long id, String name) { | |
| 113 | + if (StrUtil.isBlank(name)) { | |
| 114 | + return; | |
| 115 | + } | |
| 116 | + IotDataRuleDO dataRule = dataRuleMapper.selectByName(name); | |
| 117 | + if (dataRule == null) { | |
| 118 | + return; | |
| 119 | + } | |
| 120 | + // 如果 id 为空,说明不用比较是否为相同 id 的规则 | |
| 121 | + if (id == null) { | |
| 122 | + throw exception(DATA_RULE_NAME_EXISTS); | |
| 123 | + } | |
| 124 | + if (!dataRule.getId().equals(id)) { | |
| 125 | + throw exception(DATA_RULE_NAME_EXISTS); | |
| 126 | + } | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** | |
| 102 | 130 | * 校验数据流转规则配置 |
| 103 | 131 | * |
| 104 | 132 | * @param reqVO 数据流转规则保存请求VO | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/IotDataSinkServiceImpl.java
| 1 | 1 | package cn.iocoder.yudao.module.iot.service.rule.data; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | +import cn.hutool.core.util.StrUtil; | |
| 4 | 5 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 5 | 6 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 6 | 7 | import cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink.IotDataSinkPageReqVO; |
| ... | ... | @@ -19,6 +20,7 @@ import java.util.List; |
| 19 | 20 | |
| 20 | 21 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 21 | 22 | import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_DELETE_FAIL_USED_BY_RULE; |
| 23 | +import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_NAME_EXISTS; | |
| 22 | 24 | import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.DATA_SINK_NOT_EXISTS; |
| 23 | 25 | |
| 24 | 26 | /** |
| ... | ... | @@ -39,6 +41,9 @@ public class IotDataSinkServiceImpl implements IotDataSinkService { |
| 39 | 41 | |
| 40 | 42 | @Override |
| 41 | 43 | public Long createDataSink(IotDataSinkSaveReqVO createReqVO) { |
| 44 | + // 校验名称唯一 | |
| 45 | + validateDataSinkNameUnique(null, createReqVO.getName()); | |
| 46 | + // 新增 | |
| 42 | 47 | IotDataSinkDO dataBridge = BeanUtils.toBean(createReqVO, IotDataSinkDO.class); |
| 43 | 48 | dataSinkMapper.insert(dataBridge); |
| 44 | 49 | return dataBridge.getId(); |
| ... | ... | @@ -48,6 +53,8 @@ public class IotDataSinkServiceImpl implements IotDataSinkService { |
| 48 | 53 | public void updateDataSink(IotDataSinkSaveReqVO updateReqVO) { |
| 49 | 54 | // 校验存在 |
| 50 | 55 | validateDataBridgeExists(updateReqVO.getId()); |
| 56 | + // 校验名称唯一 | |
| 57 | + validateDataSinkNameUnique(updateReqVO.getId(), updateReqVO.getName()); | |
| 51 | 58 | // 更新 |
| 52 | 59 | IotDataSinkDO updateObj = BeanUtils.toBean(updateReqVO, IotDataSinkDO.class); |
| 53 | 60 | dataSinkMapper.updateById(updateObj); |
| ... | ... | @@ -71,6 +78,29 @@ public class IotDataSinkServiceImpl implements IotDataSinkService { |
| 71 | 78 | } |
| 72 | 79 | } |
| 73 | 80 | |
| 81 | + /** | |
| 82 | + * 校验数据流转目的名称唯一性 | |
| 83 | + * | |
| 84 | + * @param id 数据流转目的编号(用于更新时排除自身) | |
| 85 | + * @param name 数据流转目的名称 | |
| 86 | + */ | |
| 87 | + private void validateDataSinkNameUnique(Long id, String name) { | |
| 88 | + if (StrUtil.isBlank(name)) { | |
| 89 | + return; | |
| 90 | + } | |
| 91 | + IotDataSinkDO dataSink = dataSinkMapper.selectByName(name); | |
| 92 | + if (dataSink == null) { | |
| 93 | + return; | |
| 94 | + } | |
| 95 | + // 如果 id 为空,说明不用比较是否为相同 id 的目的 | |
| 96 | + if (id == null) { | |
| 97 | + throw exception(DATA_SINK_NAME_EXISTS); | |
| 98 | + } | |
| 99 | + if (!dataSink.getId().equals(id)) { | |
| 100 | + throw exception(DATA_SINK_NAME_EXISTS); | |
| 101 | + } | |
| 102 | + } | |
| 103 | + | |
| 74 | 104 | @Override |
| 75 | 105 | public IotDataSinkDO getDataSink(Long id) { |
| 76 | 106 | return dataSinkMapper.selectById(id); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotTcpDataRuleAction.java
| ... | ... | @@ -7,8 +7,6 @@ import cn.iocoder.yudao.module.iot.service.rule.data.action.tcp.IotTcpClient; |
| 7 | 7 | import lombok.extern.slf4j.Slf4j; |
| 8 | 8 | import org.springframework.stereotype.Component; |
| 9 | 9 | |
| 10 | -import java.time.Duration; | |
| 11 | - | |
| 12 | 10 | /** |
| 13 | 11 | * TCP 的 {@link IotDataRuleAction} 实现类 |
| 14 | 12 | * <p> |
| ... | ... | @@ -23,9 +21,6 @@ import java.time.Duration; |
| 23 | 21 | public class IotTcpDataRuleAction extends |
| 24 | 22 | IotDataRuleCacheableAction<IotDataSinkTcpConfig, IotTcpClient> { |
| 25 | 23 | |
| 26 | - private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); | |
| 27 | - private static final Duration SEND_TIMEOUT = Duration.ofSeconds(10); | |
| 28 | - | |
| 29 | 24 | @Override |
| 30 | 25 | public Integer getType() { |
| 31 | 26 | return IotDataSinkTypeEnum.TCP.getType(); | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotWebSocketDataRuleAction.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.service.rule.data.action; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 5 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig; | |
| 6 | +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum; | |
| 7 | +import cn.iocoder.yudao.module.iot.service.rule.data.action.websocket.IotWebSocketClient; | |
| 8 | +import lombok.extern.slf4j.Slf4j; | |
| 9 | +import org.springframework.stereotype.Component; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * WebSocket 的 {@link IotDataRuleAction} 实现类 | |
| 13 | + * <p> | |
| 14 | + * 负责将设备消息发送到外部 WebSocket 服务器 | |
| 15 | + * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式 | |
| 16 | + * 使用连接池管理 WebSocket 连接,提高性能和资源利用率 | |
| 17 | + * | |
| 18 | + * @author HUIHUI | |
| 19 | + */ | |
| 20 | +@Component | |
| 21 | +@Slf4j | |
| 22 | +public class IotWebSocketDataRuleAction extends | |
| 23 | + IotDataRuleCacheableAction<IotDataSinkWebSocketConfig, IotWebSocketClient> { | |
| 24 | + | |
| 25 | + @Override | |
| 26 | + public Integer getType() { | |
| 27 | + return IotDataSinkTypeEnum.WEBSOCKET.getType(); | |
| 28 | + } | |
| 29 | + | |
| 30 | + @Override | |
| 31 | + protected IotWebSocketClient initProducer(IotDataSinkWebSocketConfig config) throws Exception { | |
| 32 | + // 1. 参数校验 | |
| 33 | + if (StrUtil.isBlank(config.getServerUrl())) { | |
| 34 | + throw new IllegalArgumentException("WebSocket 服务器地址不能为空"); | |
| 35 | + } | |
| 36 | + if (!StrUtil.startWithAny(config.getServerUrl(), "ws://", "wss://")) { | |
| 37 | + throw new IllegalArgumentException("WebSocket 服务器地址必须以 ws:// 或 wss:// 开头"); | |
| 38 | + } | |
| 39 | + | |
| 40 | + // 2.1 创建 WebSocket 客户端 | |
| 41 | + IotWebSocketClient webSocketClient = new IotWebSocketClient( | |
| 42 | + config.getServerUrl(), | |
| 43 | + config.getConnectTimeoutMs(), | |
| 44 | + config.getSendTimeoutMs(), | |
| 45 | + config.getDataFormat() | |
| 46 | + ); | |
| 47 | + // 2.2 连接服务器 | |
| 48 | + webSocketClient.connect(); | |
| 49 | + log.info("[initProducer][WebSocket 客户端创建并连接成功,服务器: {},数据格式: {}]", | |
| 50 | + config.getServerUrl(), config.getDataFormat()); | |
| 51 | + return webSocketClient; | |
| 52 | + } | |
| 53 | + | |
| 54 | + @Override | |
| 55 | + protected void closeProducer(IotWebSocketClient producer) throws Exception { | |
| 56 | + if (producer != null) { | |
| 57 | + producer.close(); | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + @Override | |
| 62 | + protected void execute(IotDeviceMessage message, IotDataSinkWebSocketConfig config) throws Exception { | |
| 63 | + try { | |
| 64 | + // 1.1 获取或创建 WebSocket 客户端 | |
| 65 | + // TODO @puhui999:需要加锁,保证必须连接上; | |
| 66 | + IotWebSocketClient webSocketClient = getProducer(config); | |
| 67 | + // 1.2 检查连接状态,如果断开则重新连接 | |
| 68 | + if (!webSocketClient.isConnected()) { | |
| 69 | + log.warn("[execute][WebSocket 连接已断开,尝试重新连接,服务器: {}]", config.getServerUrl()); | |
| 70 | + webSocketClient.connect(); | |
| 71 | + } | |
| 72 | + | |
| 73 | + // 2.1 发送消息 | |
| 74 | + webSocketClient.sendMessage(message); | |
| 75 | + // 2.2 记录发送成功日志 | |
| 76 | + log.info("[execute][message({}) config({}) 发送成功,WebSocket 服务器: {}]", | |
| 77 | + message, config, config.getServerUrl()); | |
| 78 | + } catch (Exception e) { | |
| 79 | + log.error("[execute][message({}) config({}) 发送失败,WebSocket 服务器: {}]", | |
| 80 | + message, config, config.getServerUrl(), e); | |
| 81 | + throw e; | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/tcp/IotTcpClient.java
| ... | ... | @@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.iot.service.rule.data.action.tcp; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 4 | 4 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 5 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkTcpConfig; | |
| 5 | 6 | import lombok.extern.slf4j.Slf4j; |
| 6 | 7 | |
| 7 | 8 | import javax.net.ssl.SSLSocketFactory; |
| ... | ... | @@ -30,6 +31,7 @@ public class IotTcpClient { |
| 30 | 31 | private final Integer connectTimeoutMs; |
| 31 | 32 | private final Integer readTimeoutMs; |
| 32 | 33 | private final Boolean ssl; |
| 34 | + // TODO @puhui999:sslCertPath 是不是没在用? | |
| 33 | 35 | private final String sslCertPath; |
| 34 | 36 | private final String dataFormat; |
| 35 | 37 | |
| ... | ... | @@ -38,16 +40,16 @@ public class IotTcpClient { |
| 38 | 40 | private BufferedReader reader; |
| 39 | 41 | private final AtomicBoolean connected = new AtomicBoolean(false); |
| 40 | 42 | |
| 41 | - // TODO @puhui999:default 值,IotDataSinkTcpConfig.java 枚举起来哈; | |
| 42 | 43 | public IotTcpClient(String host, Integer port, Integer connectTimeoutMs, Integer readTimeoutMs, |
| 43 | 44 | Boolean ssl, String sslCertPath, String dataFormat) { |
| 44 | 45 | this.host = host; |
| 45 | 46 | this.port = port; |
| 46 | - this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : 5000; | |
| 47 | - this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : 10000; | |
| 48 | - this.ssl = ssl != null ? ssl : false; | |
| 47 | + this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkTcpConfig.DEFAULT_CONNECT_TIMEOUT_MS; | |
| 48 | + this.readTimeoutMs = readTimeoutMs != null ? readTimeoutMs : IotDataSinkTcpConfig.DEFAULT_READ_TIMEOUT_MS; | |
| 49 | + this.ssl = ssl != null ? ssl : IotDataSinkTcpConfig.DEFAULT_SSL; | |
| 49 | 50 | this.sslCertPath = sslCertPath; |
| 50 | - this.dataFormat = dataFormat != null ? dataFormat : "JSON"; | |
| 51 | + // TODO @puhui999:可以使用 StrUtil.defaultIfBlank 方法简化 | |
| 52 | + this.dataFormat = dataFormat != null ? dataFormat : IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT; | |
| 51 | 53 | } |
| 52 | 54 | |
| 53 | 55 | /** |
| ... | ... | @@ -99,9 +101,8 @@ public class IotTcpClient { |
| 99 | 101 | } |
| 100 | 102 | |
| 101 | 103 | try { |
| 102 | - // TODO @puhui999:枚举值 | |
| 103 | 104 | String messageData; |
| 104 | - if ("JSON".equalsIgnoreCase(dataFormat)) { | |
| 105 | + if (IotDataSinkTcpConfig.DEFAULT_DATA_FORMAT.equalsIgnoreCase(dataFormat)) { | |
| 105 | 106 | // JSON 格式 |
| 106 | 107 | messageData = JsonUtils.toJsonString(message); |
| 107 | 108 | } else { | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/websocket/IotWebSocketClient.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.service.rule.data.action.websocket; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 5 | +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkWebSocketConfig; | |
| 6 | +import lombok.extern.slf4j.Slf4j; | |
| 7 | + | |
| 8 | +import java.net.URI; | |
| 9 | +import java.net.http.HttpClient; | |
| 10 | +import java.net.http.WebSocket; | |
| 11 | +import java.time.Duration; | |
| 12 | +import java.util.concurrent.CompletableFuture; | |
| 13 | +import java.util.concurrent.CompletionStage; | |
| 14 | +import java.util.concurrent.TimeUnit; | |
| 15 | +import java.util.concurrent.atomic.AtomicBoolean; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * IoT WebSocket 客户端 | |
| 19 | + * <p> | |
| 20 | + * 负责与外部 WebSocket 服务器建立连接并发送设备消息 | |
| 21 | + * 支持 ws:// 和 wss:// 协议,支持 JSON 和 TEXT 数据格式 | |
| 22 | + * 基于 Java 11+ 内置的 java.net.http.WebSocket 实现 | |
| 23 | + * | |
| 24 | + * @author HUIHUI | |
| 25 | + */ | |
| 26 | +@Slf4j | |
| 27 | +public class IotWebSocketClient implements WebSocket.Listener { | |
| 28 | + | |
| 29 | + private final String serverUrl; | |
| 30 | + private final Integer connectTimeoutMs; | |
| 31 | + private final Integer sendTimeoutMs; | |
| 32 | + private final String dataFormat; | |
| 33 | + | |
| 34 | + private WebSocket webSocket; | |
| 35 | + private final AtomicBoolean connected = new AtomicBoolean(false); | |
| 36 | + private final StringBuilder messageBuffer = new StringBuilder(); | |
| 37 | + | |
| 38 | + public IotWebSocketClient(String serverUrl, Integer connectTimeoutMs, Integer sendTimeoutMs, String dataFormat) { | |
| 39 | + this.serverUrl = serverUrl; | |
| 40 | + this.connectTimeoutMs = connectTimeoutMs != null ? connectTimeoutMs : IotDataSinkWebSocketConfig.DEFAULT_CONNECT_TIMEOUT_MS; | |
| 41 | + this.sendTimeoutMs = sendTimeoutMs != null ? sendTimeoutMs : IotDataSinkWebSocketConfig.DEFAULT_SEND_TIMEOUT_MS; | |
| 42 | + this.dataFormat = dataFormat != null ? dataFormat : IotDataSinkWebSocketConfig.DEFAULT_DATA_FORMAT; | |
| 43 | + } | |
| 44 | + | |
| 45 | + /** | |
| 46 | + * 连接到 WebSocket 服务器 | |
| 47 | + */ | |
| 48 | + @SuppressWarnings("resource") | |
| 49 | + public void connect() throws Exception { | |
| 50 | + if (connected.get()) { | |
| 51 | + log.warn("[connect][WebSocket 客户端已经连接,无需重复连接]"); | |
| 52 | + return; | |
| 53 | + } | |
| 54 | + | |
| 55 | + try { | |
| 56 | + HttpClient httpClient = HttpClient.newBuilder() | |
| 57 | + .connectTimeout(Duration.ofMillis(connectTimeoutMs)) | |
| 58 | + .build(); | |
| 59 | + | |
| 60 | + CompletableFuture<WebSocket> future = httpClient.newWebSocketBuilder() | |
| 61 | + .connectTimeout(Duration.ofMillis(connectTimeoutMs)) | |
| 62 | + .buildAsync(URI.create(serverUrl), this); | |
| 63 | + | |
| 64 | + // 等待连接完成 | |
| 65 | + webSocket = future.get(connectTimeoutMs, TimeUnit.MILLISECONDS); | |
| 66 | + connected.set(true); | |
| 67 | + log.info("[connect][WebSocket 客户端连接成功,服务器地址: {}]", serverUrl); | |
| 68 | + } catch (Exception e) { | |
| 69 | + close(); | |
| 70 | + log.error("[connect][WebSocket 客户端连接失败,服务器地址: {}]", serverUrl, e); | |
| 71 | + throw e; | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + @Override | |
| 76 | + public void onOpen(WebSocket webSocket) { | |
| 77 | + log.debug("[onOpen][WebSocket 连接已打开]"); | |
| 78 | + webSocket.request(1); | |
| 79 | + } | |
| 80 | + | |
| 81 | + @Override | |
| 82 | + public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) { | |
| 83 | + messageBuffer.append(data); | |
| 84 | + if (last) { | |
| 85 | + log.debug("[onText][收到 WebSocket 消息: {}]", messageBuffer); | |
| 86 | + messageBuffer.setLength(0); | |
| 87 | + } | |
| 88 | + webSocket.request(1); | |
| 89 | + return null; | |
| 90 | + } | |
| 91 | + | |
| 92 | + @Override | |
| 93 | + public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) { | |
| 94 | + connected.set(false); | |
| 95 | + log.info("[onClose][WebSocket 连接已关闭,状态码: {},原因: {}]", statusCode, reason); | |
| 96 | + return null; | |
| 97 | + } | |
| 98 | + | |
| 99 | + @Override | |
| 100 | + public void onError(WebSocket webSocket, Throwable error) { | |
| 101 | + connected.set(false); | |
| 102 | + log.error("[onError][WebSocket 发生错误]", error); | |
| 103 | + } | |
| 104 | + | |
| 105 | + /** | |
| 106 | + * 发送设备消息 | |
| 107 | + * | |
| 108 | + * @param message 设备消息 | |
| 109 | + * @throws Exception 发送异常 | |
| 110 | + */ | |
| 111 | + public void sendMessage(IotDeviceMessage message) throws Exception { | |
| 112 | + if (!connected.get() || webSocket == null) { | |
| 113 | + throw new IllegalStateException("WebSocket 客户端未连接"); | |
| 114 | + } | |
| 115 | + | |
| 116 | + try { | |
| 117 | + String messageData; | |
| 118 | + if (IotDataSinkWebSocketConfig.DEFAULT_DATA_FORMAT.equalsIgnoreCase(dataFormat)) { | |
| 119 | + messageData = JsonUtils.toJsonString(message); | |
| 120 | + } else { | |
| 121 | + messageData = message.toString(); | |
| 122 | + } | |
| 123 | + | |
| 124 | + // 发送消息并等待完成 | |
| 125 | + CompletableFuture<WebSocket> future = webSocket.sendText(messageData, true); | |
| 126 | + future.get(sendTimeoutMs, TimeUnit.MILLISECONDS); | |
| 127 | + log.debug("[sendMessage][发送消息成功,设备 ID: {},消息长度: {}]", | |
| 128 | + message.getDeviceId(), messageData.length()); | |
| 129 | + } catch (Exception e) { | |
| 130 | + log.error("[sendMessage][发送消息失败,设备 ID: {}]", message.getDeviceId(), e); | |
| 131 | + throw e; | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + /** | |
| 136 | + * 关闭连接 | |
| 137 | + */ | |
| 138 | + public void close() { | |
| 139 | + if (!connected.get() && webSocket == null) { | |
| 140 | + return; | |
| 141 | + } | |
| 142 | + | |
| 143 | + try { | |
| 144 | + if (webSocket != null) { | |
| 145 | + webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "客户端主动关闭") | |
| 146 | + .orTimeout(5, TimeUnit.SECONDS) | |
| 147 | + .exceptionally(e -> { | |
| 148 | + log.warn("[close][发送关闭帧失败]", e); | |
| 149 | + return null; | |
| 150 | + }); | |
| 151 | + } | |
| 152 | + connected.set(false); | |
| 153 | + log.info("[close][WebSocket 客户端连接已关闭,服务器地址: {}]", serverUrl); | |
| 154 | + } catch (Exception e) { | |
| 155 | + log.error("[close][关闭 WebSocket 客户端连接异常]", e); | |
| 156 | + } | |
| 157 | + } | |
| 158 | + | |
| 159 | + /** | |
| 160 | + * 检查连接状态 | |
| 161 | + * | |
| 162 | + * @return 是否已连接 | |
| 163 | + */ | |
| 164 | + public boolean isConnected() { | |
| 165 | + return connected.get() && webSocket != null; | |
| 166 | + } | |
| 167 | + | |
| 168 | + @Override | |
| 169 | + public String toString() { | |
| 170 | + return "IotWebSocketClient{" + | |
| 171 | + "serverUrl='" + serverUrl + '\'' + | |
| 172 | + ", dataFormat='" + dataFormat + '\'' + | |
| 173 | + ", connected=" + connected.get() + | |
| 174 | + '}'; | |
| 175 | + } | |
| 176 | + | |
| 177 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/IotSceneRuleServiceImpl.java
| ... | ... | @@ -30,6 +30,7 @@ import org.springframework.cache.annotation.Cacheable; |
| 30 | 30 | import org.springframework.stereotype.Service; |
| 31 | 31 | import org.springframework.validation.annotation.Validated; |
| 32 | 32 | |
| 33 | +import java.time.LocalDateTime; | |
| 33 | 34 | import java.util.Collection; |
| 34 | 35 | import java.util.List; |
| 35 | 36 | |
| ... | ... | @@ -392,9 +393,25 @@ public class IotSceneRuleServiceImpl implements IotSceneRuleService { |
| 392 | 393 | } |
| 393 | 394 | }); |
| 394 | 395 | }); |
| 396 | + | |
| 397 | + // 3. 更新最后触发时间 | |
| 398 | + updateLastTriggerTime(sceneRule.getId()); | |
| 395 | 399 | }); |
| 396 | 400 | } |
| 397 | 401 | |
| 402 | + /** | |
| 403 | + * 更新规则场景的最后触发时间 | |
| 404 | + * | |
| 405 | + * @param id 规则场景编号 | |
| 406 | + */ | |
| 407 | + private void updateLastTriggerTime(Long id) { | |
| 408 | + try { | |
| 409 | + sceneRuleMapper.updateById(new IotSceneRuleDO().setId(id).setLastTriggerTime(LocalDateTime.now())); | |
| 410 | + } catch (Exception e) { | |
| 411 | + log.error("[updateLastTriggerTime][规则场景编号({}) 更新最后触发时间异常]", id, e); | |
| 412 | + } | |
| 413 | + } | |
| 414 | + | |
| 398 | 415 | private IotSceneRuleServiceImpl getSelf() { |
| 399 | 416 | return SpringUtil.getBean(IotSceneRuleServiceImpl.class); |
| 400 | 417 | } | ... | ... |
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/scene/matcher/trigger/IotDevicePropertyPostTriggerMatcher.java
| ... | ... | @@ -36,11 +36,12 @@ public class IotDevicePropertyPostTriggerMatcher implements IotSceneRuleTriggerM |
| 36 | 36 | return false; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | - // 1.3 检查标识符是否匹配 | |
| 40 | - String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message); | |
| 41 | - if (!IotSceneRuleMatcherHelper.isIdentifierMatched(trigger.getIdentifier(), messageIdentifier)) { | |
| 42 | - IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "标识符不匹配,期望: " + | |
| 43 | - trigger.getIdentifier() + ", 实际: " + messageIdentifier); | |
| 39 | + // 1.3 检查消息中是否包含触发器指定的属性标识符 | |
| 40 | + // 注意:属性上报可能同时上报多个属性,所以需要判断 trigger.getIdentifier() 是否在 message 的 params 中 | |
| 41 | + // TODO @puhui999:可以考虑 notXXX 方法,简化代码(尽量取反) | |
| 42 | + if (!IotDeviceMessageUtils.containsIdentifier(message, trigger.getIdentifier())) { | |
| 43 | + IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中不包含属性: " + | |
| 44 | + trigger.getIdentifier()); | |
| 44 | 45 | return false; |
| 45 | 46 | } |
| 46 | 47 | ... | ... |
yudao-module-iot/yudao-module-iot-core/src/main/java/cn/iocoder/yudao/module/iot/core/util/IotDeviceMessageUtils.java
| ... | ... | @@ -5,6 +5,7 @@ import cn.hutool.core.map.MapUtil; |
| 5 | 5 | import cn.hutool.core.util.IdUtil; |
| 6 | 6 | import cn.hutool.core.util.StrUtil; |
| 7 | 7 | import cn.hutool.system.SystemUtil; |
| 8 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | |
| 8 | 9 | import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; |
| 9 | 10 | import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; |
| 10 | 11 | |
| ... | ... | @@ -70,6 +71,55 @@ public class IotDeviceMessageUtils { |
| 70 | 71 | } |
| 71 | 72 | |
| 72 | 73 | /** |
| 74 | + * 判断消息中是否包含指定的标识符 | |
| 75 | + * | |
| 76 | + * 对于不同消息类型的处理: | |
| 77 | + * - EVENT_POST/SERVICE_INVOKE:检查 params.identifier 是否匹配 | |
| 78 | + * - STATE_UPDATE:检查 params.state 是否匹配 | |
| 79 | + * - PROPERTY_POST:检查 params 中是否包含该属性 key | |
| 80 | + * | |
| 81 | + * @param message 消息 | |
| 82 | + * @param identifier 要检查的标识符 | |
| 83 | + * @return 是否包含 | |
| 84 | + */ | |
| 85 | + public static boolean containsIdentifier(IotDeviceMessage message, String identifier) { | |
| 86 | + if (message.getParams() == null || StrUtil.isBlank(identifier)) { | |
| 87 | + return false; | |
| 88 | + } | |
| 89 | + // EVENT_POST / SERVICE_INVOKE / STATE_UPDATE:使用原有逻辑 | |
| 90 | + String messageIdentifier = getIdentifier(message); | |
| 91 | + if (messageIdentifier != null) { | |
| 92 | + return identifier.equals(messageIdentifier); | |
| 93 | + } | |
| 94 | + // PROPERTY_POST:检查 params 中是否包含该属性 key | |
| 95 | + if (StrUtil.equals(message.getMethod(), IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod())) { | |
| 96 | + Map<String, Object> params = parseParamsToMap(message.getParams()); | |
| 97 | + return params != null && params.containsKey(identifier); | |
| 98 | + } | |
| 99 | + return false; | |
| 100 | + } | |
| 101 | + | |
| 102 | + /** | |
| 103 | + * 将 params 解析为 Map | |
| 104 | + * | |
| 105 | + * @param params 参数(可能是 Map 或 JSON 字符串) | |
| 106 | + * @return Map,解析失败返回 null | |
| 107 | + */ | |
| 108 | + @SuppressWarnings("unchecked") | |
| 109 | + private static Map<String, Object> parseParamsToMap(Object params) { | |
| 110 | + if (params instanceof Map) { | |
| 111 | + return (Map<String, Object>) params; | |
| 112 | + } | |
| 113 | + if (params instanceof String) { | |
| 114 | + try { | |
| 115 | + return JsonUtils.parseObject((String) params, Map.class); | |
| 116 | + } catch (Exception ignored) { | |
| 117 | + } | |
| 118 | + } | |
| 119 | + return null; | |
| 120 | + } | |
| 121 | + | |
| 122 | + /** | |
| 73 | 123 | * 从设备消息中提取指定标识符的属性值 |
| 74 | 124 | * - 支持多种消息格式和属性值提取策略 |
| 75 | 125 | * - 兼容现有的消息结构 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayConfiguration.java
| ... | ... | @@ -10,6 +10,10 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttDownstreamSubscr |
| 10 | 10 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttUpstreamProtocol; |
| 11 | 11 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager; |
| 12 | 12 | import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router.IotMqttDownstreamHandler; |
| 13 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.IotMqttWsDownstreamSubscriber; | |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.IotMqttWsUpstreamProtocol; | |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager; | |
| 16 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsDownstreamHandler; | |
| 13 | 17 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpDownstreamSubscriber; |
| 14 | 18 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol; |
| 15 | 19 | import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager; |
| ... | ... | @@ -17,6 +21,7 @@ import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; |
| 17 | 21 | import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; |
| 18 | 22 | import io.vertx.core.Vertx; |
| 19 | 23 | import lombok.extern.slf4j.Slf4j; |
| 24 | +import org.springframework.beans.factory.annotation.Qualifier; | |
| 20 | 25 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; |
| 21 | 26 | import org.springframework.boot.context.properties.EnableConfigurationProperties; |
| 22 | 27 | import org.springframework.context.annotation.Bean; |
| ... | ... | @@ -55,20 +60,20 @@ public class IotGatewayConfiguration { |
| 55 | 60 | @Slf4j |
| 56 | 61 | public static class EmqxProtocolConfiguration { |
| 57 | 62 | |
| 58 | - @Bean(destroyMethod = "close") | |
| 63 | + @Bean(name = "emqxVertx", destroyMethod = "close") | |
| 59 | 64 | public Vertx emqxVertx() { |
| 60 | 65 | return Vertx.vertx(); |
| 61 | 66 | } |
| 62 | 67 | |
| 63 | 68 | @Bean |
| 64 | 69 | public IotEmqxAuthEventProtocol iotEmqxAuthEventProtocol(IotGatewayProperties gatewayProperties, |
| 65 | - Vertx emqxVertx) { | |
| 70 | + @Qualifier("emqxVertx") Vertx emqxVertx) { | |
| 66 | 71 | return new IotEmqxAuthEventProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx); |
| 67 | 72 | } |
| 68 | 73 | |
| 69 | 74 | @Bean |
| 70 | 75 | public IotEmqxUpstreamProtocol iotEmqxUpstreamProtocol(IotGatewayProperties gatewayProperties, |
| 71 | - Vertx emqxVertx) { | |
| 76 | + @Qualifier("emqxVertx") Vertx emqxVertx) { | |
| 72 | 77 | return new IotEmqxUpstreamProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx); |
| 73 | 78 | } |
| 74 | 79 | |
| ... | ... | @@ -87,7 +92,7 @@ public class IotGatewayConfiguration { |
| 87 | 92 | @Slf4j |
| 88 | 93 | public static class TcpProtocolConfiguration { |
| 89 | 94 | |
| 90 | - @Bean(destroyMethod = "close") | |
| 95 | + @Bean(name = "tcpVertx", destroyMethod = "close") | |
| 91 | 96 | public Vertx tcpVertx() { |
| 92 | 97 | return Vertx.vertx(); |
| 93 | 98 | } |
| ... | ... | @@ -97,7 +102,7 @@ public class IotGatewayConfiguration { |
| 97 | 102 | IotDeviceService deviceService, |
| 98 | 103 | IotDeviceMessageService messageService, |
| 99 | 104 | IotTcpConnectionManager connectionManager, |
| 100 | - Vertx tcpVertx) { | |
| 105 | + @Qualifier("tcpVertx") Vertx tcpVertx) { | |
| 101 | 106 | return new IotTcpUpstreamProtocol(gatewayProperties.getProtocol().getTcp(), |
| 102 | 107 | deviceService, messageService, connectionManager, tcpVertx); |
| 103 | 108 | } |
| ... | ... | @@ -122,7 +127,7 @@ public class IotGatewayConfiguration { |
| 122 | 127 | @Slf4j |
| 123 | 128 | public static class MqttProtocolConfiguration { |
| 124 | 129 | |
| 125 | - @Bean(destroyMethod = "close") | |
| 130 | + @Bean(name = "mqttVertx", destroyMethod = "close") | |
| 126 | 131 | public Vertx mqttVertx() { |
| 127 | 132 | return Vertx.vertx(); |
| 128 | 133 | } |
| ... | ... | @@ -131,7 +136,7 @@ public class IotGatewayConfiguration { |
| 131 | 136 | public IotMqttUpstreamProtocol iotMqttUpstreamProtocol(IotGatewayProperties gatewayProperties, |
| 132 | 137 | IotDeviceMessageService messageService, |
| 133 | 138 | IotMqttConnectionManager connectionManager, |
| 134 | - Vertx mqttVertx) { | |
| 139 | + @Qualifier("mqttVertx") Vertx mqttVertx) { | |
| 135 | 140 | return new IotMqttUpstreamProtocol(gatewayProperties.getProtocol().getMqtt(), messageService, |
| 136 | 141 | connectionManager, mqttVertx); |
| 137 | 142 | } |
| ... | ... | @@ -151,4 +156,42 @@ public class IotGatewayConfiguration { |
| 151 | 156 | |
| 152 | 157 | } |
| 153 | 158 | |
| 159 | + /** | |
| 160 | + * IoT 网关 MQTT WebSocket 协议配置类 | |
| 161 | + */ | |
| 162 | + @Configuration | |
| 163 | + @ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.mqtt-ws", name = "enabled", havingValue = "true") | |
| 164 | + @Slf4j | |
| 165 | + public static class MqttWsProtocolConfiguration { | |
| 166 | + | |
| 167 | + @Bean(name = "mqttWsVertx", destroyMethod = "close") | |
| 168 | + public Vertx mqttWsVertx() { | |
| 169 | + return Vertx.vertx(); | |
| 170 | + } | |
| 171 | + | |
| 172 | + @Bean | |
| 173 | + public IotMqttWsUpstreamProtocol iotMqttWsUpstreamProtocol(IotGatewayProperties gatewayProperties, | |
| 174 | + IotDeviceMessageService messageService, | |
| 175 | + IotMqttWsConnectionManager connectionManager, | |
| 176 | + @Qualifier("mqttWsVertx") Vertx mqttWsVertx) { | |
| 177 | + return new IotMqttWsUpstreamProtocol(gatewayProperties.getProtocol().getMqttWs(), | |
| 178 | + messageService, connectionManager, mqttWsVertx); | |
| 179 | + } | |
| 180 | + | |
| 181 | + @Bean | |
| 182 | + public IotMqttWsDownstreamHandler iotMqttWsDownstreamHandler(IotDeviceMessageService messageService, | |
| 183 | + IotDeviceService deviceService, | |
| 184 | + IotMqttWsConnectionManager connectionManager) { | |
| 185 | + return new IotMqttWsDownstreamHandler(messageService, deviceService, connectionManager); | |
| 186 | + } | |
| 187 | + | |
| 188 | + @Bean | |
| 189 | + public IotMqttWsDownstreamSubscriber iotMqttWsDownstreamSubscriber(IotMqttWsUpstreamProtocol mqttWsUpstreamProtocol, | |
| 190 | + IotMqttWsDownstreamHandler downstreamHandler, | |
| 191 | + IotMessageBus messageBus) { | |
| 192 | + return new IotMqttWsDownstreamSubscriber(mqttWsUpstreamProtocol, downstreamHandler, messageBus); | |
| 193 | + } | |
| 194 | + | |
| 195 | + } | |
| 196 | + | |
| 154 | 197 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/config/IotGatewayProperties.java
| ... | ... | @@ -88,6 +88,11 @@ public class IotGatewayProperties { |
| 88 | 88 | */ |
| 89 | 89 | private MqttProperties mqtt; |
| 90 | 90 | |
| 91 | + /** | |
| 92 | + * MQTT WebSocket 组件配置 | |
| 93 | + */ | |
| 94 | + private MqttWsProperties mqttWs; | |
| 95 | + | |
| 91 | 96 | } |
| 92 | 97 | |
| 93 | 98 | @Data |
| ... | ... | @@ -402,4 +407,100 @@ public class IotGatewayProperties { |
| 402 | 407 | |
| 403 | 408 | } |
| 404 | 409 | |
| 410 | + @Data | |
| 411 | + public static class MqttWsProperties { | |
| 412 | + | |
| 413 | + /** | |
| 414 | + * 是否开启 | |
| 415 | + */ | |
| 416 | + @NotNull(message = "是否开启不能为空") | |
| 417 | + private Boolean enabled; | |
| 418 | + | |
| 419 | + /** | |
| 420 | + * WebSocket 服务器端口(默认:8083) | |
| 421 | + */ | |
| 422 | + private Integer port = 8083; | |
| 423 | + | |
| 424 | + /** | |
| 425 | + * WebSocket 路径(默认:/mqtt) | |
| 426 | + */ | |
| 427 | + @NotEmpty(message = "WebSocket 路径不能为空") | |
| 428 | + private String path = "/mqtt"; | |
| 429 | + | |
| 430 | + /** | |
| 431 | + * 最大消息大小(字节) | |
| 432 | + */ | |
| 433 | + private Integer maxMessageSize = 8192; | |
| 434 | + | |
| 435 | + /** | |
| 436 | + * 连接超时时间(秒) | |
| 437 | + */ | |
| 438 | + private Integer connectTimeoutSeconds = 60; | |
| 439 | + | |
| 440 | + /** | |
| 441 | + * 保持连接超时时间(秒) | |
| 442 | + */ | |
| 443 | + private Integer keepAliveTimeoutSeconds = 300; | |
| 444 | + | |
| 445 | + /** | |
| 446 | + * 是否启用 SSL(wss://) | |
| 447 | + */ | |
| 448 | + private Boolean sslEnabled = false; | |
| 449 | + | |
| 450 | + /** | |
| 451 | + * SSL 配置 | |
| 452 | + */ | |
| 453 | + private SslOptions sslOptions = new SslOptions(); | |
| 454 | + | |
| 455 | + /** | |
| 456 | + * WebSocket 子协议(通常为 "mqtt" 或 "mqttv3.1") | |
| 457 | + */ | |
| 458 | + @NotEmpty(message = "WebSocket 子协议不能为空") | |
| 459 | + private String subProtocol = "mqtt"; | |
| 460 | + | |
| 461 | + /** | |
| 462 | + * 最大帧大小(字节) | |
| 463 | + */ | |
| 464 | + private Integer maxFrameSize = 65536; | |
| 465 | + | |
| 466 | + /** | |
| 467 | + * SSL 配置选项 | |
| 468 | + */ | |
| 469 | + @Data | |
| 470 | + public static class SslOptions { | |
| 471 | + | |
| 472 | + /** | |
| 473 | + * 密钥证书选项 | |
| 474 | + */ | |
| 475 | + private io.vertx.core.net.KeyCertOptions keyCertOptions; | |
| 476 | + | |
| 477 | + /** | |
| 478 | + * 信任选项 | |
| 479 | + */ | |
| 480 | + private io.vertx.core.net.TrustOptions trustOptions; | |
| 481 | + | |
| 482 | + /** | |
| 483 | + * SSL 证书路径 | |
| 484 | + */ | |
| 485 | + private String certPath; | |
| 486 | + | |
| 487 | + /** | |
| 488 | + * SSL 私钥路径 | |
| 489 | + */ | |
| 490 | + private String keyPath; | |
| 491 | + | |
| 492 | + /** | |
| 493 | + * 信任存储路径 | |
| 494 | + */ | |
| 495 | + private String trustStorePath; | |
| 496 | + | |
| 497 | + /** | |
| 498 | + * 信任存储密码 | |
| 499 | + */ | |
| 500 | + private String trustStorePassword; | |
| 501 | + | |
| 502 | + } | |
| 503 | + | |
| 504 | + } | |
| 505 | + | |
| 405 | 506 | } | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsDownstreamSubscriber.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 7 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsDownstreamHandler; | |
| 9 | +import jakarta.annotation.PostConstruct; | |
| 10 | +import lombok.extern.slf4j.Slf4j; | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * IoT MQTT WebSocket 下行消息订阅器 | |
| 14 | + * <p> | |
| 15 | + * 订阅消息总线的设备下行消息,并通过 WebSocket 发送到设备 | |
| 16 | + * | |
| 17 | + * @author 芋道源码 | |
| 18 | + */ | |
| 19 | +@Slf4j | |
| 20 | +public class IotMqttWsDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> { | |
| 21 | + | |
| 22 | + private final IotMqttWsUpstreamProtocol upstreamProtocol; | |
| 23 | + private final IotMqttWsDownstreamHandler downstreamHandler; | |
| 24 | + private final IotMessageBus messageBus; | |
| 25 | + | |
| 26 | + public IotMqttWsDownstreamSubscriber(IotMqttWsUpstreamProtocol upstreamProtocol, | |
| 27 | + IotMqttWsDownstreamHandler downstreamHandler, | |
| 28 | + IotMessageBus messageBus) { | |
| 29 | + this.upstreamProtocol = upstreamProtocol; | |
| 30 | + this.downstreamHandler = downstreamHandler; | |
| 31 | + this.messageBus = messageBus; | |
| 32 | + } | |
| 33 | + | |
| 34 | + @PostConstruct | |
| 35 | + public void init() { | |
| 36 | + messageBus.register(this); | |
| 37 | + log.info("[init][MQTT WebSocket 下行消息订阅器已启动,topic: {}]", getTopic()); | |
| 38 | + } | |
| 39 | + | |
| 40 | + @Override | |
| 41 | + public String getTopic() { | |
| 42 | + return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(upstreamProtocol.getServerId()); | |
| 43 | + } | |
| 44 | + | |
| 45 | + @Override | |
| 46 | + public String getGroup() { | |
| 47 | + // 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group | |
| 48 | + return getTopic(); | |
| 49 | + } | |
| 50 | + | |
| 51 | + @Override | |
| 52 | + public void onMessage(IotDeviceMessage message) { | |
| 53 | + log.debug("[onMessage][收到下行消息,deviceId: {},method: {}]", | |
| 54 | + message.getDeviceId(), message.getMethod()); | |
| 55 | + try { | |
| 56 | + // 1. 校验 | |
| 57 | + String method = message.getMethod(); | |
| 58 | + if (StrUtil.isBlank(method)) { | |
| 59 | + log.warn("[onMessage][消息方法为空,deviceId: {}]", message.getDeviceId()); | |
| 60 | + return; | |
| 61 | + } | |
| 62 | + | |
| 63 | + // 2. 委托给下行处理器处理业务逻辑 | |
| 64 | + boolean success = downstreamHandler.handleDownstreamMessage(message); | |
| 65 | + if (success) { | |
| 66 | + log.debug("[onMessage][下行消息处理成功,deviceId: {},method: {}]", | |
| 67 | + message.getDeviceId(), message.getMethod()); | |
| 68 | + } else { | |
| 69 | + log.warn("[onMessage][下行消息处理失败,deviceId: {},method: {}]", | |
| 70 | + message.getDeviceId(), message.getMethod()); | |
| 71 | + } | |
| 72 | + } catch (Exception e) { | |
| 73 | + log.error("[onMessage][处理下行消息失败,deviceId: {},method: {}]", | |
| 74 | + message.getDeviceId(), message.getMethod(), e); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | +} | |
| 79 | + | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/IotMqttWsUpstreamProtocol.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 4 | +import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties; | |
| 5 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager; | |
| 6 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router.IotMqttWsUpstreamHandler; | |
| 7 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 8 | +import io.vertx.core.Vertx; | |
| 9 | +import io.vertx.core.http.HttpServer; | |
| 10 | +import io.vertx.core.http.HttpServerOptions; | |
| 11 | +import io.vertx.core.http.ServerWebSocket; | |
| 12 | +import jakarta.annotation.PostConstruct; | |
| 13 | +import jakarta.annotation.PreDestroy; | |
| 14 | +import lombok.Getter; | |
| 15 | +import lombok.extern.slf4j.Slf4j; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * IoT 网关 MQTT WebSocket 协议:接收设备上行消息 | |
| 19 | + * <p> | |
| 20 | + * 基于 Vert.x 实现 MQTT over WebSocket 服务端,支持: | |
| 21 | + * - 标准 MQTT 3.1.1 协议 | |
| 22 | + * - WebSocket 协议升级 | |
| 23 | + * - SSL/TLS 加密(wss://) | |
| 24 | + * - 设备认证与连接管理 | |
| 25 | + * - QoS 0/1/2 消息质量保证 | |
| 26 | + * | |
| 27 | + * @author 芋道源码 | |
| 28 | + */ | |
| 29 | +@Slf4j | |
| 30 | +public class IotMqttWsUpstreamProtocol { | |
| 31 | + | |
| 32 | + private final IotGatewayProperties.MqttWsProperties mqttWsProperties; | |
| 33 | + | |
| 34 | + private final IotDeviceMessageService messageService; | |
| 35 | + | |
| 36 | + private final IotMqttWsConnectionManager connectionManager; | |
| 37 | + | |
| 38 | + private final Vertx vertx; | |
| 39 | + | |
| 40 | + @Getter | |
| 41 | + private final String serverId; | |
| 42 | + | |
| 43 | + private HttpServer httpServer; | |
| 44 | + | |
| 45 | + public IotMqttWsUpstreamProtocol(IotGatewayProperties.MqttWsProperties mqttWsProperties, | |
| 46 | + IotDeviceMessageService messageService, | |
| 47 | + IotMqttWsConnectionManager connectionManager, | |
| 48 | + Vertx vertx) { | |
| 49 | + this.mqttWsProperties = mqttWsProperties; | |
| 50 | + this.messageService = messageService; | |
| 51 | + this.connectionManager = connectionManager; | |
| 52 | + this.vertx = vertx; | |
| 53 | + this.serverId = IotDeviceMessageUtils.generateServerId(mqttWsProperties.getPort()); | |
| 54 | + } | |
| 55 | + | |
| 56 | + @PostConstruct | |
| 57 | + public void start() { | |
| 58 | + // 创建 HTTP 服务器选项 | |
| 59 | + HttpServerOptions options = new HttpServerOptions() | |
| 60 | + .setPort(mqttWsProperties.getPort()) | |
| 61 | + .setIdleTimeout(mqttWsProperties.getKeepAliveTimeoutSeconds()) | |
| 62 | + .setMaxWebSocketFrameSize(mqttWsProperties.getMaxFrameSize()) | |
| 63 | + .setMaxWebSocketMessageSize(mqttWsProperties.getMaxMessageSize()) | |
| 64 | + // 配置 WebSocket 子协议支持 | |
| 65 | + .addWebSocketSubProtocol(mqttWsProperties.getSubProtocol()); | |
| 66 | + | |
| 67 | + // 配置 SSL(如果启用) | |
| 68 | + if (Boolean.TRUE.equals(mqttWsProperties.getSslEnabled())) { | |
| 69 | + options.setSsl(true) | |
| 70 | + .setKeyCertOptions(mqttWsProperties.getSslOptions().getKeyCertOptions()) | |
| 71 | + .setTrustOptions(mqttWsProperties.getSslOptions().getTrustOptions()); | |
| 72 | + log.info("[start][MQTT WebSocket 已启用 SSL/TLS (wss://)]"); | |
| 73 | + } | |
| 74 | + | |
| 75 | + // 创建 HTTP 服务器 | |
| 76 | + httpServer = vertx.createHttpServer(options); | |
| 77 | + | |
| 78 | + // 设置 WebSocket 处理器 | |
| 79 | + httpServer.webSocketHandler(this::handleWebSocketConnection); | |
| 80 | + | |
| 81 | + // 启动服务器 | |
| 82 | + try { | |
| 83 | + httpServer.listen().result(); | |
| 84 | + log.info("[start][IoT 网关 MQTT WebSocket 协议启动成功,端口: {},路径: {},支持子协议: {}]", | |
| 85 | + mqttWsProperties.getPort(), mqttWsProperties.getPath(), | |
| 86 | + "mqtt, mqttv3.1, " + mqttWsProperties.getSubProtocol()); | |
| 87 | + } catch (Exception e) { | |
| 88 | + log.error("[start][IoT 网关 MQTT WebSocket 协议启动失败]", e); | |
| 89 | + throw e; | |
| 90 | + } | |
| 91 | + } | |
| 92 | + | |
| 93 | + @PreDestroy | |
| 94 | + public void stop() { | |
| 95 | + if (httpServer != null) { | |
| 96 | + try { | |
| 97 | + // 关闭所有连接 | |
| 98 | + connectionManager.closeAllConnections(); | |
| 99 | + | |
| 100 | + // 关闭服务器 | |
| 101 | + httpServer.close().result(); | |
| 102 | + log.info("[stop][IoT 网关 MQTT WebSocket 协议已停止]"); | |
| 103 | + } catch (Exception e) { | |
| 104 | + log.error("[stop][IoT 网关 MQTT WebSocket 协议停止失败]", e); | |
| 105 | + } | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + /** | |
| 110 | + * 处理 WebSocket 连接请求 | |
| 111 | + * | |
| 112 | + * @param socket WebSocket 连接 | |
| 113 | + */ | |
| 114 | + private void handleWebSocketConnection(ServerWebSocket socket) { | |
| 115 | + String path = socket.path(); | |
| 116 | + String subProtocol = socket.subProtocol(); | |
| 117 | + | |
| 118 | + log.info("[handleWebSocketConnection][收到 WebSocket 连接请求,path: {},subProtocol: {},remoteAddress: {}]", | |
| 119 | + path, subProtocol, socket.remoteAddress()); | |
| 120 | + | |
| 121 | + // 验证路径 | |
| 122 | + if (!mqttWsProperties.getPath().equals(path)) { | |
| 123 | + log.warn("[handleWebSocketConnection][WebSocket 路径不匹配,拒绝连接,path: {},期望: {}]", | |
| 124 | + path, mqttWsProperties.getPath()); | |
| 125 | + socket.close(); | |
| 126 | + return; | |
| 127 | + } | |
| 128 | + | |
| 129 | + // 验证子协议 | |
| 130 | + // Vert.x 已经自动进行了子协议协商,这里只需要验证是否为 MQTT 相关协议 | |
| 131 | + if (subProtocol != null && !subProtocol.startsWith("mqtt")) { | |
| 132 | + log.warn("[handleWebSocketConnection][WebSocket 子协议不支持,拒绝连接,subProtocol: {}]", subProtocol); | |
| 133 | + socket.close(); | |
| 134 | + return; | |
| 135 | + } | |
| 136 | + | |
| 137 | + log.info("[handleWebSocketConnection][WebSocket 连接已接受,remoteAddress: {},subProtocol: {}]", | |
| 138 | + socket.remoteAddress(), subProtocol); | |
| 139 | + | |
| 140 | + // 创建处理器并处理连接 | |
| 141 | + IotMqttWsUpstreamHandler handler = new IotMqttWsUpstreamHandler( | |
| 142 | + this, messageService, connectionManager); | |
| 143 | + handler.handle(socket); | |
| 144 | + } | |
| 145 | + | |
| 146 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/manager/IotMqttWsConnectionManager.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager; | |
| 2 | + | |
| 3 | +import cn.hutool.core.collection.CollUtil; | |
| 4 | +import io.vertx.core.http.ServerWebSocket; | |
| 5 | +import lombok.extern.slf4j.Slf4j; | |
| 6 | +import org.springframework.stereotype.Component; | |
| 7 | + | |
| 8 | +import java.util.Map; | |
| 9 | +import java.util.Set; | |
| 10 | +import java.util.concurrent.ConcurrentHashMap; | |
| 11 | +import java.util.concurrent.CopyOnWriteArraySet; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * IoT MQTT WebSocket 连接管理器 | |
| 15 | + * | |
| 16 | + * @author 芋道源码 | |
| 17 | + */ | |
| 18 | +@Slf4j | |
| 19 | +@Component | |
| 20 | +public class IotMqttWsConnectionManager { | |
| 21 | + | |
| 22 | + /** | |
| 23 | + * 存储设备连接 | |
| 24 | + * Key: 设备标识(deviceKey) | |
| 25 | + * Value: WebSocket 连接 | |
| 26 | + */ | |
| 27 | + private final Map<String, ServerWebSocket> connections = new ConcurrentHashMap<>(); | |
| 28 | + | |
| 29 | + /** | |
| 30 | + * 存储设备标识与 Socket ID 的映射 | |
| 31 | + * Key: 设备标识(deviceKey) | |
| 32 | + * Value: Socket ID(UUID) | |
| 33 | + */ | |
| 34 | + private final Map<String, String> deviceKeyToSocketId = new ConcurrentHashMap<>(); | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * 存储 Socket ID 与设备标识的映射 | |
| 38 | + * Key: Socket ID(UUID) | |
| 39 | + * Value: 设备标识(deviceKey) | |
| 40 | + */ | |
| 41 | + private final Map<String, String> socketIdToDeviceKey = new ConcurrentHashMap<>(); | |
| 42 | + | |
| 43 | + /** | |
| 44 | + * 存储设备订阅的主题 | |
| 45 | + * Key: 设备标识(deviceKey) | |
| 46 | + * Value: 订阅的主题集合 | |
| 47 | + */ | |
| 48 | + private final Map<String, Set<String>> deviceSubscriptions = new ConcurrentHashMap<>(); | |
| 49 | + | |
| 50 | + /** | |
| 51 | + * 添加连接 | |
| 52 | + * | |
| 53 | + * @param deviceKey 设备标识 | |
| 54 | + * @param socket WebSocket 连接 | |
| 55 | + * @param socketId Socket ID(UUID) | |
| 56 | + */ | |
| 57 | + public void addConnection(String deviceKey, ServerWebSocket socket, String socketId) { | |
| 58 | + connections.put(deviceKey, socket); | |
| 59 | + deviceKeyToSocketId.put(deviceKey, socketId); | |
| 60 | + socketIdToDeviceKey.put(socketId, deviceKey); | |
| 61 | + log.info("[addConnection][设备连接已添加,deviceKey: {},socketId: {},当前连接数: {}]", | |
| 62 | + deviceKey, socketId, connections.size()); | |
| 63 | + } | |
| 64 | + | |
| 65 | + /** | |
| 66 | + * 移除连接 | |
| 67 | + * | |
| 68 | + * @param deviceKey 设备标识 | |
| 69 | + */ | |
| 70 | + public void removeConnection(String deviceKey) { | |
| 71 | + ServerWebSocket socket = connections.remove(deviceKey); | |
| 72 | + String socketId = deviceKeyToSocketId.remove(deviceKey); | |
| 73 | + if (socketId != null) { | |
| 74 | + socketIdToDeviceKey.remove(socketId); | |
| 75 | + } | |
| 76 | + if (socket != null) { | |
| 77 | + log.info("[removeConnection][设备连接已移除,deviceKey: {},socketId: {},当前连接数: {}]", | |
| 78 | + deviceKey, socketId, connections.size()); | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + /** | |
| 83 | + * 根据 Socket ID 移除连接 | |
| 84 | + * | |
| 85 | + * @param socketId WebSocket 文本框架 ID | |
| 86 | + */ | |
| 87 | + public void removeConnectionBySocketId(String socketId) { | |
| 88 | + String deviceKey = socketIdToDeviceKey.remove(socketId); | |
| 89 | + if (deviceKey != null) { | |
| 90 | + connections.remove(deviceKey); | |
| 91 | + log.info("[removeConnectionBySocketId][设备连接已移除,socketId: {},deviceKey: {},当前连接数: {}]", | |
| 92 | + socketId, deviceKey, connections.size()); | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + /** | |
| 97 | + * 获取连接 | |
| 98 | + * | |
| 99 | + * @param deviceKey 设备标识 | |
| 100 | + * @return WebSocket 连接 | |
| 101 | + */ | |
| 102 | + public ServerWebSocket getConnection(String deviceKey) { | |
| 103 | + return connections.get(deviceKey); | |
| 104 | + } | |
| 105 | + | |
| 106 | + /** | |
| 107 | + * 根据 Socket ID 获取设备标识 | |
| 108 | + * | |
| 109 | + * @param socketId WebSocket 文本框架 ID | |
| 110 | + * @return 设备标识 | |
| 111 | + */ | |
| 112 | + public String getDeviceKeyBySocketId(String socketId) { | |
| 113 | + return socketIdToDeviceKey.get(socketId); | |
| 114 | + } | |
| 115 | + | |
| 116 | + /** | |
| 117 | + * 检查设备是否在线 | |
| 118 | + * | |
| 119 | + * @param deviceKey 设备标识 | |
| 120 | + * @return 是否在线 | |
| 121 | + */ | |
| 122 | + public boolean isOnline(String deviceKey) { | |
| 123 | + return connections.containsKey(deviceKey); | |
| 124 | + } | |
| 125 | + | |
| 126 | + /** | |
| 127 | + * 获取当前连接数 | |
| 128 | + * | |
| 129 | + * @return 连接数 | |
| 130 | + */ | |
| 131 | + public int getConnectionCount() { | |
| 132 | + return connections.size(); | |
| 133 | + } | |
| 134 | + | |
| 135 | + /** | |
| 136 | + * 关闭所有连接 | |
| 137 | + */ | |
| 138 | + public void closeAllConnections() { | |
| 139 | + connections.forEach((deviceKey, socket) -> { | |
| 140 | + try { | |
| 141 | + socket.close(); | |
| 142 | + log.info("[closeAllConnections][关闭设备连接,deviceKey: {}]", deviceKey); | |
| 143 | + } catch (Exception e) { | |
| 144 | + log.error("[closeAllConnections][关闭设备连接失败,deviceKey: {}]", deviceKey, e); | |
| 145 | + } | |
| 146 | + }); | |
| 147 | + connections.clear(); | |
| 148 | + deviceKeyToSocketId.clear(); | |
| 149 | + socketIdToDeviceKey.clear(); | |
| 150 | + deviceSubscriptions.clear(); | |
| 151 | + log.info("[closeAllConnections][所有连接已关闭]"); | |
| 152 | + } | |
| 153 | + | |
| 154 | + // ==================== 订阅管理方法 ==================== | |
| 155 | + | |
| 156 | + /** | |
| 157 | + * 添加订阅 | |
| 158 | + * | |
| 159 | + * @param deviceKey 设备标识 | |
| 160 | + * @param topic 订阅主题 | |
| 161 | + */ | |
| 162 | + public void addSubscription(String deviceKey, String topic) { | |
| 163 | + deviceSubscriptions.computeIfAbsent(deviceKey, k -> new CopyOnWriteArraySet<>()).add(topic); | |
| 164 | + log.debug("[addSubscription][设备订阅主题,deviceKey: {},topic: {}]", deviceKey, topic); | |
| 165 | + } | |
| 166 | + | |
| 167 | + /** | |
| 168 | + * 移除订阅 | |
| 169 | + * | |
| 170 | + * @param deviceKey 设备标识 | |
| 171 | + * @param topic 订阅主题 | |
| 172 | + */ | |
| 173 | + public void removeSubscription(String deviceKey, String topic) { | |
| 174 | + Set<String> topics = deviceSubscriptions.get(deviceKey); | |
| 175 | + if (topics != null) { | |
| 176 | + topics.remove(topic); | |
| 177 | + log.debug("[removeSubscription][设备取消订阅,deviceKey: {},topic: {}]", deviceKey, topic); | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + /** | |
| 182 | + * 检查设备是否订阅了指定主题 | |
| 183 | + * 支持 MQTT 通配符匹配(+ 和 #) | |
| 184 | + * | |
| 185 | + * @param deviceKey 设备标识 | |
| 186 | + * @param topic 发布主题 | |
| 187 | + * @return 是否匹配 | |
| 188 | + */ | |
| 189 | + public boolean isSubscribed(String deviceKey, String topic) { | |
| 190 | + Set<String> subscriptions = deviceSubscriptions.get(deviceKey); | |
| 191 | + if (CollUtil.isEmpty(subscriptions)) { | |
| 192 | + return false; | |
| 193 | + } | |
| 194 | + | |
| 195 | + // 检查是否有匹配的订阅 | |
| 196 | + for (String subscription : subscriptions) { | |
| 197 | + if (topicMatches(subscription, topic)) { | |
| 198 | + return true; | |
| 199 | + } | |
| 200 | + } | |
| 201 | + return false; | |
| 202 | + } | |
| 203 | + | |
| 204 | + /** | |
| 205 | + * 获取设备的所有订阅 | |
| 206 | + * | |
| 207 | + * @param deviceKey 设备标识 | |
| 208 | + * @return 订阅主题集合 | |
| 209 | + */ | |
| 210 | + public Set<String> getSubscriptions(String deviceKey) { | |
| 211 | + return deviceSubscriptions.get(deviceKey); | |
| 212 | + } | |
| 213 | + | |
| 214 | + // TODO @haohao:这个方法,是不是也可以考虑抽到 IotMqttTopicUtils 里面去哈;感觉更简洁一点? | |
| 215 | + /** | |
| 216 | + * MQTT 主题匹配 | |
| 217 | + * 支持通配符: | |
| 218 | + * - +:匹配单层主题 | |
| 219 | + * - #:匹配多层主题(必须在末尾) | |
| 220 | + * | |
| 221 | + * @param subscription 订阅主题(可能包含通配符) | |
| 222 | + * @param topic 发布主题(不包含通配符) | |
| 223 | + * @return 是否匹配 | |
| 224 | + */ | |
| 225 | + private boolean topicMatches(String subscription, String topic) { | |
| 226 | + // 完全匹配 | |
| 227 | + if (subscription.equals(topic)) { | |
| 228 | + return true; | |
| 229 | + } | |
| 230 | + | |
| 231 | + // 不包含通配符 | |
| 232 | + // TODO @haohao:这里要不要枚举下哈;+ # | |
| 233 | + if (!subscription.contains("+") && !subscription.contains("#")) { | |
| 234 | + return false; | |
| 235 | + } | |
| 236 | + | |
| 237 | + String[] subscriptionParts = subscription.split("/"); | |
| 238 | + String[] topicParts = topic.split("/"); | |
| 239 | + int i = 0; | |
| 240 | + for (; i < subscriptionParts.length && i < topicParts.length; i++) { | |
| 241 | + String subPart = subscriptionParts[i]; | |
| 242 | + String topicPart = topicParts[i]; | |
| 243 | + | |
| 244 | + // # 匹配剩余所有层级,且必须在末尾 | |
| 245 | + if (subPart.equals("#")) { | |
| 246 | + return i == subscriptionParts.length - 1; | |
| 247 | + } | |
| 248 | + | |
| 249 | + // 不是通配符且不匹配 | |
| 250 | + if (!subPart.equals("+") && !subPart.equals(topicPart)) { | |
| 251 | + return false; | |
| 252 | + } | |
| 253 | + } | |
| 254 | + | |
| 255 | + // 检查是否都匹配完 | |
| 256 | + return i == subscriptionParts.length && i == topicParts.length; | |
| 257 | + } | |
| 258 | + | |
| 259 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/package-info.java
0 → 100644
| 1 | +/** | |
| 2 | + * IoT 网关 MQTT WebSocket 协议实现 | |
| 3 | + * <p> | |
| 4 | + * 基于 Vert.x 实现 MQTT over WebSocket 服务端,支持: | |
| 5 | + * - 标准 MQTT 3.1.1 协议 | |
| 6 | + * - WebSocket 协议升级 | |
| 7 | + * - SSL/TLS 加密(wss://) | |
| 8 | + * - 设备认证与连接管理 | |
| 9 | + * - QoS 0/1/2 消息质量保证 | |
| 10 | + * - 双向消息通信(上行/下行) | |
| 11 | + * | |
| 12 | + * @author 芋道源码 | |
| 13 | + */ | |
| 14 | +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws; | |
| 15 | + | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsDownstreamHandler.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.StrUtil; | |
| 4 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; | |
| 5 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 6 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils; | |
| 7 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager; | |
| 8 | +import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService; | |
| 9 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 10 | +import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils; | |
| 11 | +import io.vertx.core.buffer.Buffer; | |
| 12 | +import io.vertx.core.http.ServerWebSocket; | |
| 13 | +import lombok.extern.slf4j.Slf4j; | |
| 14 | + | |
| 15 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * IoT MQTT WebSocket 下行消息处理器 | |
| 19 | + * <p> | |
| 20 | + * 处理从消息总线发送到设备的消息,包括: | |
| 21 | + * - 属性设置 | |
| 22 | + * - 服务调用 | |
| 23 | + * - 事件通知 | |
| 24 | + * | |
| 25 | + * @author 芋道源码 | |
| 26 | + */ | |
| 27 | +@Slf4j | |
| 28 | +public class IotMqttWsDownstreamHandler { | |
| 29 | + | |
| 30 | + private final IotDeviceMessageService deviceMessageService; | |
| 31 | + | |
| 32 | + private final IotDeviceService deviceService; | |
| 33 | + | |
| 34 | + private final IotMqttWsConnectionManager connectionManager; | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * 消息 ID 生成器(用于发布消息) | |
| 38 | + */ | |
| 39 | + private final AtomicInteger messageIdGenerator = new AtomicInteger(1); | |
| 40 | + | |
| 41 | + public IotMqttWsDownstreamHandler(IotDeviceMessageService deviceMessageService, | |
| 42 | + IotDeviceService deviceService, | |
| 43 | + IotMqttWsConnectionManager connectionManager) { | |
| 44 | + this.deviceMessageService = deviceMessageService; | |
| 45 | + this.deviceService = deviceService; | |
| 46 | + this.connectionManager = connectionManager; | |
| 47 | + } | |
| 48 | + | |
| 49 | + /** | |
| 50 | + * 处理下行消息 | |
| 51 | + * | |
| 52 | + * @param message 设备消息 | |
| 53 | + * @return 是否处理成功 | |
| 54 | + */ | |
| 55 | + public boolean handleDownstreamMessage(IotDeviceMessage message) { | |
| 56 | + try { | |
| 57 | + // 1. 基础校验 | |
| 58 | + if (message == null || message.getDeviceId() == null) { | |
| 59 | + log.warn("[handleDownstreamMessage][消息或设备 ID 为空,忽略处理]"); | |
| 60 | + return false; | |
| 61 | + } | |
| 62 | + | |
| 63 | + // 2. 获取设备信息 | |
| 64 | + IotDeviceRespDTO deviceInfo = deviceService.getDeviceFromCache(message.getDeviceId()); | |
| 65 | + if (deviceInfo == null) { | |
| 66 | + log.warn("[handleDownstreamMessage][设备不存在,设备 ID:{}]", message.getDeviceId()); | |
| 67 | + return false; | |
| 68 | + } | |
| 69 | + | |
| 70 | + // 3. 构建设备标识 | |
| 71 | + String deviceKey = deviceInfo.getProductKey() + ":" + deviceInfo.getDeviceName(); | |
| 72 | + | |
| 73 | + // 4. 检查设备是否在线 | |
| 74 | + if (!connectionManager.isOnline(deviceKey)) { | |
| 75 | + log.warn("[handleDownstreamMessage][设备离线,无法发送消息,deviceKey: {}]", deviceKey); | |
| 76 | + return false; | |
| 77 | + } | |
| 78 | + | |
| 79 | + // 5. 构建主题 | |
| 80 | + String topic = buildDownstreamTopic(message, deviceInfo); | |
| 81 | + if (StrUtil.isBlank(topic)) { | |
| 82 | + log.warn("[handleDownstreamMessage][主题构建失败,设备 ID:{},方法:{}]", | |
| 83 | + message.getDeviceId(), message.getMethod()); | |
| 84 | + return false; | |
| 85 | + } | |
| 86 | + | |
| 87 | + // 6. 检查设备是否订阅了该主题 | |
| 88 | + if (!connectionManager.isSubscribed(deviceKey, topic)) { | |
| 89 | + log.warn("[handleDownstreamMessage][设备未订阅该主题,deviceKey: {},topic: {}]", deviceKey, topic); | |
| 90 | + return false; | |
| 91 | + } | |
| 92 | + | |
| 93 | + // 8. 编码消息 | |
| 94 | + byte[] payload = deviceMessageService.encodeDeviceMessage(message, | |
| 95 | + deviceInfo.getProductKey(), deviceInfo.getDeviceName()); | |
| 96 | + if (payload == null || payload.length == 0) { | |
| 97 | + log.warn("[handleDownstreamMessage][消息编码失败,设备 ID:{}]", message.getDeviceId()); | |
| 98 | + return false; | |
| 99 | + } | |
| 100 | + | |
| 101 | + // 9. 发送消息到设备 | |
| 102 | + return sendMessageToDevice(deviceKey, topic, payload, 1); | |
| 103 | + } catch (Exception e) { | |
| 104 | + if (message != null) { | |
| 105 | + log.error("[handleDownstreamMessage][处理下行消息异常,设备 ID:{},错误:{}]", | |
| 106 | + message.getDeviceId(), e.getMessage(), e); | |
| 107 | + } | |
| 108 | + return false; | |
| 109 | + } | |
| 110 | + } | |
| 111 | + | |
| 112 | + /** | |
| 113 | + * 构建下行消息主题 | |
| 114 | + * | |
| 115 | + * @param message 设备消息 | |
| 116 | + * @param deviceInfo 设备信息 | |
| 117 | + * @return 主题 | |
| 118 | + */ | |
| 119 | + private String buildDownstreamTopic(IotDeviceMessage message, IotDeviceRespDTO deviceInfo) { | |
| 120 | + String method = message.getMethod(); | |
| 121 | + if (StrUtil.isBlank(method)) { | |
| 122 | + return null; | |
| 123 | + } | |
| 124 | + | |
| 125 | + // 使用工具类构建主题,支持回复消息处理 | |
| 126 | + boolean isReply = IotDeviceMessageUtils.isReplyMessage(message); | |
| 127 | + return IotMqttTopicUtils.buildTopicByMethod(method, deviceInfo.getProductKey(), | |
| 128 | + deviceInfo.getDeviceName(), isReply); | |
| 129 | + } | |
| 130 | + | |
| 131 | + /** | |
| 132 | + * 发送消息到设备 | |
| 133 | + * | |
| 134 | + * @param deviceKey 设备标识(productKey:deviceName) | |
| 135 | + * @param topic 主题 | |
| 136 | + * @param payload 消息内容 | |
| 137 | + * @param qos QoS 级别 | |
| 138 | + * @return 是否发送成功 | |
| 139 | + */ | |
| 140 | + private boolean sendMessageToDevice(String deviceKey, String topic, byte[] payload, int qos) { | |
| 141 | + // 获取设备连接 | |
| 142 | + ServerWebSocket socket = connectionManager.getConnection(deviceKey); | |
| 143 | + if (socket == null) { | |
| 144 | + log.warn("[sendMessageToDevice][设备未连接,deviceKey: {}]", deviceKey); | |
| 145 | + return false; | |
| 146 | + } | |
| 147 | + | |
| 148 | + try { | |
| 149 | + int messageId = qos > 0 ? generateMessageId() : 0; | |
| 150 | + | |
| 151 | + // 手动编码 MQTT PUBLISH 消息 | |
| 152 | + io.netty.buffer.ByteBuf byteBuf = io.netty.buffer.Unpooled.buffer(); | |
| 153 | + | |
| 154 | + // 固定头:消息类型(PUBLISH=3) + DUP(0) + QoS + RETAIN | |
| 155 | + int fixedHeaderByte1 = 0x30 | (qos << 1); // PUBLISH类型 | |
| 156 | + byteBuf.writeByte(fixedHeaderByte1); | |
| 157 | + | |
| 158 | + // 计算剩余长度 | |
| 159 | + int topicLength = topic.getBytes().length; | |
| 160 | + int remainingLength = 2 + topicLength + (qos > 0 ? 2 : 0) + payload.length; | |
| 161 | + | |
| 162 | + // 写入剩余长度(简化版本,假设小于 128 字节) | |
| 163 | + if (remainingLength < 128) { | |
| 164 | + byteBuf.writeByte(remainingLength); | |
| 165 | + } else { | |
| 166 | + // 处理大于 127 的情况 | |
| 167 | + int x = remainingLength; | |
| 168 | + do { | |
| 169 | + int encodedByte = x % 128; | |
| 170 | + x = x / 128; | |
| 171 | + if (x > 0) { | |
| 172 | + encodedByte = encodedByte | 128; | |
| 173 | + } | |
| 174 | + byteBuf.writeByte(encodedByte); | |
| 175 | + } while (x > 0); | |
| 176 | + } | |
| 177 | + | |
| 178 | + // 可变头:主题名称 | |
| 179 | + byteBuf.writeShort(topicLength); | |
| 180 | + byteBuf.writeBytes(topic.getBytes()); | |
| 181 | + | |
| 182 | + // 可变头:消息 ID(仅 QoS > 0 时) | |
| 183 | + if (qos > 0) { | |
| 184 | + byteBuf.writeShort(messageId); | |
| 185 | + } | |
| 186 | + | |
| 187 | + // 有效载荷 | |
| 188 | + byteBuf.writeBytes(payload); | |
| 189 | + | |
| 190 | + // 发送 | |
| 191 | + byte[] bytes = new byte[byteBuf.readableBytes()]; | |
| 192 | + byteBuf.readBytes(bytes); | |
| 193 | + byteBuf.release(); | |
| 194 | + socket.writeBinaryMessage(Buffer.buffer(bytes)); | |
| 195 | + | |
| 196 | + log.info("[sendMessageToDevice][消息已发送到设备,deviceKey: {},topic: {},qos: {},messageId: {}]", | |
| 197 | + deviceKey, topic, qos, messageId); | |
| 198 | + return true; | |
| 199 | + } catch (Exception e) { | |
| 200 | + log.error("[sendMessageToDevice][发送消息到设备失败,deviceKey: {},topic: {}]", deviceKey, topic, e); | |
| 201 | + return false; | |
| 202 | + } | |
| 203 | + } | |
| 204 | + | |
| 205 | + /** | |
| 206 | + * 生成消息 ID | |
| 207 | + * | |
| 208 | + * @return 消息 ID | |
| 209 | + */ | |
| 210 | + private int generateMessageId() { | |
| 211 | + int id = messageIdGenerator.getAndIncrement(); | |
| 212 | + // MQTT 消息 ID 范围是 1-65535 | |
| 213 | + // TODO @haohao:并发可能有问题; | |
| 214 | + if (id > 65535) { | |
| 215 | + messageIdGenerator.set(1); | |
| 216 | + return 1; | |
| 217 | + } | |
| 218 | + return id; | |
| 219 | + } | |
| 220 | + | |
| 221 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/java/cn/iocoder/yudao/module/iot/gateway/protocol/mqttws/router/IotMqttWsUpstreamHandler.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.router; | |
| 2 | + | |
| 3 | +import cn.hutool.core.util.BooleanUtil; | |
| 4 | +import cn.hutool.core.util.IdUtil; | |
| 5 | +import cn.hutool.core.util.StrUtil; | |
| 6 | +import cn.hutool.extra.spring.SpringUtil; | |
| 7 | +import cn.iocoder.yudao.framework.common.pojo.CommonResult; | |
| 8 | +import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi; | |
| 9 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO; | |
| 10 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO; | |
| 11 | +import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO; | |
| 12 | +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; | |
| 13 | +import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils; | |
| 14 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.IotMqttWsUpstreamProtocol; | |
| 15 | +import cn.iocoder.yudao.module.iot.gateway.protocol.mqttws.manager.IotMqttWsConnectionManager; | |
| 16 | +import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService; | |
| 17 | +import io.netty.buffer.ByteBuf; | |
| 18 | +import io.netty.buffer.Unpooled; | |
| 19 | +import io.netty.channel.embedded.EmbeddedChannel; | |
| 20 | +import io.netty.handler.codec.DecoderException; | |
| 21 | +import io.netty.handler.codec.mqtt.*; | |
| 22 | +import io.vertx.core.buffer.Buffer; | |
| 23 | +import io.vertx.core.http.ServerWebSocket; | |
| 24 | +import lombok.extern.slf4j.Slf4j; | |
| 25 | + | |
| 26 | +import java.nio.charset.StandardCharsets; | |
| 27 | +import java.util.concurrent.ConcurrentHashMap; | |
| 28 | +import java.util.concurrent.atomic.AtomicInteger; | |
| 29 | + | |
| 30 | +/** | |
| 31 | + * IoT MQTT WebSocket 上行消息处理器 | |
| 32 | + * <p> | |
| 33 | + * 处理来自设备的 MQTT 消息,包括: | |
| 34 | + * - CONNECT:设备连接认证 | |
| 35 | + * - PUBLISH:设备发布消息 | |
| 36 | + * - SUBSCRIBE:设备订阅主题 | |
| 37 | + * - UNSUBSCRIBE:设备取消订阅 | |
| 38 | + * - PINGREQ:心跳请求 | |
| 39 | + * - DISCONNECT:设备断开连接 | |
| 40 | + * | |
| 41 | + * @author 芋道源码 | |
| 42 | + */ | |
| 43 | +@Slf4j | |
| 44 | +public class IotMqttWsUpstreamHandler { | |
| 45 | + | |
| 46 | + private final IotMqttWsUpstreamProtocol upstreamProtocol; | |
| 47 | + | |
| 48 | + private final IotDeviceCommonApi deviceApi; | |
| 49 | + | |
| 50 | + private final IotDeviceMessageService messageService; | |
| 51 | + | |
| 52 | + private final IotMqttWsConnectionManager connectionManager; | |
| 53 | + | |
| 54 | + /** | |
| 55 | + * 存储 WebSocket 连接到 Socket ID 的映射 | |
| 56 | + * Key: WebSocket 对象 | |
| 57 | + * Value: Socket ID(UUID) | |
| 58 | + */ | |
| 59 | + private final ConcurrentHashMap<ServerWebSocket, String> socketIdMap = new ConcurrentHashMap<>(); | |
| 60 | + | |
| 61 | + /** | |
| 62 | + * 存储 Socket ID 对应的设备信息 | |
| 63 | + * Key: Socket ID(UUID) | |
| 64 | + * Value: 设备信息 | |
| 65 | + */ | |
| 66 | + private final ConcurrentHashMap<String, IotDeviceRespDTO> socketDeviceMap = new ConcurrentHashMap<>(); | |
| 67 | + | |
| 68 | + /** | |
| 69 | + * 存储设备的消息 ID 生成器(用于 QoS > 0 的消息) | |
| 70 | + */ | |
| 71 | + private final ConcurrentHashMap<String, AtomicInteger> deviceMessageIdMap = new ConcurrentHashMap<>(); | |
| 72 | + | |
| 73 | + /** | |
| 74 | + * MQTT 解码通道(用于解析 WebSocket 中的 MQTT 二进制消息) | |
| 75 | + */ | |
| 76 | + private final ThreadLocal<EmbeddedChannel> decoderChannelThreadLocal = ThreadLocal | |
| 77 | + .withInitial(() -> new EmbeddedChannel(new MqttDecoder())); | |
| 78 | + | |
| 79 | + /** | |
| 80 | + * MQTT 编码通道(用于编码 MQTT 响应消息) | |
| 81 | + */ | |
| 82 | + private final ThreadLocal<EmbeddedChannel> encoderChannelThreadLocal = ThreadLocal | |
| 83 | + .withInitial(() -> new EmbeddedChannel(MqttEncoder.INSTANCE)); | |
| 84 | + | |
| 85 | + public IotMqttWsUpstreamHandler(IotMqttWsUpstreamProtocol upstreamProtocol, | |
| 86 | + IotDeviceMessageService messageService, | |
| 87 | + IotMqttWsConnectionManager connectionManager) { | |
| 88 | + this.upstreamProtocol = upstreamProtocol; | |
| 89 | + this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class); | |
| 90 | + this.messageService = messageService; | |
| 91 | + this.connectionManager = connectionManager; | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** | |
| 95 | + * 处理 WebSocket 连接 | |
| 96 | + * | |
| 97 | + * @param socket WebSocket 连接 | |
| 98 | + */ | |
| 99 | + public void handle(ServerWebSocket socket) { | |
| 100 | + // 生成唯一的 Socket ID(因为 MQTT 使用二进制协议,textHandlerID() 会返回 null) | |
| 101 | + String socketId = IdUtil.simpleUUID(); | |
| 102 | + socketIdMap.put(socket, socketId); | |
| 103 | + | |
| 104 | + log.info("[handle][WebSocket 连接建立,socketId: {},remoteAddress: {}]", | |
| 105 | + socketId, socket.remoteAddress()); | |
| 106 | + | |
| 107 | + // 设置二进制数据处理器 | |
| 108 | + socket.binaryMessageHandler(buffer -> { | |
| 109 | + try { | |
| 110 | + handleMqttMessage(socket, buffer); | |
| 111 | + } catch (Exception e) { | |
| 112 | + log.error("[handle][处理 MQTT 消息异常,socketId: {}]", socketId, e); | |
| 113 | + socket.close(); | |
| 114 | + } | |
| 115 | + }); | |
| 116 | + | |
| 117 | + // 设置关闭处理器 | |
| 118 | + socket.closeHandler(v -> { | |
| 119 | + socketIdMap.remove(socket); | |
| 120 | + IotDeviceRespDTO device = socketDeviceMap.remove(socketId); | |
| 121 | + if (device != null) { | |
| 122 | + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); | |
| 123 | + connectionManager.removeConnection(deviceKey); | |
| 124 | + deviceMessageIdMap.remove(deviceKey); | |
| 125 | + // 发送设备离线消息 | |
| 126 | + sendOfflineMessage(device); | |
| 127 | + log.info("[handle][WebSocket 连接关闭,deviceKey: {},socketId: {}]", deviceKey, socketId); | |
| 128 | + } | |
| 129 | + }); | |
| 130 | + | |
| 131 | + // 设置异常处理器 | |
| 132 | + socket.exceptionHandler(e -> { | |
| 133 | + log.error("[handle][WebSocket 连接异常,socketId: {}]", socketId, e); | |
| 134 | + socketIdMap.remove(socket); | |
| 135 | + IotDeviceRespDTO device = socketDeviceMap.remove(socketId); | |
| 136 | + if (device != null) { | |
| 137 | + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); | |
| 138 | + connectionManager.removeConnection(deviceKey); | |
| 139 | + deviceMessageIdMap.remove(deviceKey); | |
| 140 | + } | |
| 141 | + socket.close(); | |
| 142 | + }); | |
| 143 | + } | |
| 144 | + | |
| 145 | + /** | |
| 146 | + * 处理 MQTT 消息 | |
| 147 | + * | |
| 148 | + * @param socket WebSocket 连接 | |
| 149 | + * @param buffer 消息缓冲区 | |
| 150 | + */ | |
| 151 | + private void handleMqttMessage(ServerWebSocket socket, Buffer buffer) { | |
| 152 | + String socketId = socketIdMap.get(socket); | |
| 153 | + ByteBuf byteBuf = Unpooled.wrappedBuffer(buffer.getBytes()); | |
| 154 | + | |
| 155 | + try { | |
| 156 | + // 使用 EmbeddedChannel 解码 MQTT 消息 | |
| 157 | + EmbeddedChannel decoderChannel = decoderChannelThreadLocal.get(); | |
| 158 | + decoderChannel.writeInbound(byteBuf.retain()); | |
| 159 | + | |
| 160 | + // 读取解码后的消息 | |
| 161 | + MqttMessage mqttMessage = decoderChannel.readInbound(); | |
| 162 | + if (mqttMessage == null) { | |
| 163 | + log.warn("[handleMqttMessage][MQTT 消息解码失败,socketId: {}]", socketId); | |
| 164 | + return; | |
| 165 | + } | |
| 166 | + | |
| 167 | + MqttMessageType messageType = mqttMessage.fixedHeader().messageType(); | |
| 168 | + log.debug("[handleMqttMessage][收到 MQTT 消息,类型: {},socketId: {}]", messageType, socketId); | |
| 169 | + | |
| 170 | + // 根据消息类型分发处理 | |
| 171 | + switch (messageType) { | |
| 172 | + case CONNECT: | |
| 173 | + handleConnect(socket, (MqttConnectMessage) mqttMessage); | |
| 174 | + break; | |
| 175 | + case PUBLISH: | |
| 176 | + handlePublish(socket, (MqttPublishMessage) mqttMessage); | |
| 177 | + break; | |
| 178 | + case PUBACK: | |
| 179 | + handlePubAck(socket, mqttMessage); | |
| 180 | + break; | |
| 181 | + case PUBREC: | |
| 182 | + handlePubRec(socket, mqttMessage); | |
| 183 | + break; | |
| 184 | + case PUBREL: | |
| 185 | + handlePubRel(socket, mqttMessage); | |
| 186 | + break; | |
| 187 | + case PUBCOMP: | |
| 188 | + handlePubComp(socket, mqttMessage); | |
| 189 | + break; | |
| 190 | + case SUBSCRIBE: | |
| 191 | + handleSubscribe(socket, (MqttSubscribeMessage) mqttMessage); | |
| 192 | + break; | |
| 193 | + case UNSUBSCRIBE: | |
| 194 | + handleUnsubscribe(socket, (MqttUnsubscribeMessage) mqttMessage); | |
| 195 | + break; | |
| 196 | + case PINGREQ: | |
| 197 | + handlePingReq(socket); | |
| 198 | + break; | |
| 199 | + case DISCONNECT: | |
| 200 | + handleDisconnect(socket); | |
| 201 | + break; | |
| 202 | + default: | |
| 203 | + log.warn("[handleMqttMessage][不支持的消息类型: {},socketId: {}]", messageType, socketId); | |
| 204 | + } | |
| 205 | + } catch (DecoderException e) { | |
| 206 | + log.error("[handleMqttMessage][MQTT 消息解码异常,socketId: {}]", socketId, e); | |
| 207 | + socket.close(); | |
| 208 | + } catch (Exception e) { | |
| 209 | + log.error("[handleMqttMessage][处理 MQTT 消息失败,socketId: {}]", socketId, e); | |
| 210 | + socket.close(); | |
| 211 | + } finally { | |
| 212 | + byteBuf.release(); | |
| 213 | + } | |
| 214 | + } | |
| 215 | + | |
| 216 | + /** | |
| 217 | + * 处理 CONNECT 消息(设备认证) | |
| 218 | + */ | |
| 219 | + private void handleConnect(ServerWebSocket socket, MqttConnectMessage message) { | |
| 220 | + String socketId = socketIdMap.get(socket); | |
| 221 | + try { | |
| 222 | + // 1. 解析 CONNECT 消息 | |
| 223 | + MqttConnectPayload payload = message.payload(); | |
| 224 | + String clientId = payload.clientIdentifier(); | |
| 225 | + String username = payload.userName(); | |
| 226 | + String password = payload.passwordInBytes() != null | |
| 227 | + ? new String(payload.passwordInBytes(), StandardCharsets.UTF_8) | |
| 228 | + : null; | |
| 229 | + | |
| 230 | + log.info("[handleConnect][收到 CONNECT 消息,clientId: {},username: {},socketId: {}]", | |
| 231 | + clientId, username, socketId); | |
| 232 | + | |
| 233 | + // 2. 设备认证 | |
| 234 | + IotDeviceRespDTO device = authenticateDevice(clientId, username, password); | |
| 235 | + if (device == null) { | |
| 236 | + log.warn("[handleConnect][设备认证失败,clientId: {},socketId: {}]", clientId, socketId); | |
| 237 | + sendConnAck(socket, MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD); | |
| 238 | + socket.close(); | |
| 239 | + return; | |
| 240 | + } | |
| 241 | + | |
| 242 | + // 3. 保存设备信息 | |
| 243 | + socketDeviceMap.put(socketId, device); | |
| 244 | + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); | |
| 245 | + connectionManager.addConnection(deviceKey, socket, socketId); | |
| 246 | + deviceMessageIdMap.put(deviceKey, new AtomicInteger(1)); | |
| 247 | + | |
| 248 | + log.info("[handleConnect][设备认证成功,deviceId: {},deviceKey: {},socketId: {}]", | |
| 249 | + device.getId(), deviceKey, socketId); | |
| 250 | + | |
| 251 | + // 4. 发送 CONNACK | |
| 252 | + sendConnAck(socket, MqttConnectReturnCode.CONNECTION_ACCEPTED); | |
| 253 | + | |
| 254 | + // 5. 发送设备上线消息 | |
| 255 | + sendOnlineMessage(device); | |
| 256 | + } catch (Exception e) { | |
| 257 | + log.error("[handleConnect][处理 CONNECT 消息失败,socketId: {}]", socketId, e); | |
| 258 | + sendConnAck(socket, MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE); | |
| 259 | + socket.close(); | |
| 260 | + } | |
| 261 | + } | |
| 262 | + | |
| 263 | + /** | |
| 264 | + * 处理 PUBLISH 消息(设备发布消息) | |
| 265 | + */ | |
| 266 | + private void handlePublish(ServerWebSocket socket, MqttPublishMessage message) { | |
| 267 | + String socketId = socketIdMap.get(socket); | |
| 268 | + IotDeviceRespDTO device = socketDeviceMap.get(socketId); | |
| 269 | + | |
| 270 | + if (device == null) { | |
| 271 | + log.warn("[handlePublish][设备未认证,socketId: {}]", socketId); | |
| 272 | + socket.close(); | |
| 273 | + return; | |
| 274 | + } | |
| 275 | + | |
| 276 | + try { | |
| 277 | + // 1. 解析 PUBLISH 消息 | |
| 278 | + MqttFixedHeader fixedHeader = message.fixedHeader(); | |
| 279 | + MqttPublishVariableHeader variableHeader = message.variableHeader(); | |
| 280 | + ByteBuf payload = message.payload(); | |
| 281 | + | |
| 282 | + String topic = variableHeader.topicName(); | |
| 283 | + int messageId = variableHeader.packetId(); | |
| 284 | + MqttQoS qos = fixedHeader.qosLevel(); | |
| 285 | + | |
| 286 | + log.debug("[handlePublish][收到 PUBLISH 消息,topic: {},messageId: {},QoS: {},deviceId: {}]", | |
| 287 | + topic, messageId, qos, device.getId()); | |
| 288 | + | |
| 289 | + // 2. 读取 payload | |
| 290 | + byte[] payloadBytes = new byte[payload.readableBytes()]; | |
| 291 | + payload.readBytes(payloadBytes); | |
| 292 | + | |
| 293 | + // 3. 解码并发送消息 | |
| 294 | + IotDeviceMessage deviceMessage = messageService.decodeDeviceMessage(payloadBytes, | |
| 295 | + device.getProductKey(), device.getDeviceName()); | |
| 296 | + if (deviceMessage != null) { | |
| 297 | + deviceMessage.setServerId(upstreamProtocol.getServerId()); | |
| 298 | + messageService.sendDeviceMessage(deviceMessage, device.getProductKey(), | |
| 299 | + device.getDeviceName(), upstreamProtocol.getServerId()); | |
| 300 | + log.info("[handlePublish][设备消息已发送,method: {},deviceId: {}]", | |
| 301 | + deviceMessage.getMethod(), device.getId()); | |
| 302 | + } | |
| 303 | + | |
| 304 | + // 4. 根据 QoS 级别发送相应的确认消息 | |
| 305 | + if (qos == MqttQoS.AT_LEAST_ONCE) { | |
| 306 | + // QoS 1:发送 PUBACK | |
| 307 | + sendPubAck(socket, messageId); | |
| 308 | + } else if (qos == MqttQoS.EXACTLY_ONCE) { | |
| 309 | + // QoS 2:发送 PUBREC | |
| 310 | + sendPubRec(socket, messageId); | |
| 311 | + } | |
| 312 | + // QoS 0 无需确认 | |
| 313 | + } catch (Exception e) { | |
| 314 | + log.error("[handlePublish][处理 PUBLISH 消息失败,deviceId: {}]", device.getId(), e); | |
| 315 | + } | |
| 316 | + } | |
| 317 | + | |
| 318 | + /** | |
| 319 | + * 处理 PUBACK 消息(QoS 1 确认) | |
| 320 | + */ | |
| 321 | + private void handlePubAck(ServerWebSocket socket, MqttMessage message) { | |
| 322 | + String socketId = socketIdMap.get(socket); | |
| 323 | + IotDeviceRespDTO device = socketDeviceMap.get(socketId); | |
| 324 | + if (device == null) { | |
| 325 | + log.warn("[handlePubAck][设备未认证,socketId: {}]", socketId); | |
| 326 | + socket.close(); | |
| 327 | + return; | |
| 328 | + } | |
| 329 | + | |
| 330 | + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); | |
| 331 | + log.debug("[handlePubAck][收到 PUBACK,messageId: {},deviceId: {}]", messageId, device.getId()); | |
| 332 | + } | |
| 333 | + | |
| 334 | + /** | |
| 335 | + * 处理 PUBREC 消息(QoS 2 第一步确认) | |
| 336 | + */ | |
| 337 | + private void handlePubRec(ServerWebSocket socket, MqttMessage message) { | |
| 338 | + String socketId = socketIdMap.get(socket); | |
| 339 | + IotDeviceRespDTO device = socketDeviceMap.get(socketId); | |
| 340 | + if (device == null) { | |
| 341 | + log.warn("[handlePubRec][设备未认证,socketId: {}]", socketId); | |
| 342 | + socket.close(); | |
| 343 | + return; | |
| 344 | + } | |
| 345 | + | |
| 346 | + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); | |
| 347 | + log.debug("[handlePubRec][收到 PUBREC,messageId: {},deviceId: {}]", messageId, device.getId()); | |
| 348 | + // 发送 PUBREL | |
| 349 | + sendPubRel(socket, messageId); | |
| 350 | + } | |
| 351 | + | |
| 352 | + /** | |
| 353 | + * 处理 PUBREL 消息(QoS 2 第二步) | |
| 354 | + */ | |
| 355 | + private void handlePubRel(ServerWebSocket socket, MqttMessage message) { | |
| 356 | + String socketId = socketIdMap.get(socket); | |
| 357 | + IotDeviceRespDTO device = socketDeviceMap.get(socketId); | |
| 358 | + | |
| 359 | + if (device == null) { | |
| 360 | + log.warn("[handlePubRel][设备未认证,socketId: {}]", socketId); | |
| 361 | + socket.close(); | |
| 362 | + return; | |
| 363 | + } | |
| 364 | + | |
| 365 | + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); | |
| 366 | + log.debug("[handlePubRel][收到 PUBREL,messageId: {},deviceId: {}]", messageId, device.getId()); | |
| 367 | + // 发送 PUBCOMP | |
| 368 | + sendPubComp(socket, messageId); | |
| 369 | + } | |
| 370 | + | |
| 371 | + /** | |
| 372 | + * 处理 PUBCOMP 消息(QoS 2 完成确认) | |
| 373 | + */ | |
| 374 | + private void handlePubComp(ServerWebSocket socket, MqttMessage message) { | |
| 375 | + String socketId = socketIdMap.get(socket); | |
| 376 | + IotDeviceRespDTO device = socketDeviceMap.get(socketId); | |
| 377 | + | |
| 378 | + if (device == null) { | |
| 379 | + log.warn("[handlePubComp][设备未认证,socketId: {}]", socketId); | |
| 380 | + socket.close(); | |
| 381 | + return; | |
| 382 | + } | |
| 383 | + | |
| 384 | + int messageId = ((MqttMessageIdVariableHeader) message.variableHeader()).messageId(); | |
| 385 | + log.debug("[handlePubComp][收到 PUBCOMP,messageId: {},deviceId: {}]", messageId, device.getId()); | |
| 386 | + } | |
| 387 | + | |
| 388 | + /** | |
| 389 | + * 处理 SUBSCRIBE 消息(设备订阅主题) | |
| 390 | + */ | |
| 391 | + private void handleSubscribe(ServerWebSocket socket, MqttSubscribeMessage message) { | |
| 392 | + String socketId = socketIdMap.get(socket); | |
| 393 | + IotDeviceRespDTO device = socketDeviceMap.get(socketId); | |
| 394 | + if (device == null) { | |
| 395 | + log.warn("[handleSubscribe][设备未认证,socketId: {}]", socketId); | |
| 396 | + socket.close(); | |
| 397 | + return; | |
| 398 | + } | |
| 399 | + | |
| 400 | + try { | |
| 401 | + // 1. 解析 SUBSCRIBE 消息 | |
| 402 | + int messageId = message.variableHeader().messageId(); | |
| 403 | + MqttSubscribePayload payload = message.payload(); | |
| 404 | + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); | |
| 405 | + | |
| 406 | + log.info("[handleSubscribe][设备订阅请求,deviceKey: {},messageId: {},主题数量: {}]", | |
| 407 | + deviceKey, messageId, payload.topicSubscriptions().size()); | |
| 408 | + | |
| 409 | + // 2. 构建 QoS 列表并记录订阅信息 | |
| 410 | + int[] grantedQosList = new int[payload.topicSubscriptions().size()]; | |
| 411 | + for (int i = 0; i < payload.topicSubscriptions().size(); i++) { | |
| 412 | + MqttTopicSubscription subscription = payload.topicSubscriptions().get(i); | |
| 413 | + String topic = subscription.topicFilter(); | |
| 414 | + grantedQosList[i] = subscription.qualityOfService().value(); | |
| 415 | + | |
| 416 | + // 记录订阅信息到连接管理器 | |
| 417 | + connectionManager.addSubscription(deviceKey, topic); | |
| 418 | + | |
| 419 | + log.info("[handleSubscribe][订阅主题: {},QoS: {},deviceKey: {}]", | |
| 420 | + topic, subscription.qualityOfService(), deviceKey); | |
| 421 | + } | |
| 422 | + | |
| 423 | + // 3. 发送 SUBACK | |
| 424 | + sendSubAck(socket, messageId, grantedQosList); | |
| 425 | + } catch (Exception e) { | |
| 426 | + log.error("[handleSubscribe][处理 SUBSCRIBE 消息失败,deviceId: {}]", device.getId(), e); | |
| 427 | + } | |
| 428 | + } | |
| 429 | + | |
| 430 | + /** | |
| 431 | + * 处理 UNSUBSCRIBE 消息(设备取消订阅) | |
| 432 | + */ | |
| 433 | + private void handleUnsubscribe(ServerWebSocket socket, MqttUnsubscribeMessage message) { | |
| 434 | + String socketId = socketIdMap.get(socket); | |
| 435 | + IotDeviceRespDTO device = socketDeviceMap.get(socketId); | |
| 436 | + if (device == null) { | |
| 437 | + log.warn("[handleUnsubscribe][设备未认证,socketId: {}]", socketId); | |
| 438 | + socket.close(); | |
| 439 | + return; | |
| 440 | + } | |
| 441 | + | |
| 442 | + try { | |
| 443 | + // 1. 解析 UNSUBSCRIBE 消息 | |
| 444 | + int messageId = message.variableHeader().messageId(); | |
| 445 | + MqttUnsubscribePayload payload = message.payload(); | |
| 446 | + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); | |
| 447 | + | |
| 448 | + log.info("[handleUnsubscribe][设备取消订阅,deviceKey: {},messageId: {},主题数量: {}]", | |
| 449 | + deviceKey, messageId, payload.topics().size()); | |
| 450 | + | |
| 451 | + // 2. 移除订阅信息 | |
| 452 | + for (String topic : payload.topics()) { | |
| 453 | + connectionManager.removeSubscription(deviceKey, topic); | |
| 454 | + log.info("[handleUnsubscribe][取消订阅主题: {},deviceKey: {}]", topic, deviceKey); | |
| 455 | + } | |
| 456 | + | |
| 457 | + // 3. 发送 UNSUBACK | |
| 458 | + sendUnsubAck(socket, messageId); | |
| 459 | + } catch (Exception e) { | |
| 460 | + log.error("[handleUnsubscribe][处理 UNSUBSCRIBE 消息失败,deviceId: {}]", device.getId(), e); | |
| 461 | + } | |
| 462 | + } | |
| 463 | + | |
| 464 | + /** | |
| 465 | + * 处理 PINGREQ 消息(心跳请求) | |
| 466 | + */ | |
| 467 | + private void handlePingReq(ServerWebSocket socket) { | |
| 468 | + String socketId = socketIdMap.get(socket); | |
| 469 | + IotDeviceRespDTO device = socketDeviceMap.get(socketId); | |
| 470 | + if (device == null) { | |
| 471 | + log.warn("[handlePingReq][设备未认证,socketId: {}]", socketId); | |
| 472 | + socket.close(); | |
| 473 | + return; | |
| 474 | + } | |
| 475 | + | |
| 476 | + log.debug("[handlePingReq][收到心跳请求,deviceId: {}]", device.getId()); | |
| 477 | + // 发送 PINGRESP | |
| 478 | + sendPingResp(socket); | |
| 479 | + } | |
| 480 | + | |
| 481 | + /** | |
| 482 | + * 处理 DISCONNECT 消息(设备断开连接) | |
| 483 | + */ | |
| 484 | + private void handleDisconnect(ServerWebSocket socket) { | |
| 485 | + String socketId = socketIdMap.get(socket); | |
| 486 | + IotDeviceRespDTO device = socketDeviceMap.remove(socketId); | |
| 487 | + if (device != null) { | |
| 488 | + String deviceKey = device.getProductKey() + ":" + device.getDeviceName(); | |
| 489 | + connectionManager.removeConnection(deviceKey); | |
| 490 | + deviceMessageIdMap.remove(deviceKey); | |
| 491 | + sendOfflineMessage(device); | |
| 492 | + log.info("[handleDisconnect][设备主动断开连接,deviceKey: {}]", deviceKey); | |
| 493 | + } | |
| 494 | + | |
| 495 | + socket.close(); | |
| 496 | + } | |
| 497 | + | |
| 498 | + // ==================== 设备认证和状态相关方法 ==================== | |
| 499 | + | |
| 500 | + /** | |
| 501 | + * 设备认证 | |
| 502 | + */ | |
| 503 | + private IotDeviceRespDTO authenticateDevice(String clientId, String username, String password) { | |
| 504 | + try { | |
| 505 | + // 1. 参数校验 | |
| 506 | + if (StrUtil.hasEmpty(clientId, username, password)) { | |
| 507 | + log.warn("[authenticateDevice][认证参数不完整,clientId: {},username: {}]", clientId, username); | |
| 508 | + return null; | |
| 509 | + } | |
| 510 | + | |
| 511 | + // 2. 构建认证参数并调用 API | |
| 512 | + IotDeviceAuthReqDTO authParams = new IotDeviceAuthReqDTO() | |
| 513 | + .setClientId(clientId) | |
| 514 | + .setUsername(username) | |
| 515 | + .setPassword(password); | |
| 516 | + | |
| 517 | + CommonResult<Boolean> authResult = deviceApi.authDevice(authParams); | |
| 518 | + if (!authResult.isSuccess() || !BooleanUtil.isTrue(authResult.getData())) { | |
| 519 | + log.warn("[authenticateDevice][设备认证失败,clientId: {}]", clientId); | |
| 520 | + return null; | |
| 521 | + } | |
| 522 | + | |
| 523 | + // 3. 获取设备信息 | |
| 524 | + IotDeviceAuthUtils.DeviceInfo deviceInfo = IotDeviceAuthUtils.parseUsername(username); | |
| 525 | + if (deviceInfo == null) { | |
| 526 | + log.warn("[authenticateDevice][用户名格式不正确,username: {}]", username); | |
| 527 | + return null; | |
| 528 | + } | |
| 529 | + | |
| 530 | + IotDeviceGetReqDTO getReqDTO = new IotDeviceGetReqDTO() | |
| 531 | + .setProductKey(deviceInfo.getProductKey()) | |
| 532 | + .setDeviceName(deviceInfo.getDeviceName()); | |
| 533 | + | |
| 534 | + CommonResult<IotDeviceRespDTO> deviceResult = deviceApi.getDevice(getReqDTO); | |
| 535 | + if (!deviceResult.isSuccess() || deviceResult.getData() == null) { | |
| 536 | + log.warn("[authenticateDevice][获取设备信息失败,username: {}]", username); | |
| 537 | + return null; | |
| 538 | + } | |
| 539 | + | |
| 540 | + return deviceResult.getData(); | |
| 541 | + } catch (Exception e) { | |
| 542 | + log.error("[authenticateDevice][设备认证异常,clientId: {}]", clientId, e); | |
| 543 | + return null; | |
| 544 | + } | |
| 545 | + } | |
| 546 | + | |
| 547 | + /** | |
| 548 | + * 发送设备上线消息 | |
| 549 | + */ | |
| 550 | + private void sendOnlineMessage(IotDeviceRespDTO device) { | |
| 551 | + try { | |
| 552 | + IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline(); | |
| 553 | + messageService.sendDeviceMessage(onlineMessage, device.getProductKey(), | |
| 554 | + device.getDeviceName(), upstreamProtocol.getServerId()); | |
| 555 | + log.info("[sendOnlineMessage][设备上线,deviceId: {}]", device.getId()); | |
| 556 | + } catch (Exception e) { | |
| 557 | + log.error("[sendOnlineMessage][发送设备上线消息失败,deviceId: {}]", device.getId(), e); | |
| 558 | + } | |
| 559 | + } | |
| 560 | + | |
| 561 | + /** | |
| 562 | + * 发送设备离线消息 | |
| 563 | + */ | |
| 564 | + private void sendOfflineMessage(IotDeviceRespDTO device) { | |
| 565 | + try { | |
| 566 | + IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline(); | |
| 567 | + messageService.sendDeviceMessage(offlineMessage, device.getProductKey(), | |
| 568 | + device.getDeviceName(), upstreamProtocol.getServerId()); | |
| 569 | + log.info("[sendOfflineMessage][设备离线,deviceId: {}]", device.getId()); | |
| 570 | + } catch (Exception e) { | |
| 571 | + log.error("[sendOfflineMessage][发送设备离线消息失败,deviceId: {}]", device.getId(), e); | |
| 572 | + } | |
| 573 | + } | |
| 574 | + | |
| 575 | + // ==================== 发送响应消息的辅助方法 ==================== | |
| 576 | + | |
| 577 | + /** | |
| 578 | + * 发送 CONNACK 消息 | |
| 579 | + */ | |
| 580 | + private void sendConnAck(ServerWebSocket socket, MqttConnectReturnCode returnCode) { | |
| 581 | + try { | |
| 582 | + // 构建 CONNACK 消息 | |
| 583 | + MqttFixedHeader fixedHeader = new MqttFixedHeader( | |
| 584 | + MqttMessageType.CONNACK, false, MqttQoS.AT_MOST_ONCE, false, 0); | |
| 585 | + MqttConnAckVariableHeader variableHeader = new MqttConnAckVariableHeader(returnCode, false); | |
| 586 | + MqttConnAckMessage connAckMessage = new MqttConnAckMessage(fixedHeader, variableHeader); | |
| 587 | + | |
| 588 | + // 编码并发送 | |
| 589 | + sendMqttMessage(socket, connAckMessage); | |
| 590 | + log.debug("[sendConnAck][发送 CONNACK 消息,returnCode: {}]", returnCode); | |
| 591 | + } catch (Exception e) { | |
| 592 | + log.error("[sendConnAck][发送 CONNACK 消息失败]", e); | |
| 593 | + } | |
| 594 | + } | |
| 595 | + | |
| 596 | + /** | |
| 597 | + * 发送 PUBACK 消息(QoS 1 确认) | |
| 598 | + */ | |
| 599 | + private void sendPubAck(ServerWebSocket socket, int messageId) { | |
| 600 | + try { | |
| 601 | + // 构建 PUBACK 消息 | |
| 602 | + MqttFixedHeader fixedHeader = new MqttFixedHeader( | |
| 603 | + MqttMessageType.PUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0); | |
| 604 | + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); | |
| 605 | + MqttMessage pubAckMessage = new MqttMessage(fixedHeader, variableHeader); | |
| 606 | + | |
| 607 | + // 编码并发送 | |
| 608 | + sendMqttMessage(socket, pubAckMessage); | |
| 609 | + log.debug("[sendPubAck][发送 PUBACK 消息,messageId: {}]", messageId); | |
| 610 | + } catch (Exception e) { | |
| 611 | + log.error("[sendPubAck][发送 PUBACK 消息失败,messageId: {}]", messageId, e); | |
| 612 | + } | |
| 613 | + } | |
| 614 | + | |
| 615 | + /** | |
| 616 | + * 发送 PUBREC 消息(QoS 2 第一步确认) | |
| 617 | + */ | |
| 618 | + private void sendPubRec(ServerWebSocket socket, int messageId) { | |
| 619 | + try { | |
| 620 | + // 构建 PUBREC 消息 | |
| 621 | + MqttFixedHeader fixedHeader = new MqttFixedHeader( | |
| 622 | + MqttMessageType.PUBREC, false, MqttQoS.AT_MOST_ONCE, false, 0); | |
| 623 | + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); | |
| 624 | + MqttMessage pubRecMessage = new MqttMessage(fixedHeader, variableHeader); | |
| 625 | + | |
| 626 | + // 编码并发送 | |
| 627 | + sendMqttMessage(socket, pubRecMessage); | |
| 628 | + log.debug("[sendPubRec][发送 PUBREC 消息,messageId: {}]", messageId); | |
| 629 | + } catch (Exception e) { | |
| 630 | + log.error("[sendPubRec][发送 PUBREC 消息失败,messageId: {}]", messageId, e); | |
| 631 | + } | |
| 632 | + } | |
| 633 | + | |
| 634 | + /** | |
| 635 | + * 发送 PUBREL 消息(QoS 2 第二步) | |
| 636 | + */ | |
| 637 | + private void sendPubRel(ServerWebSocket socket, int messageId) { | |
| 638 | + try { | |
| 639 | + // 构建 PUBREL 消息 | |
| 640 | + MqttFixedHeader fixedHeader = new MqttFixedHeader( | |
| 641 | + MqttMessageType.PUBREL, false, MqttQoS.AT_LEAST_ONCE, false, 0); | |
| 642 | + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); | |
| 643 | + MqttMessage pubRelMessage = new MqttMessage(fixedHeader, variableHeader); | |
| 644 | + | |
| 645 | + // 编码并发送 | |
| 646 | + sendMqttMessage(socket, pubRelMessage); | |
| 647 | + log.debug("[sendPubRel][发送 PUBREL 消息,messageId: {}]", messageId); | |
| 648 | + } catch (Exception e) { | |
| 649 | + log.error("[sendPubRel][发送 PUBREL 消息失败,messageId: {}]", messageId, e); | |
| 650 | + } | |
| 651 | + } | |
| 652 | + | |
| 653 | + /** | |
| 654 | + * 发送 PUBCOMP 消息(QoS 2 完成确认) | |
| 655 | + */ | |
| 656 | + private void sendPubComp(ServerWebSocket socket, int messageId) { | |
| 657 | + try { | |
| 658 | + // 构建 PUBCOMP 消息 | |
| 659 | + MqttFixedHeader fixedHeader = new MqttFixedHeader( | |
| 660 | + MqttMessageType.PUBCOMP, false, MqttQoS.AT_MOST_ONCE, false, 0); | |
| 661 | + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); | |
| 662 | + MqttMessage pubCompMessage = new MqttMessage(fixedHeader, variableHeader); | |
| 663 | + | |
| 664 | + // 编码并发送 | |
| 665 | + sendMqttMessage(socket, pubCompMessage); | |
| 666 | + log.debug("[sendPubComp][发送 PUBCOMP 消息,messageId: {}]", messageId); | |
| 667 | + } catch (Exception e) { | |
| 668 | + log.error("[sendPubComp][发送 PUBCOMP 消息失败,messageId: {}]", messageId, e); | |
| 669 | + } | |
| 670 | + } | |
| 671 | + | |
| 672 | + /** | |
| 673 | + * 发送 SUBACK 消息 | |
| 674 | + */ | |
| 675 | + private void sendSubAck(ServerWebSocket socket, int messageId, int[] grantedQosList) { | |
| 676 | + try { | |
| 677 | + // 构建 SUBACK 消息 | |
| 678 | + MqttFixedHeader fixedHeader = new MqttFixedHeader( | |
| 679 | + MqttMessageType.SUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0); | |
| 680 | + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); | |
| 681 | + MqttSubAckPayload payload = new MqttSubAckPayload(grantedQosList); | |
| 682 | + MqttSubAckMessage subAckMessage = new MqttSubAckMessage(fixedHeader, variableHeader, payload); | |
| 683 | + | |
| 684 | + // 编码并发送 | |
| 685 | + sendMqttMessage(socket, subAckMessage); | |
| 686 | + log.debug("[sendSubAck][发送 SUBACK 消息,messageId: {},主题数量: {}]", messageId, grantedQosList.length); | |
| 687 | + } catch (Exception e) { | |
| 688 | + log.error("[sendSubAck][发送 SUBACK 消息失败,messageId: {}]", messageId, e); | |
| 689 | + } | |
| 690 | + } | |
| 691 | + | |
| 692 | + /** | |
| 693 | + * 发送 UNSUBACK 消息 | |
| 694 | + */ | |
| 695 | + private void sendUnsubAck(ServerWebSocket socket, int messageId) { | |
| 696 | + try { | |
| 697 | + // 构建 UNSUBACK 消息 | |
| 698 | + MqttFixedHeader fixedHeader = new MqttFixedHeader( | |
| 699 | + MqttMessageType.UNSUBACK, false, MqttQoS.AT_MOST_ONCE, false, 0); | |
| 700 | + MqttMessageIdVariableHeader variableHeader = MqttMessageIdVariableHeader.from(messageId); | |
| 701 | + MqttUnsubAckMessage unsubAckMessage = new MqttUnsubAckMessage(fixedHeader, variableHeader); | |
| 702 | + | |
| 703 | + // 编码并发送 | |
| 704 | + sendMqttMessage(socket, unsubAckMessage); | |
| 705 | + log.debug("[sendUnsubAck][发送 UNSUBACK 消息,messageId: {}]", messageId); | |
| 706 | + } catch (Exception e) { | |
| 707 | + log.error("[sendUnsubAck][发送 UNSUBACK 消息失败,messageId: {}]", messageId, e); | |
| 708 | + } | |
| 709 | + } | |
| 710 | + | |
| 711 | + /** | |
| 712 | + * 发送 PINGRESP 消息 | |
| 713 | + */ | |
| 714 | + private void sendPingResp(ServerWebSocket socket) { | |
| 715 | + try { | |
| 716 | + // 构建 PINGRESP 消息 | |
| 717 | + MqttFixedHeader fixedHeader = new MqttFixedHeader( | |
| 718 | + MqttMessageType.PINGRESP, false, MqttQoS.AT_MOST_ONCE, false, 0); | |
| 719 | + MqttMessage pingRespMessage = new MqttMessage(fixedHeader); | |
| 720 | + | |
| 721 | + // 编码并发送 | |
| 722 | + sendMqttMessage(socket, pingRespMessage); | |
| 723 | + log.debug("[sendPingResp][发送 PINGRESP 消息]"); | |
| 724 | + } catch (Exception e) { | |
| 725 | + log.error("[sendPingResp][发送 PINGRESP 消息失败]", e); | |
| 726 | + } | |
| 727 | + } | |
| 728 | + | |
| 729 | + /** | |
| 730 | + * 发送 MQTT 消息到 WebSocket | |
| 731 | + */ | |
| 732 | + private void sendMqttMessage(ServerWebSocket socket, MqttMessage mqttMessage) { | |
| 733 | + ByteBuf byteBuf = null; | |
| 734 | + try { | |
| 735 | + // 使用 EmbeddedChannel 编码 MQTT 消息 | |
| 736 | + EmbeddedChannel encoderChannel = encoderChannelThreadLocal.get(); | |
| 737 | + encoderChannel.writeOutbound(mqttMessage); | |
| 738 | + | |
| 739 | + // 读取编码后的 ByteBuf | |
| 740 | + byteBuf = encoderChannel.readOutbound(); | |
| 741 | + if (byteBuf != null) { | |
| 742 | + byte[] bytes = new byte[byteBuf.readableBytes()]; | |
| 743 | + byteBuf.readBytes(bytes); | |
| 744 | + socket.writeBinaryMessage(Buffer.buffer(bytes)); | |
| 745 | + } | |
| 746 | + } finally { | |
| 747 | + if (byteBuf != null) { | |
| 748 | + byteBuf.release(); | |
| 749 | + } | |
| 750 | + } | |
| 751 | + } | |
| 752 | + | |
| 753 | +} | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml
| ... | ... | @@ -99,11 +99,24 @@ yudao: |
| 99 | 99 | # 针对引入的 MQTT 组件的配置 |
| 100 | 100 | # ==================================== |
| 101 | 101 | mqtt: |
| 102 | - enabled: true | |
| 102 | + enabled: false | |
| 103 | 103 | port: 1883 |
| 104 | 104 | max-message-size: 8192 |
| 105 | 105 | connect-timeout-seconds: 60 |
| 106 | 106 | ssl-enabled: false |
| 107 | + # ==================================== | |
| 108 | + # 针对引入的 MQTT WebSocket 组件的配置 | |
| 109 | + # ==================================== | |
| 110 | + mqtt-ws: | |
| 111 | + enabled: false # 是否启用 MQTT WebSocket | |
| 112 | + port: 8083 # WebSocket 服务端口 | |
| 113 | + path: /mqtt # WebSocket 路径 | |
| 114 | + max-message-size: 8192 # 最大消息大小(字节) | |
| 115 | + max-frame-size: 65536 # 最大帧大小(字节) | |
| 116 | + connect-timeout-seconds: 60 # 连接超时时间(秒) | |
| 117 | + keep-alive-timeout-seconds: 300 # 保持连接超时时间(秒) | |
| 118 | + ssl-enabled: false # 是否启用 SSL(wss://) | |
| 119 | + sub-protocol: mqtt # WebSocket 子协议 | |
| 107 | 120 | |
| 108 | 121 | --- #################### 日志相关配置 #################### |
| 109 | 122 | |
| ... | ... | @@ -123,6 +136,7 @@ logging: |
| 123 | 136 | cn.iocoder.yudao.module.iot.gateway.protocol.emqx: DEBUG |
| 124 | 137 | cn.iocoder.yudao.module.iot.gateway.protocol.http: DEBUG |
| 125 | 138 | cn.iocoder.yudao.module.iot.gateway.protocol.mqtt: DEBUG |
| 139 | + cn.iocoder.yudao.module.iot.gateway.protocol.mqttws: DEBUG | |
| 126 | 140 | # 根日志级别 |
| 127 | 141 | root: INFO |
| 128 | 142 | ... | ... |
yudao-module-iot/yudao-module-iot-gateway/src/test/resources/mqtt-websocket-test-client.html
0 → 100644
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="zh-CN"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta content="width=device-width, initial-scale=1.0" name="viewport"> | |
| 6 | + <title>MQTT WebSocket 测试客户端</title> | |
| 7 | + <style> | |
| 8 | + * { | |
| 9 | + margin: 0; | |
| 10 | + padding: 0; | |
| 11 | + box-sizing: border-box; | |
| 12 | + } | |
| 13 | + | |
| 14 | + body { | |
| 15 | + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| 16 | + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| 17 | + padding: 20px; | |
| 18 | + min-height: 100vh; | |
| 19 | + } | |
| 20 | + | |
| 21 | + .container { | |
| 22 | + max-width: 1200px; | |
| 23 | + margin: 0 auto; | |
| 24 | + background: white; | |
| 25 | + border-radius: 10px; | |
| 26 | + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); | |
| 27 | + overflow: hidden; | |
| 28 | + } | |
| 29 | + | |
| 30 | + .header { | |
| 31 | + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| 32 | + color: white; | |
| 33 | + padding: 30px; | |
| 34 | + text-align: center; | |
| 35 | + } | |
| 36 | + | |
| 37 | + .header h1 { | |
| 38 | + font-size: 28px; | |
| 39 | + margin-bottom: 10px; | |
| 40 | + } | |
| 41 | + | |
| 42 | + .header p { | |
| 43 | + opacity: 0.9; | |
| 44 | + } | |
| 45 | + | |
| 46 | + .info-box { | |
| 47 | + background: #f8f9fa; | |
| 48 | + border-left: 4px solid #667eea; | |
| 49 | + padding: 15px; | |
| 50 | + margin: 20px; | |
| 51 | + border-radius: 5px; | |
| 52 | + } | |
| 53 | + | |
| 54 | + .info-box h3 { | |
| 55 | + color: #667eea; | |
| 56 | + margin-bottom: 10px; | |
| 57 | + font-size: 16px; | |
| 58 | + } | |
| 59 | + | |
| 60 | + .info-box ul { | |
| 61 | + margin-left: 20px; | |
| 62 | + color: #666; | |
| 63 | + font-size: 14px; | |
| 64 | + line-height: 1.6; | |
| 65 | + } | |
| 66 | + | |
| 67 | + .info-box code { | |
| 68 | + background: #e9ecef; | |
| 69 | + padding: 2px 6px; | |
| 70 | + border-radius: 3px; | |
| 71 | + font-family: 'Courier New', monospace; | |
| 72 | + color: #d63384; | |
| 73 | + } | |
| 74 | + | |
| 75 | + .content { | |
| 76 | + display: grid; | |
| 77 | + grid-template-columns: 1fr 1fr; | |
| 78 | + gap: 20px; | |
| 79 | + padding: 30px; | |
| 80 | + } | |
| 81 | + | |
| 82 | + .panel { | |
| 83 | + background: #f8f9fa; | |
| 84 | + border-radius: 8px; | |
| 85 | + padding: 20px; | |
| 86 | + } | |
| 87 | + | |
| 88 | + .panel h2 { | |
| 89 | + color: #667eea; | |
| 90 | + margin-bottom: 20px; | |
| 91 | + font-size: 20px; | |
| 92 | + border-bottom: 2px solid #667eea; | |
| 93 | + padding-bottom: 10px; | |
| 94 | + } | |
| 95 | + | |
| 96 | + .form-group { | |
| 97 | + margin-bottom: 15px; | |
| 98 | + } | |
| 99 | + | |
| 100 | + .form-group label { | |
| 101 | + display: block; | |
| 102 | + margin-bottom: 5px; | |
| 103 | + color: #333; | |
| 104 | + font-weight: 500; | |
| 105 | + } | |
| 106 | + | |
| 107 | + .form-group input, | |
| 108 | + .form-group select, | |
| 109 | + .form-group textarea { | |
| 110 | + width: 100%; | |
| 111 | + padding: 10px; | |
| 112 | + border: 1px solid #ddd; | |
| 113 | + border-radius: 4px; | |
| 114 | + font-size: 14px; | |
| 115 | + } | |
| 116 | + | |
| 117 | + .form-group input:focus, | |
| 118 | + .form-group select:focus, | |
| 119 | + .form-group textarea:focus { | |
| 120 | + outline: none; | |
| 121 | + border-color: #667eea; | |
| 122 | + } | |
| 123 | + | |
| 124 | + .form-group textarea { | |
| 125 | + resize: vertical; | |
| 126 | + min-height: 80px; | |
| 127 | + font-family: monospace; | |
| 128 | + } | |
| 129 | + | |
| 130 | + .btn { | |
| 131 | + padding: 10px 20px; | |
| 132 | + border: none; | |
| 133 | + border-radius: 4px; | |
| 134 | + cursor: pointer; | |
| 135 | + font-size: 14px; | |
| 136 | + font-weight: 500; | |
| 137 | + transition: all 0.3s; | |
| 138 | + } | |
| 139 | + | |
| 140 | + .btn-primary { | |
| 141 | + background: #667eea; | |
| 142 | + color: white; | |
| 143 | + } | |
| 144 | + | |
| 145 | + .btn-primary:hover { | |
| 146 | + background: #5568d3; | |
| 147 | + } | |
| 148 | + | |
| 149 | + .btn-success { | |
| 150 | + background: #28a745; | |
| 151 | + color: white; | |
| 152 | + } | |
| 153 | + | |
| 154 | + .btn-success:hover { | |
| 155 | + background: #218838; | |
| 156 | + } | |
| 157 | + | |
| 158 | + .btn-danger { | |
| 159 | + background: #dc3545; | |
| 160 | + color: white; | |
| 161 | + } | |
| 162 | + | |
| 163 | + .btn-danger:hover { | |
| 164 | + background: #c82333; | |
| 165 | + } | |
| 166 | + | |
| 167 | + .btn-warning { | |
| 168 | + background: #ffc107; | |
| 169 | + color: #333; | |
| 170 | + } | |
| 171 | + | |
| 172 | + .btn-warning:hover { | |
| 173 | + background: #e0a800; | |
| 174 | + } | |
| 175 | + | |
| 176 | + .btn-group { | |
| 177 | + display: flex; | |
| 178 | + gap: 10px; | |
| 179 | + margin-top: 15px; | |
| 180 | + } | |
| 181 | + | |
| 182 | + .status { | |
| 183 | + padding: 10px; | |
| 184 | + border-radius: 4px; | |
| 185 | + margin-bottom: 20px; | |
| 186 | + font-weight: 500; | |
| 187 | + } | |
| 188 | + | |
| 189 | + .status.disconnected { | |
| 190 | + background: #f8d7da; | |
| 191 | + color: #721c24; | |
| 192 | + } | |
| 193 | + | |
| 194 | + .status.connected { | |
| 195 | + background: #d4edda; | |
| 196 | + color: #155724; | |
| 197 | + } | |
| 198 | + | |
| 199 | + .status.connecting { | |
| 200 | + background: #fff3cd; | |
| 201 | + color: #856404; | |
| 202 | + } | |
| 203 | + | |
| 204 | + .log-area { | |
| 205 | + background: #1e1e1e; | |
| 206 | + color: #d4d4d4; | |
| 207 | + padding: 15px; | |
| 208 | + border-radius: 4px; | |
| 209 | + height: 400px; | |
| 210 | + overflow-y: auto; | |
| 211 | + font-family: 'Courier New', monospace; | |
| 212 | + font-size: 12px; | |
| 213 | + line-height: 1.6; | |
| 214 | + } | |
| 215 | + | |
| 216 | + .log-entry { | |
| 217 | + margin-bottom: 5px; | |
| 218 | + } | |
| 219 | + | |
| 220 | + .log-entry.info { | |
| 221 | + color: #4ec9b0; | |
| 222 | + } | |
| 223 | + | |
| 224 | + .log-entry.success { | |
| 225 | + color: #6a9955; | |
| 226 | + } | |
| 227 | + | |
| 228 | + .log-entry.error { | |
| 229 | + color: #f48771; | |
| 230 | + } | |
| 231 | + | |
| 232 | + .log-entry.warning { | |
| 233 | + color: #dcdcaa; | |
| 234 | + } | |
| 235 | + | |
| 236 | + .stats { | |
| 237 | + display: grid; | |
| 238 | + grid-template-columns: repeat(3, 1fr); | |
| 239 | + gap: 10px; | |
| 240 | + margin-top: 15px; | |
| 241 | + } | |
| 242 | + | |
| 243 | + .stat-item { | |
| 244 | + background: white; | |
| 245 | + padding: 15px; | |
| 246 | + border-radius: 4px; | |
| 247 | + text-align: center; | |
| 248 | + } | |
| 249 | + | |
| 250 | + .stat-item .value { | |
| 251 | + font-size: 24px; | |
| 252 | + font-weight: bold; | |
| 253 | + color: #667eea; | |
| 254 | + } | |
| 255 | + | |
| 256 | + .stat-item .label { | |
| 257 | + font-size: 12px; | |
| 258 | + color: #666; | |
| 259 | + margin-top: 5px; | |
| 260 | + } | |
| 261 | + | |
| 262 | + @media (max-width: 768px) { | |
| 263 | + .content { | |
| 264 | + grid-template-columns: 1fr; | |
| 265 | + } | |
| 266 | + } | |
| 267 | + </style> | |
| 268 | +</head> | |
| 269 | +<body> | |
| 270 | +<div class="container"> | |
| 271 | + <div class="header"> | |
| 272 | + <h1>🚀 MQTT WebSocket 测试客户端</h1> | |
| 273 | + <p>RuoYi-Vue-Pro IoT 模块 - MQTT over WebSocket 在线测试工具</p> | |
| 274 | + </div> | |
| 275 | + | |
| 276 | + <!-- 协议格式说明 --> | |
| 277 | + <div class="info-box"> | |
| 278 | + <h3>📌 标准协议格式说明</h3> | |
| 279 | + <ul> | |
| 280 | + <li><strong>Topic 格式:</strong><code>/sys/{productKey}/{deviceName}/thing/property/post</code></li> | |
| 281 | + <li><strong>Client ID 格式:</strong><code>{productKey}.{deviceName}</code> 例如:<code>zOXKLvHjUqTo7ipD.ceshi001</code> | |
| 282 | + </li> | |
| 283 | + <li><strong>Username 格式:</strong><code>{deviceName}&{productKey}</code> 例如:<code>ceshi001&zOXKLvHjUqTo7ipD</code> | |
| 284 | + </li> | |
| 285 | + <li><strong>消息格式(Alink 协议):</strong> | |
| 286 | + <pre style="background: #e9ecef; padding: 10px; border-radius: 5px; margin-top: 5px; overflow-x: auto;"> | |
| 287 | +{ | |
| 288 | + "id": "消息 ID(唯一标识)", | |
| 289 | + "version": "1.0", | |
| 290 | + "method": "thing.property.post", | |
| 291 | + "params": { | |
| 292 | + "temperature": 25.5, | |
| 293 | + "humidity": 60 | |
| 294 | + } | |
| 295 | +}</pre> | |
| 296 | + </li> | |
| 297 | + <li><strong>常用 Topic(下行 - 服务端推送):</strong> | |
| 298 | + <ul style="margin-top: 5px;"> | |
| 299 | + <li>属性设置:<code>/sys/{pk}/{dn}/thing/property/set</code></li> | |
| 300 | + <li>服务调用:<code>/sys/{pk}/{dn}/thing/service/invoke</code></li> | |
| 301 | + <li>配置推送:<code>/sys/{pk}/{dn}/thing/config/push</code></li> | |
| 302 | + <li>OTA 升级:<code>/sys/{pk}/{dn}/thing/ota/upgrade</code></li> | |
| 303 | + </ul> | |
| 304 | + </li> | |
| 305 | + <li><strong>常用 Topic(上行 - 设备上报):</strong> | |
| 306 | + <ul style="margin-top: 5px;"> | |
| 307 | + <li>状态更新:<code>/sys/{pk}/{dn}/thing/state/update</code></li> | |
| 308 | + <li>属性上报:<code>/sys/{pk}/{dn}/thing/property/post</code></li> | |
| 309 | + <li>事件上报:<code>/sys/{pk}/{dn}/thing/event/post</code></li> | |
| 310 | + <li>OTA 进度:<code>/sys/{pk}/{dn}/thing/ota/progress</code></li> | |
| 311 | + </ul> | |
| 312 | + </li> | |
| 313 | + </ul> | |
| 314 | + </div> | |
| 315 | + | |
| 316 | + <div class="content"> | |
| 317 | + <!-- 连接配置面板 --> | |
| 318 | + <div class="panel"> | |
| 319 | + <h2>📡 连接配置</h2> | |
| 320 | + | |
| 321 | + <div class="status disconnected" id="statusBar"> | |
| 322 | + ⚫ 未连接 | |
| 323 | + </div> | |
| 324 | + | |
| 325 | + <div class="form-group"> | |
| 326 | + <label>服务器地址</label> | |
| 327 | + <input id="serverUrl" placeholder="ws://host:port/path" type="text" value="ws://localhost:8083/mqtt"> | |
| 328 | + <small style="color: #666; font-size: 12px;">WebSocket 地址,支持 ws:// 和 wss://</small> | |
| 329 | + </div> | |
| 330 | + | |
| 331 | + <div class="form-group"> | |
| 332 | + <label>Client ID</label> | |
| 333 | + <input id="clientId" placeholder="设备客户端 ID" type="text" value="fqTn4Afs982Nak4N.jiali001"> | |
| 334 | + <small style="color: #666; font-size: 12px;">格式:{productKey}.{deviceName}</small> | |
| 335 | + </div> | |
| 336 | + | |
| 337 | + <div class="form-group"> | |
| 338 | + <label>Username</label> | |
| 339 | + <input id="username" placeholder="用户名" type="text" value="jiali001&fqTn4Afs982Nak4N"> | |
| 340 | + <small style="color: #666; font-size: 12px;">格式:{deviceName}&{productKey}</small> | |
| 341 | + </div> | |
| 342 | + | |
| 343 | + <div class="form-group"> | |
| 344 | + <label>Password</label> | |
| 345 | + <input id="password" placeholder="设备密钥" | |
| 346 | + type="password" value="ae10188f93febbb6b37bd57f463b2a795ae2800fab8933aef75d3c6422873f28"> | |
| 347 | + <small style="color: #666; font-size: 12px;">设备的认证密钥(Device Secret)</small> | |
| 348 | + </div> | |
| 349 | + | |
| 350 | + <div class="btn-group"> | |
| 351 | + <button class="btn btn-success" id="connectBtn" onclick="connect()">🔌 连接</button> | |
| 352 | + <button class="btn btn-danger" disabled id="disconnectBtn" onclick="disconnect()">🔌 断开</button> | |
| 353 | + <button class="btn btn-warning" onclick="clearLogs()">🗑️ 清空日志</button> | |
| 354 | + </div> | |
| 355 | + | |
| 356 | + <!-- 统计信息 --> | |
| 357 | + <div class="stats"> | |
| 358 | + <div class="stat-item"> | |
| 359 | + <div class="value" id="sentCount">0</div> | |
| 360 | + <div class="label">发送消息数</div> | |
| 361 | + </div> | |
| 362 | + <div class="stat-item"> | |
| 363 | + <div class="value" id="receivedCount">0</div> | |
| 364 | + <div class="label">接收消息数</div> | |
| 365 | + </div> | |
| 366 | + <div class="stat-item"> | |
| 367 | + <div class="value" id="errorCount">0</div> | |
| 368 | + <div class="label">错误次数</div> | |
| 369 | + </div> | |
| 370 | + </div> | |
| 371 | + </div> | |
| 372 | + | |
| 373 | + <!-- 消息发布面板 --> | |
| 374 | + <div class="panel"> | |
| 375 | + <h2>📤 消息发布</h2> | |
| 376 | + | |
| 377 | + <div class="form-group"> | |
| 378 | + <label>快捷主题选择(上行消息 - 设备 → 服务端)</label> | |
| 379 | + <select id="quickPublishTopicSelect" onchange="selectQuickPublishTopic()" | |
| 380 | + style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 5px;"> | |
| 381 | + <option value="">-- 选择上行消息类型 --</option> | |
| 382 | + <option value="thing.state.update">设备状态更新 (thing.state.update)</option> | |
| 383 | + <option value="thing.property.post">属性上报 (thing.property.post)</option> | |
| 384 | + <option value="thing.event.post">事件上报 (thing.event.post)</option> | |
| 385 | + <option value="thing.ota.progress">OTA 升级进度 (thing.ota.progress)</option> | |
| 386 | + </select> | |
| 387 | + </div> | |
| 388 | + | |
| 389 | + <div class="form-group"> | |
| 390 | + <label>主题 (Topic)</label> | |
| 391 | + <input id="pubTopic" placeholder="消息主题,格式:/sys/{productKey}/{deviceName}/thing/property/post" type="text" | |
| 392 | + value="/sys/fqTn4Afs982Nak4N/jiali001/thing/property/post"> | |
| 393 | + <small style="color: #666; font-size: 12px;">标准格式:/sys/{productKey}/{deviceName}/thing/property/post</small> | |
| 394 | + </div> | |
| 395 | + | |
| 396 | + <div class="form-group"> | |
| 397 | + <label>QoS 级别</label> | |
| 398 | + <select id="pubQos"> | |
| 399 | + <option value="0">0 - 最多一次</option> | |
| 400 | + <option selected value="1">1 - 至少一次</option> | |
| 401 | + <option value="2">2 - 刚好一次</option> | |
| 402 | + </select> | |
| 403 | + </div> | |
| 404 | + | |
| 405 | + <div class="form-group"> | |
| 406 | + <label>消息内容 (JSON - Alink 协议格式)</label> | |
| 407 | + <textarea id="pubMessage" placeholder='Alink 协议格式消息'>{ | |
| 408 | + "id": "123456789", | |
| 409 | + "version": "1.0", | |
| 410 | + "method": "thing.property.post", | |
| 411 | + "params": { | |
| 412 | + "temperature": 25.5, | |
| 413 | + "humidity": 60 | |
| 414 | + } | |
| 415 | +}</textarea> | |
| 416 | + <small style="color: #666; font-size: 12px;"> | |
| 417 | + Alink 协议格式:id(消息 ID)、version(协议版本)、method(方法)、params(参数) | |
| 418 | + </small> | |
| 419 | + </div> | |
| 420 | + | |
| 421 | + <div class="btn-group"> | |
| 422 | + <button class="btn btn-primary" onclick="publish()">📤 发布消息</button> | |
| 423 | + <button class="btn btn-success" onclick="publishSampleData()">📊 发送样例数据</button> | |
| 424 | + </div> | |
| 425 | + | |
| 426 | + <h2 style="margin-top: 30px;">📥 主题订阅</h2> | |
| 427 | + | |
| 428 | + <div class="form-group"> | |
| 429 | + <label>快捷主题选择(下行消息 - 服务端 → 设备)</label> | |
| 430 | + <select id="quickTopicSelect" onchange="selectQuickTopic()" | |
| 431 | + style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 5px;"> | |
| 432 | + <option value="">-- 选择下行消息类型 --</option> | |
| 433 | + <optgroup label="📥 下行消息"> | |
| 434 | + <option value="thing.property.set">属性设置 (thing.property.set)</option> | |
| 435 | + <option value="thing.service.invoke">服务调用 (thing.service.invoke)</option> | |
| 436 | + <option value="thing.config.push">配置推送 (thing.config.push)</option> | |
| 437 | + <option value="thing.ota.upgrade">OTA 固件推送 (thing.ota.upgrade)</option> | |
| 438 | + </optgroup> | |
| 439 | + <optgroup label="🔄 回复主题(上行消息的回复)"> | |
| 440 | + <option value="thing.property.post_reply">属性上报回复 (thing.property.post_reply)</option> | |
| 441 | + <option value="thing.event.post_reply">事件上报回复 (thing.event.post_reply)</option> | |
| 442 | + </optgroup> | |
| 443 | + <optgroup label="🔧 通配符订阅"> | |
| 444 | + <option value="wildcard_all">订阅所有主题 (/sys/+/+/#)</option> | |
| 445 | + <option value="wildcard_thing">订阅所有 thing 主题 (/sys/+/+/thing/#)</option> | |
| 446 | + <option value="wildcard_reply">订阅所有回复主题 (/sys/+/+/#_reply)</option> | |
| 447 | + </optgroup> | |
| 448 | + </select> | |
| 449 | + </div> | |
| 450 | + | |
| 451 | + <div class="form-group"> | |
| 452 | + <label>订阅主题</label> | |
| 453 | + <input id="subTopic" placeholder="订阅主题,格式:/sys/{productKey}/{deviceName}/thing/property/set" type="text" | |
| 454 | + value="/sys/fqTn4Afs982Nak4N/jiali001/thing/property/set"> | |
| 455 | + <small style="color: #666; font-size: 12px;">标准格式:/sys/{productKey}/{deviceName}/thing/method 或使用通配符 | |
| 456 | + /sys/+/+/#</small> | |
| 457 | + </div> | |
| 458 | + | |
| 459 | + <div class="form-group"> | |
| 460 | + <label>QoS 级别</label> | |
| 461 | + <select id="subQos"> | |
| 462 | + <option value="0">0 - 最多一次</option> | |
| 463 | + <option selected value="1">1 - 至少一次</option> | |
| 464 | + <option value="2">2 - 刚好一次</option> | |
| 465 | + </select> | |
| 466 | + </div> | |
| 467 | + | |
| 468 | + <div class="btn-group"> | |
| 469 | + <button class="btn btn-primary" onclick="subscribe()">📥 订阅</button> | |
| 470 | + <button class="btn btn-danger" onclick="unsubscribe()">❌ 取消订阅</button> | |
| 471 | + </div> | |
| 472 | + </div> | |
| 473 | + | |
| 474 | + <!-- 日志面板 --> | |
| 475 | + <div class="panel" style="grid-column: 1 / -1;"> | |
| 476 | + <h2>📝 日志输出</h2> | |
| 477 | + <div class="log-area" id="logArea"></div> | |
| 478 | + </div> | |
| 479 | + </div> | |
| 480 | +</div> | |
| 481 | + | |
| 482 | +<!-- 使用 MQTT.js 库 --> | |
| 483 | +<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script> | |
| 484 | + | |
| 485 | +<script> | |
| 486 | + let client = null; | |
| 487 | + let sentCount = 0; | |
| 488 | + let receivedCount = 0; | |
| 489 | + let errorCount = 0; | |
| 490 | + | |
| 491 | + // 添加日志 | |
| 492 | + function addLog(message, type = 'info') { | |
| 493 | + const logArea = document.getElementById('logArea'); | |
| 494 | + const timestamp = new Date().toLocaleTimeString(); | |
| 495 | + const logEntry = document.createElement('div'); | |
| 496 | + logEntry.className = `log-entry ${type}`; | |
| 497 | + logEntry.textContent = `[${timestamp}] ${message}`; | |
| 498 | + logArea.appendChild(logEntry); | |
| 499 | + logArea.scrollTop = logArea.scrollHeight; | |
| 500 | + } | |
| 501 | + | |
| 502 | + // 更新状态栏 | |
| 503 | + function updateStatus(status, text) { | |
| 504 | + const statusBar = document.getElementById('statusBar'); | |
| 505 | + statusBar.className = `status ${status}`; | |
| 506 | + | |
| 507 | + const icons = { | |
| 508 | + 'disconnected': '⚫', | |
| 509 | + 'connecting': '🟡', | |
| 510 | + 'connected': '🟢' | |
| 511 | + }; | |
| 512 | + | |
| 513 | + statusBar.textContent = `${icons[status]} ${text}`; | |
| 514 | + } | |
| 515 | + | |
| 516 | + // 更新统计信息 | |
| 517 | + function updateStats() { | |
| 518 | + document.getElementById('sentCount').textContent = sentCount; | |
| 519 | + document.getElementById('receivedCount').textContent = receivedCount; | |
| 520 | + document.getElementById('errorCount').textContent = errorCount; | |
| 521 | + } | |
| 522 | + | |
| 523 | + // 连接到服务器 | |
| 524 | + function connect() { | |
| 525 | + const serverUrl = document.getElementById('serverUrl').value; | |
| 526 | + const clientId = document.getElementById('clientId').value; | |
| 527 | + const username = document.getElementById('username').value; | |
| 528 | + const password = document.getElementById('password').value; | |
| 529 | + | |
| 530 | + if (!serverUrl || !clientId) { | |
| 531 | + addLog('❌ 请填写服务器地址和 Client ID', 'error'); | |
| 532 | + errorCount++; | |
| 533 | + updateStats(); | |
| 534 | + return; | |
| 535 | + } | |
| 536 | + | |
| 537 | + updateStatus('connecting', '正在连接...'); | |
| 538 | + addLog(`🔄 正在连接到 ${serverUrl}...`, 'info'); | |
| 539 | + | |
| 540 | + const options = { | |
| 541 | + clientId: clientId, | |
| 542 | + username: username, | |
| 543 | + password: password, | |
| 544 | + clean: true, | |
| 545 | + reconnectPeriod: 5000, | |
| 546 | + connectTimeout: 30000, | |
| 547 | + }; | |
| 548 | + | |
| 549 | + client = mqtt.connect(serverUrl, options); | |
| 550 | + | |
| 551 | + // 连接成功 | |
| 552 | + client.on('connect', () => { | |
| 553 | + updateStatus('connected', '已连接'); | |
| 554 | + addLog('✅ 连接成功!', 'success'); | |
| 555 | + document.getElementById('connectBtn').disabled = true; | |
| 556 | + document.getElementById('disconnectBtn').disabled = false; | |
| 557 | + }); | |
| 558 | + | |
| 559 | + // 接收消息 | |
| 560 | + client.on('message', (topic, message) => { | |
| 561 | + receivedCount++; | |
| 562 | + updateStats(); | |
| 563 | + addLog(`📥 收到消息 [${topic}]: ${message.toString()}`, 'success'); | |
| 564 | + }); | |
| 565 | + | |
| 566 | + // 连接错误 | |
| 567 | + client.on('error', (error) => { | |
| 568 | + errorCount++; | |
| 569 | + updateStats(); | |
| 570 | + addLog(`❌ 连接错误: ${error.message}`, 'error'); | |
| 571 | + }); | |
| 572 | + | |
| 573 | + // 断开连接 | |
| 574 | + client.on('close', () => { | |
| 575 | + updateStatus('disconnected', '未连接'); | |
| 576 | + addLog('🔌 连接已断开', 'warning'); | |
| 577 | + document.getElementById('connectBtn').disabled = false; | |
| 578 | + document.getElementById('disconnectBtn').disabled = true; | |
| 579 | + }); | |
| 580 | + | |
| 581 | + // 离线 | |
| 582 | + client.on('offline', () => { | |
| 583 | + updateStatus('disconnected', '离线'); | |
| 584 | + addLog('⚠️ 客户端离线', 'warning'); | |
| 585 | + }); | |
| 586 | + | |
| 587 | + // 重连 | |
| 588 | + client.on('reconnect', () => { | |
| 589 | + updateStatus('connecting', '正在重连...'); | |
| 590 | + addLog('🔄 正在重连...', 'info'); | |
| 591 | + }); | |
| 592 | + } | |
| 593 | + | |
| 594 | + // 断开连接 | |
| 595 | + function disconnect() { | |
| 596 | + if (client) { | |
| 597 | + client.end(); | |
| 598 | + addLog('👋 主动断开连接', 'info'); | |
| 599 | + } | |
| 600 | + } | |
| 601 | + | |
| 602 | + // 发布消息 | |
| 603 | + function publish() { | |
| 604 | + if (!client || !client.connected) { | |
| 605 | + addLog('❌ 请先连接到服务器', 'error'); | |
| 606 | + errorCount++; | |
| 607 | + updateStats(); | |
| 608 | + return; | |
| 609 | + } | |
| 610 | + | |
| 611 | + const topic = document.getElementById('pubTopic').value; | |
| 612 | + const qos = parseInt(document.getElementById('pubQos').value); | |
| 613 | + const message = document.getElementById('pubMessage').value; | |
| 614 | + | |
| 615 | + if (!topic || !message) { | |
| 616 | + addLog('❌ 请填写主题和消息内容', 'error'); | |
| 617 | + errorCount++; | |
| 618 | + updateStats(); | |
| 619 | + return; | |
| 620 | + } | |
| 621 | + | |
| 622 | + // 验证 JSON 格式 | |
| 623 | + try { | |
| 624 | + JSON.parse(message); | |
| 625 | + } catch (e) { | |
| 626 | + addLog('⚠️ 消息不是有效的 JSON 格式,将作为纯文本发送', 'warning'); | |
| 627 | + } | |
| 628 | + | |
| 629 | + client.publish(topic, message, {qos: qos}, (error) => { | |
| 630 | + if (error) { | |
| 631 | + errorCount++; | |
| 632 | + updateStats(); | |
| 633 | + addLog(`❌ 发布失败: ${error.message}`, 'error'); | |
| 634 | + } else { | |
| 635 | + sentCount++; | |
| 636 | + updateStats(); | |
| 637 | + addLog(`📤 消息已发布 [${topic}] (QoS ${qos})`, 'success'); | |
| 638 | + } | |
| 639 | + }); | |
| 640 | + } | |
| 641 | + | |
| 642 | + // 发送样例数据 | |
| 643 | + function publishSampleData() { | |
| 644 | + // 使用 Alink 协议格式的样例数据 | |
| 645 | + const sampleData = { | |
| 646 | + id: Date.now().toString(), | |
| 647 | + version: "1.0", | |
| 648 | + method: "thing.property.post", | |
| 649 | + params: { | |
| 650 | + temperature: parseFloat((20 + Math.random() * 10).toFixed(2)), | |
| 651 | + humidity: parseFloat((50 + Math.random() * 20).toFixed(2)), | |
| 652 | + pressure: parseFloat((1000 + Math.random() * 50).toFixed(2)) | |
| 653 | + } | |
| 654 | + }; | |
| 655 | + | |
| 656 | + document.getElementById('pubMessage').value = JSON.stringify(sampleData, null, 2); | |
| 657 | + addLog('样例数据已生成(Alink 协议格式)', 'info'); | |
| 658 | + publish(); | |
| 659 | + } | |
| 660 | + | |
| 661 | + // 获取 productKey 和 deviceName | |
| 662 | + function getDeviceInfo() { | |
| 663 | + const clientId = document.getElementById('clientId').value; | |
| 664 | + const parts = clientId.split('.'); | |
| 665 | + | |
| 666 | + if (parts.length !== 2) { | |
| 667 | + addLog('❌ Client ID 格式不正确(应为 {productKey}.{deviceName}),无法生成主题', 'error'); | |
| 668 | + return null; | |
| 669 | + } | |
| 670 | + | |
| 671 | + return { | |
| 672 | + productKey: parts[0], | |
| 673 | + deviceName: parts[1] | |
| 674 | + }; | |
| 675 | + } | |
| 676 | + | |
| 677 | + // 快捷主题选择(消息发布 - 上行消息) | |
| 678 | + function selectQuickPublishTopic() { | |
| 679 | + const select = document.getElementById('quickPublishTopicSelect'); | |
| 680 | + const selectedValue = select.value; | |
| 681 | + | |
| 682 | + console.log('[selectQuickPublishTopic] 选择的值:', selectedValue); | |
| 683 | + | |
| 684 | + if (!selectedValue) { | |
| 685 | + return; | |
| 686 | + } | |
| 687 | + | |
| 688 | + const deviceInfo = getDeviceInfo(); | |
| 689 | + if (!deviceInfo) { | |
| 690 | + return; | |
| 691 | + } | |
| 692 | + | |
| 693 | + console.log('[selectQuickPublishTopic] 设备信息:', deviceInfo); | |
| 694 | + | |
| 695 | + // 构建标准主题,将枚举中的点号替换为斜杠 | |
| 696 | + // 例如:thing.property.post -> /sys/{pk}/{dn}/thing/property/post | |
| 697 | + const topic = `/sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/${selectedValue.replace(/\./g, '/')}`; | |
| 698 | + | |
| 699 | + console.log('[selectQuickPublishTopic] 生成的主题:', topic); | |
| 700 | + | |
| 701 | + const pubTopicInput = document.getElementById('pubTopic'); | |
| 702 | + pubTopicInput.value = topic; | |
| 703 | + | |
| 704 | + console.log('[selectQuickPublishTopic] 输入框的值已设置为:', pubTopicInput.value); | |
| 705 | + | |
| 706 | + addLog(`📋 已选择发布主题: ${topic}`, 'info'); | |
| 707 | + | |
| 708 | + // 需要 reply 的消息类型(不在 REPLY_DISABLED 列表中) | |
| 709 | + const needsReply = [ | |
| 710 | + 'thing.property.post', | |
| 711 | + 'thing.event.post' | |
| 712 | + ]; | |
| 713 | + | |
| 714 | + // 如果需要 reply,自动订阅 reply 主题 | |
| 715 | + if (needsReply.includes(selectedValue)) { | |
| 716 | + const replyTopic = `${topic}_reply`; | |
| 717 | + | |
| 718 | + if (client && client.connected) { | |
| 719 | + // 自动订阅 reply 主题 | |
| 720 | + client.subscribe(replyTopic, {qos: 1}, (err) => { | |
| 721 | + if (!err) { | |
| 722 | + addLog(`✅ 已自动订阅回复主题: ${replyTopic}`, 'success'); | |
| 723 | + } else { | |
| 724 | + addLog(`❌ 自动订阅回复主题失败: ${err.message}`, 'error'); | |
| 725 | + } | |
| 726 | + }); | |
| 727 | + } else { | |
| 728 | + addLog(`💡 提示: 该消息需要订阅回复主题 ${replyTopic}`, 'warning'); | |
| 729 | + } | |
| 730 | + } | |
| 731 | + | |
| 732 | + // 重置下拉框到默认选项 | |
| 733 | + select.selectedIndex = 0; | |
| 734 | + console.log('[selectQuickPublishTopic] 下拉框已重置'); | |
| 735 | + } | |
| 736 | + | |
| 737 | + // 快捷主题选择(主题订阅 - 下行消息) | |
| 738 | + function selectQuickTopic() { | |
| 739 | + const select = document.getElementById('quickTopicSelect'); | |
| 740 | + const selectedValue = select.value; | |
| 741 | + | |
| 742 | + console.log('[selectQuickTopic] 选择的值:', selectedValue); | |
| 743 | + | |
| 744 | + if (!selectedValue) { | |
| 745 | + return; | |
| 746 | + } | |
| 747 | + | |
| 748 | + const subTopicInput = document.getElementById('subTopic'); | |
| 749 | + | |
| 750 | + // 处理通配符订阅 | |
| 751 | + if (selectedValue === 'wildcard_all') { | |
| 752 | + subTopicInput.value = '/sys/+/+/#'; | |
| 753 | + addLog('📋 已选择订阅主题: /sys/+/+/#(订阅所有主题)', 'info'); | |
| 754 | + console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value); | |
| 755 | + select.selectedIndex = 0; | |
| 756 | + console.log('[selectQuickTopic] 下拉框已重置'); | |
| 757 | + return; | |
| 758 | + } else if (selectedValue === 'wildcard_thing') { | |
| 759 | + subTopicInput.value = '/sys/+/+/thing/#'; | |
| 760 | + addLog('📋 已选择订阅主题: /sys/+/+/thing/#(订阅所有 thing 主题)', 'info'); | |
| 761 | + console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value); | |
| 762 | + select.selectedIndex = 0; | |
| 763 | + console.log('[selectQuickTopic] 下拉框已重置'); | |
| 764 | + return; | |
| 765 | + } else if (selectedValue === 'wildcard_reply') { | |
| 766 | + const deviceInfo = getDeviceInfo(); | |
| 767 | + if (!deviceInfo) { | |
| 768 | + select.selectedIndex = 0; | |
| 769 | + return; | |
| 770 | + } | |
| 771 | + subTopicInput.value = `/sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/#_reply`; | |
| 772 | + addLog(`📋 已选择订阅主题: /sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/#_reply(订阅所有回复主题)`, 'info'); | |
| 773 | + console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value); | |
| 774 | + select.selectedIndex = 0; | |
| 775 | + console.log('[selectQuickTopic] 下拉框已重置'); | |
| 776 | + return; | |
| 777 | + } | |
| 778 | + | |
| 779 | + const deviceInfo = getDeviceInfo(); | |
| 780 | + if (!deviceInfo) { | |
| 781 | + select.selectedIndex = 0; | |
| 782 | + return; | |
| 783 | + } | |
| 784 | + | |
| 785 | + console.log('[selectQuickTopic] 设备信息:', deviceInfo); | |
| 786 | + | |
| 787 | + // 构建标准主题,将枚举中的点号替换为斜杠 | |
| 788 | + // 例如:thing.property.set -> /sys/{pk}/{dn}/thing/property/set | |
| 789 | + const topic = `/sys/${deviceInfo.productKey}/${deviceInfo.deviceName}/${selectedValue.replace(/\./g, '/')}`; | |
| 790 | + | |
| 791 | + console.log('[selectQuickTopic] 生成的主题:', topic); | |
| 792 | + | |
| 793 | + subTopicInput.value = topic; | |
| 794 | + addLog(`📋 已选择订阅主题: ${topic}`, 'info'); | |
| 795 | + | |
| 796 | + console.log('[selectQuickTopic] 输入框的值已设置为:', subTopicInput.value); | |
| 797 | + | |
| 798 | + // 重置下拉框到默认选项 | |
| 799 | + select.selectedIndex = 0; | |
| 800 | + console.log('[selectQuickTopic] 下拉框已重置'); | |
| 801 | + } | |
| 802 | + | |
| 803 | + // 订阅主题 | |
| 804 | + function subscribe() { | |
| 805 | + if (!client || !client.connected) { | |
| 806 | + addLog('❌ 请先连接到服务器', 'error'); | |
| 807 | + errorCount++; | |
| 808 | + updateStats(); | |
| 809 | + return; | |
| 810 | + } | |
| 811 | + | |
| 812 | + const topic = document.getElementById('subTopic').value; | |
| 813 | + const qos = parseInt(document.getElementById('subQos').value); | |
| 814 | + | |
| 815 | + if (!topic) { | |
| 816 | + addLog('❌ 请填写订阅主题', 'error'); | |
| 817 | + errorCount++; | |
| 818 | + updateStats(); | |
| 819 | + return; | |
| 820 | + } | |
| 821 | + | |
| 822 | + client.subscribe(topic, {qos: qos}, (error) => { | |
| 823 | + if (error) { | |
| 824 | + errorCount++; | |
| 825 | + updateStats(); | |
| 826 | + addLog(`❌ 订阅失败: ${error.message}`, 'error'); | |
| 827 | + } else { | |
| 828 | + addLog(`📥 已订阅主题 [${topic}] (QoS ${qos})`, 'success'); | |
| 829 | + } | |
| 830 | + }); | |
| 831 | + } | |
| 832 | + | |
| 833 | + // 取消订阅 | |
| 834 | + function unsubscribe() { | |
| 835 | + if (!client || !client.connected) { | |
| 836 | + addLog('❌ 请先连接到服务器', 'error'); | |
| 837 | + errorCount++; | |
| 838 | + updateStats(); | |
| 839 | + return; | |
| 840 | + } | |
| 841 | + | |
| 842 | + const topic = document.getElementById('subTopic').value; | |
| 843 | + | |
| 844 | + if (!topic) { | |
| 845 | + addLog('❌ 请填写要取消的订阅主题', 'error'); | |
| 846 | + errorCount++; | |
| 847 | + updateStats(); | |
| 848 | + return; | |
| 849 | + } | |
| 850 | + | |
| 851 | + client.unsubscribe(topic, (error) => { | |
| 852 | + if (error) { | |
| 853 | + errorCount++; | |
| 854 | + updateStats(); | |
| 855 | + addLog(`❌ 取消订阅失败: ${error.message}`, 'error'); | |
| 856 | + } else { | |
| 857 | + addLog(`❌ 已取消订阅 [${topic}]`, 'info'); | |
| 858 | + } | |
| 859 | + }); | |
| 860 | + } | |
| 861 | + | |
| 862 | + // 清空日志 | |
| 863 | + function clearLogs() { | |
| 864 | + document.getElementById('logArea').innerHTML = ''; | |
| 865 | + sentCount = 0; | |
| 866 | + receivedCount = 0; | |
| 867 | + errorCount = 0; | |
| 868 | + updateStats(); | |
| 869 | + addLog('🗑️ 日志已清空', 'info'); | |
| 870 | + } | |
| 871 | + | |
| 872 | + // 页面加载完成 | |
| 873 | + window.onload = function () { | |
| 874 | + addLog('👋 欢迎使用 MQTT WebSocket 测试客户端!', 'success'); | |
| 875 | + addLog('📚 请配置连接参数后点击"连接"按钮', 'info'); | |
| 876 | + }; | |
| 877 | + | |
| 878 | + // 页面关闭前断开连接 | |
| 879 | + window.onbeforeunload = function () { | |
| 880 | + if (client && client.connected) { | |
| 881 | + client.end(); | |
| 882 | + } | |
| 883 | + }; | |
| 884 | +</script> | |
| 885 | +</body> | |
| 886 | +</html> | |
| 887 | + | |
| 888 | + | ... | ... |
yudao-server/src/main/resources/application-local.yaml
| ... | ... | @@ -69,7 +69,8 @@ spring: |
| 69 | 69 | username: root |
| 70 | 70 | password: 123456 |
| 71 | 71 | # tdengine: # IoT 数据库(需要 IoT 物联网再开启噢!) |
| 72 | -# url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro | |
| 72 | +# lazy: true # 开启懒加载,保证启动速度 | |
| 73 | +# url: jdbc:TAOS-WS://127.0.0.1:6041/ruoyi_vue_pro?varcharAsString=true | |
| 73 | 74 | # driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver |
| 74 | 75 | # username: root |
| 75 | 76 | # password: taosdata | ... | ... |