【同步】BOOT 和 CLOUD 的功能(IoT)
This commit is contained in:
@@ -77,6 +77,7 @@
|
||||
<vertx.version>4.5.22</vertx.version>
|
||||
<okhttp.version>4.12.0</okhttp.version>
|
||||
<californium.version>3.12.0</californium.version>
|
||||
<j2mod.version>3.2.1</j2mod.version>
|
||||
<!-- 三方云服务相关 -->
|
||||
<awssdk.version>2.40.15</awssdk.version>
|
||||
<justauth.version>1.16.7</justauth.version>
|
||||
@@ -656,6 +657,13 @@
|
||||
<version>${californium.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Modbus 相关 -->
|
||||
<dependency>
|
||||
<groupId>com.ghgande</groupId>
|
||||
<artifactId>j2mod</artifactId>
|
||||
<version>${j2mod.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 三方云服务相关 -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
|
||||
@@ -8,8 +8,8 @@ package cn.iocoder.yudao.module.iot.enums;
|
||||
public class DictTypeConstants {
|
||||
|
||||
public static final String NET_TYPE = "iot_net_type";
|
||||
public static final String LOCATION_TYPE = "iot_location_type";
|
||||
public static final String CODEC_TYPE = "iot_codec_type";
|
||||
public static final String PROTOCOL_TYPE = "iot_protocol_type";
|
||||
public static final String SERIALIZE_TYPE = "iot_serialize_type";
|
||||
|
||||
public static final String PRODUCT_STATUS = "iot_product_status";
|
||||
public static final String PRODUCT_DEVICE_TYPE = "iot_product_device_type";
|
||||
|
||||
@@ -54,6 +54,14 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode DEVICE_GROUP_NOT_EXISTS = new ErrorCode(1_050_005_000, "设备分组不存在");
|
||||
ErrorCode DEVICE_GROUP_DELETE_FAIL_DEVICE_EXISTS = new ErrorCode(1_050_005_001, "设备分组下存在设备,不允许删除");
|
||||
|
||||
// ========== 设备 Modbus 配置 1-050-006-000 ==========
|
||||
ErrorCode DEVICE_MODBUS_CONFIG_NOT_EXISTS = new ErrorCode(1_050_006_000, "设备 Modbus 连接配置不存在");
|
||||
ErrorCode DEVICE_MODBUS_CONFIG_EXISTS = new ErrorCode(1_050_006_001, "设备 Modbus 连接配置已存在");
|
||||
|
||||
// ========== 设备 Modbus 点位 1-050-007-000 ==========
|
||||
ErrorCode DEVICE_MODBUS_POINT_NOT_EXISTS = new ErrorCode(1_050_007_000, "设备 Modbus 点位配置不存在");
|
||||
ErrorCode DEVICE_MODBUS_POINT_EXISTS = new ErrorCode(1_050_007_001, "设备 Modbus 点位配置已存在");
|
||||
|
||||
// ========== OTA 固件相关 1-050-008-000 ==========
|
||||
|
||||
ErrorCode OTA_FIRMWARE_NOT_EXISTS = new ErrorCode(1_050_008_000, "固件信息不存在");
|
||||
|
||||
@@ -19,9 +19,9 @@ public enum IotDataSinkTypeEnum implements ArrayValuable<Integer> {
|
||||
TCP(2, "TCP"),
|
||||
WEBSOCKET(3, "WebSocket"),
|
||||
|
||||
MQTT(10, "MQTT"), // TODO 待实现;
|
||||
MQTT(10, "MQTT"), // TODO @puhui999:待实现;
|
||||
|
||||
DATABASE(20, "Database"), // TODO @puhui999:待实现;可以简单点,对应的表名是什么,字段先固定了。
|
||||
DATABASE(20, "Database"), // TODO @puhui999:待实现;
|
||||
REDIS(21, "Redis"),
|
||||
|
||||
ROCKETMQ(30, "RocketMQ"),
|
||||
|
||||
@@ -18,13 +18,13 @@ public enum IotSceneRuleActionTypeEnum implements ArrayValuable<Integer> {
|
||||
/**
|
||||
* 设备属性设置
|
||||
*
|
||||
* 对应 {@link IotDeviceMessageMethodEnum#PROPERTY_SET}
|
||||
* 对应 {@link cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum#PROPERTY_SET}
|
||||
*/
|
||||
DEVICE_PROPERTY_SET(1),
|
||||
/**
|
||||
* 设备服务调用
|
||||
*
|
||||
* 对应 {@link IotDeviceMessageMethodEnum#SERVICE_INVOKE}
|
||||
* 对应 {@link cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum#SERVICE_INVOKE}
|
||||
*/
|
||||
DEVICE_SERVICE_INVOKE(2),
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package cn.iocoder.yudao.module.iot.core.biz;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceGetReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.*;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO;
|
||||
@@ -50,4 +47,12 @@ public interface IotDeviceCommonApi {
|
||||
*/
|
||||
CommonResult<List<IotSubDeviceRegisterRespDTO>> registerSubDevices(IotSubDeviceRegisterFullReqDTO reqDTO);
|
||||
|
||||
/**
|
||||
* 获取 Modbus 设备配置列表
|
||||
*
|
||||
* @param listReqDTO 查询参数
|
||||
* @return Modbus 设备配置列表
|
||||
*/
|
||||
CommonResult<List<IotModbusDeviceConfigRespDTO>> getModbusDeviceConfigList(IotModbusDeviceConfigListReqDTO listReqDTO);
|
||||
|
||||
}
|
||||
|
||||
@@ -34,8 +34,12 @@ public class IotDeviceRespDTO {
|
||||
*/
|
||||
private Long productId;
|
||||
/**
|
||||
* 编解码器类型
|
||||
* 协议类型
|
||||
*/
|
||||
private String codecType;
|
||||
private String protocolType;
|
||||
/**
|
||||
* 序列化类型
|
||||
*/
|
||||
private String serializeType;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.iocoder.yudao.module.iot.core.biz.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* IoT Modbus 设备配置列表查询 Request DTO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
@Accessors(chain = true)
|
||||
public class IotModbusDeviceConfigListReqDTO {
|
||||
|
||||
/**
|
||||
* 状态
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 模式
|
||||
*/
|
||||
private Integer mode;
|
||||
|
||||
/**
|
||||
* 协议类型
|
||||
*/
|
||||
private String protocolType;
|
||||
|
||||
/**
|
||||
* 设备 ID 集合
|
||||
*/
|
||||
private Set<Long> deviceIds;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cn.iocoder.yudao.module.iot.core.biz.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT Modbus 设备配置 Response DTO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
public class IotModbusDeviceConfigRespDTO {
|
||||
|
||||
/**
|
||||
* 设备编号
|
||||
*/
|
||||
private Long deviceId;
|
||||
/**
|
||||
* 产品标识
|
||||
*/
|
||||
private String productKey;
|
||||
/**
|
||||
* 设备名称
|
||||
*/
|
||||
private String deviceName;
|
||||
|
||||
// ========== Modbus 连接配置 ==========
|
||||
|
||||
/**
|
||||
* Modbus 服务器 IP 地址
|
||||
*/
|
||||
private String ip;
|
||||
/**
|
||||
* Modbus 服务器端口
|
||||
*/
|
||||
private Integer port;
|
||||
/**
|
||||
* 从站地址
|
||||
*/
|
||||
private Integer slaveId;
|
||||
/**
|
||||
* 连接超时时间,单位:毫秒
|
||||
*/
|
||||
private Integer timeout;
|
||||
/**
|
||||
* 重试间隔,单位:毫秒
|
||||
*/
|
||||
private Integer retryInterval;
|
||||
/**
|
||||
* 模式
|
||||
*/
|
||||
private Integer mode;
|
||||
/**
|
||||
* 数据帧格式
|
||||
*/
|
||||
private Integer frameFormat;
|
||||
|
||||
// ========== Modbus 点位配置 ==========
|
||||
|
||||
/**
|
||||
* 点位列表
|
||||
*/
|
||||
private List<IotModbusPointRespDTO> points;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package cn.iocoder.yudao.module.iot.core.biz.dto;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusByteOrderEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusRawDataTypeEnum;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* IoT Modbus 点位配置 Response DTO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
public class IotModbusPointRespDTO {
|
||||
|
||||
/**
|
||||
* 点位编号
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 属性标识符(物模型的 identifier)
|
||||
*/
|
||||
private String identifier;
|
||||
/**
|
||||
* 属性名称(物模型的 name)
|
||||
*/
|
||||
private String name;
|
||||
|
||||
// ========== Modbus 协议配置 ==========
|
||||
|
||||
/**
|
||||
* Modbus 功能码
|
||||
*
|
||||
* 取值范围:FC01-04(读线圈、读离散输入、读保持寄存器、读输入寄存器)
|
||||
*/
|
||||
private Integer functionCode;
|
||||
/**
|
||||
* 寄存器起始地址
|
||||
*/
|
||||
private Integer registerAddress;
|
||||
/**
|
||||
* 寄存器数量
|
||||
*/
|
||||
private Integer registerCount;
|
||||
/**
|
||||
* 字节序
|
||||
*
|
||||
* 枚举 {@link IotModbusByteOrderEnum}
|
||||
*/
|
||||
private String byteOrder;
|
||||
/**
|
||||
* 原始数据类型
|
||||
*
|
||||
* 枚举 {@link IotModbusRawDataTypeEnum}
|
||||
*/
|
||||
private String rawDataType;
|
||||
/**
|
||||
* 缩放因子
|
||||
*/
|
||||
private BigDecimal scale;
|
||||
/**
|
||||
* 轮询间隔(毫秒)
|
||||
*/
|
||||
private Integer pollInterval;
|
||||
|
||||
}
|
||||
@@ -64,7 +64,7 @@ public enum IotDeviceMessageMethodEnum implements ArrayValuable<String> {
|
||||
// ========== OTA 固件 ==========
|
||||
// 可参考:https://help.aliyun.com/zh/iot/user-guide/perform-ota-updates
|
||||
|
||||
OTA_UPGRADE("thing.ota.upgrade", "OTA 固定信息推送", false),
|
||||
OTA_UPGRADE("thing.ota.upgrade", "OTA 固件信息推送", false),
|
||||
OTA_PROGRESS("thing.ota.progress", "OTA 升级进度上报", true),
|
||||
|
||||
;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package cn.iocoder.yudao.module.iot.core.enums;
|
||||
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* IoT 协议类型枚举
|
||||
*
|
||||
* 用于定义传输层协议类型
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Getter
|
||||
public enum IotProtocolTypeEnum implements ArrayValuable<String> {
|
||||
|
||||
TCP("tcp"),
|
||||
UDP("udp"),
|
||||
WEBSOCKET("websocket"),
|
||||
HTTP("http"),
|
||||
MQTT("mqtt"),
|
||||
EMQX("emqx"),
|
||||
COAP("coap"),
|
||||
MODBUS_TCP_CLIENT("modbus_tcp_client"),
|
||||
MODBUS_TCP_SERVER("modbus_tcp_server");
|
||||
|
||||
public static final String[] ARRAYS = Arrays.stream(values()).map(IotProtocolTypeEnum::getType).toArray(String[]::new);
|
||||
|
||||
/**
|
||||
* 类型
|
||||
*/
|
||||
private final String type;
|
||||
|
||||
@Override
|
||||
public String[] array() {
|
||||
return ARRAYS;
|
||||
}
|
||||
|
||||
public static IotProtocolTypeEnum of(String type) {
|
||||
return ArrayUtil.firstMatch(e -> e.getType().equals(type), values());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cn.iocoder.yudao.module.iot.core.enums;
|
||||
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* IoT 序列化类型枚举
|
||||
*
|
||||
* 用于定义设备消息的序列化格式
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Getter
|
||||
public enum IotSerializeTypeEnum implements ArrayValuable<String> {
|
||||
|
||||
JSON("json"),
|
||||
BINARY("binary");
|
||||
|
||||
public static final String[] ARRAYS = Arrays.stream(values()).map(IotSerializeTypeEnum::getType).toArray(String[]::new);
|
||||
|
||||
/**
|
||||
* 类型
|
||||
*/
|
||||
private final String type;
|
||||
|
||||
@Override
|
||||
public String[] array() {
|
||||
return ARRAYS;
|
||||
}
|
||||
|
||||
public static IotSerializeTypeEnum of(String type) {
|
||||
return ArrayUtil.firstMatch(e -> e.getType().equals(type), values());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.iocoder.yudao.module.iot.core.enums;
|
||||
package cn.iocoder.yudao.module.iot.core.enums.device;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
|
||||
import lombok.Getter;
|
||||
@@ -0,0 +1,54 @@
|
||||
package cn.iocoder.yudao.module.iot.core.enums.modbus;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* IoT Modbus 字节序枚举
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum IotModbusByteOrderEnum implements ArrayValuable<String> {
|
||||
|
||||
AB("AB", "大端序(16位)", 2),
|
||||
BA("BA", "小端序(16位)", 2),
|
||||
ABCD("ABCD", "大端序(32位)", 4),
|
||||
CDAB("CDAB", "大端字交换(32位)", 4),
|
||||
DCBA("DCBA", "小端序(32位)", 4),
|
||||
BADC("BADC", "小端字交换(32位)", 4);
|
||||
|
||||
public static final String[] ARRAYS = Arrays.stream(values())
|
||||
.map(IotModbusByteOrderEnum::getOrder)
|
||||
.toArray(String[]::new);
|
||||
|
||||
/**
|
||||
* 字节序
|
||||
*/
|
||||
private final String order;
|
||||
/**
|
||||
* 名称
|
||||
*/
|
||||
private final String name;
|
||||
/**
|
||||
* 字节数
|
||||
*/
|
||||
private final Integer byteCount;
|
||||
|
||||
@Override
|
||||
public String[] array() {
|
||||
return ARRAYS;
|
||||
}
|
||||
|
||||
public static IotModbusByteOrderEnum getByOrder(String order) {
|
||||
return Arrays.stream(values())
|
||||
.filter(e -> e.getOrder().equals(order))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.iocoder.yudao.module.iot.core.enums.modbus;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* IoT Modbus 数据帧格式枚举
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum IotModbusFrameFormatEnum implements ArrayValuable<Integer> {
|
||||
|
||||
MODBUS_TCP(1),
|
||||
MODBUS_RTU(2);
|
||||
|
||||
public static final Integer[] ARRAYS = Arrays.stream(values())
|
||||
.map(IotModbusFrameFormatEnum::getFormat)
|
||||
.toArray(Integer[]::new);
|
||||
|
||||
/**
|
||||
* 格式
|
||||
*/
|
||||
private final Integer format;
|
||||
|
||||
@Override
|
||||
public Integer[] array() {
|
||||
return ARRAYS;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.iocoder.yudao.module.iot.core.enums.modbus;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* IoT Modbus 工作模式枚举
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum IotModbusModeEnum implements ArrayValuable<Integer> {
|
||||
|
||||
POLLING(1, "云端轮询"),
|
||||
ACTIVE_REPORT(2, "边缘采集");
|
||||
|
||||
public static final Integer[] ARRAYS = Arrays.stream(values())
|
||||
.map(IotModbusModeEnum::getMode)
|
||||
.toArray(Integer[]::new);
|
||||
|
||||
/**
|
||||
* 工作模式
|
||||
*/
|
||||
private final Integer mode;
|
||||
/**
|
||||
* 模式名称
|
||||
*/
|
||||
private final String name;
|
||||
|
||||
@Override
|
||||
public Integer[] array() {
|
||||
return ARRAYS;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package cn.iocoder.yudao.module.iot.core.enums.modbus;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* IoT Modbus 原始数据类型枚举
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum IotModbusRawDataTypeEnum implements ArrayValuable<String> {
|
||||
|
||||
INT16("INT16", "有符号 16 位整数", 1),
|
||||
UINT16("UINT16", "无符号 16 位整数", 1),
|
||||
INT32("INT32", "有符号 32 位整数", 2),
|
||||
UINT32("UINT32", "无符号 32 位整数", 2),
|
||||
FLOAT("FLOAT", "32 位浮点数", 2),
|
||||
DOUBLE("DOUBLE", "64 位浮点数", 4),
|
||||
BOOLEAN("BOOLEAN", "布尔值(用于线圈)", 1),
|
||||
STRING("STRING", "字符串", null); // null 表示可变长度
|
||||
|
||||
public static final String[] ARRAYS = Arrays.stream(values())
|
||||
.map(IotModbusRawDataTypeEnum::getType)
|
||||
.toArray(String[]::new);
|
||||
|
||||
/**
|
||||
* 数据类型
|
||||
*/
|
||||
private final String type;
|
||||
/**
|
||||
* 名称
|
||||
*/
|
||||
private final String name;
|
||||
/**
|
||||
* 寄存器数量(null 表示可变)
|
||||
*/
|
||||
private final Integer registerCount;
|
||||
|
||||
@Override
|
||||
public String[] array() {
|
||||
return ARRAYS;
|
||||
}
|
||||
|
||||
public static IotModbusRawDataTypeEnum getByType(String type) {
|
||||
return Arrays.stream(values())
|
||||
.filter(e -> e.getType().equals(type))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
package cn.iocoder.yudao.module.iot.core.messagebus.config;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* IoT 消息总线配置属性
|
||||
*
|
||||
|
||||
@@ -24,4 +24,14 @@ public interface IotMessageBus {
|
||||
*/
|
||||
void register(IotMessageSubscriber<?> subscriber);
|
||||
|
||||
/**
|
||||
* 取消注册消息订阅者
|
||||
*
|
||||
* @param subscriber 订阅者
|
||||
*/
|
||||
default void unregister(IotMessageSubscriber<?> subscriber) {
|
||||
// TODO 芋艿:暂时不实现,需求量不大,但是
|
||||
// throw new UnsupportedOperationException("取消注册消息订阅者功能,尚未实现");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,4 +26,16 @@ public interface IotMessageSubscriber<T> {
|
||||
*/
|
||||
void onMessage(T message);
|
||||
|
||||
/**
|
||||
* 启动订阅
|
||||
*/
|
||||
default void start() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止订阅
|
||||
*/
|
||||
default void stop() {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package cn.iocoder.yudao.module.iot.core.mq.message;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceStateEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.state.IotDeviceStateUpdateReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
@@ -60,7 +60,7 @@ public class IotDeviceMessage {
|
||||
*/
|
||||
private String serverId;
|
||||
|
||||
// ========== codec(编解码)字段 ==========
|
||||
// ========== serialize(序列化)相关字段 ==========
|
||||
|
||||
/**
|
||||
* 请求编号
|
||||
@@ -72,7 +72,7 @@ public class IotDeviceMessage {
|
||||
* 请求方法
|
||||
*
|
||||
* 枚举 {@link IotDeviceMessageMethodEnum}
|
||||
* 例如说:thing.property.report 属性上报
|
||||
* 例如说:thing.property.post 属性上报
|
||||
*/
|
||||
private String method;
|
||||
/**
|
||||
@@ -94,7 +94,7 @@ public class IotDeviceMessage {
|
||||
*/
|
||||
private String msg;
|
||||
|
||||
// ========== 基础方法:只传递"codec(编解码)字段" ==========
|
||||
// ========== 基础方法:只传递"serialize(序列化)相关字段" ==========
|
||||
|
||||
public static IotDeviceMessage requestOf(String method) {
|
||||
return requestOf(null, method, null);
|
||||
@@ -108,6 +108,23 @@ public class IotDeviceMessage {
|
||||
return of(requestId, method, params, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建设备请求消息(包含设备信息)
|
||||
*
|
||||
* @param deviceId 设备编号
|
||||
* @param tenantId 租户编号
|
||||
* @param serverId 服务标识
|
||||
* @param method 消息方法
|
||||
* @param params 消息参数
|
||||
* @return 消息对象
|
||||
*/
|
||||
public static IotDeviceMessage requestOf(Long deviceId, Long tenantId, String serverId,
|
||||
String method, Object params) {
|
||||
IotDeviceMessage message = of(null, method, params, null, null, null);
|
||||
return message.setId(IotDeviceMessageUtils.generateMessageId())
|
||||
.setDeviceId(deviceId).setTenantId(tenantId).setServerId(serverId);
|
||||
}
|
||||
|
||||
public static IotDeviceMessage replyOf(String requestId, String method,
|
||||
Object data, Integer code, String msg) {
|
||||
if (code == null) {
|
||||
@@ -132,20 +149,12 @@ public class IotDeviceMessage {
|
||||
|
||||
public static IotDeviceMessage buildStateUpdateOnline() {
|
||||
return requestOf(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod(),
|
||||
MapUtil.of("state", IotDeviceStateEnum.ONLINE.getState()));
|
||||
new IotDeviceStateUpdateReqDTO(IotDeviceStateEnum.ONLINE.getState()));
|
||||
}
|
||||
|
||||
public static IotDeviceMessage buildStateOffline() {
|
||||
return requestOf(IotDeviceMessageMethodEnum.STATE_UPDATE.getMethod(),
|
||||
MapUtil.of("state", IotDeviceStateEnum.OFFLINE.getState()));
|
||||
}
|
||||
|
||||
public static IotDeviceMessage buildOtaUpgrade(String version, String fileUrl, Long fileSize,
|
||||
String fileDigestAlgorithm, String fileDigestValue) {
|
||||
return requestOf(IotDeviceMessageMethodEnum.OTA_UPGRADE.getMethod(), MapUtil.builder()
|
||||
.put("version", version).put("fileUrl", fileUrl).put("fileSize", fileSize)
|
||||
.put("fileDigestAlgorithm", fileDigestAlgorithm).put("fileDigestValue", fileDigestValue)
|
||||
.build());
|
||||
new IotDeviceStateUpdateReqDTO(IotDeviceStateEnum.OFFLINE.getState()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.auth;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT 设备动态注册 Request DTO
|
||||
* <p>
|
||||
* 用于直连设备/网关的一型一密动态注册:使用 productSecret 验证,返回 deviceSecret
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#DEVICE_REGISTER} 消息的 params 参数
|
||||
* <p>
|
||||
* 直连设备/网关的一型一密动态注册:使用 productSecret 验证,返回 deviceSecret
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification">阿里云 - 一型一密</a>
|
||||
@@ -27,9 +30,11 @@ public class IotDeviceRegisterReqDTO {
|
||||
private String deviceName;
|
||||
|
||||
/**
|
||||
* 产品密钥
|
||||
* 注册签名
|
||||
*
|
||||
* @see cn.iocoder.yudao.module.iot.core.util.IotProductAuthUtils#buildSign(String, String, String)
|
||||
*/
|
||||
@NotEmpty(message = "产品密钥不能为空")
|
||||
private String productSecret;
|
||||
@NotEmpty(message = "签名不能为空")
|
||||
private String sign;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.auth;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
@@ -7,7 +8,7 @@ import lombok.NoArgsConstructor;
|
||||
/**
|
||||
* IoT 设备动态注册 Response DTO
|
||||
* <p>
|
||||
* 用于直连设备/网关的一型一密动态注册响应
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#DEVICE_REGISTER} 响应的设备信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification">阿里云 - 一型一密</a>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.auth;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT 子设备动态注册 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.auth.register.sub 消息的 params 数组元素
|
||||
*
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#SUB_DEVICE_REGISTER} 消息的 params 数组元素
|
||||
* <p>
|
||||
* 特殊:网关子设备的动态注册,必须已经创建好该网关子设备(不然哪来的 {@link #deviceName} 字段)。更多的好处,是设备不用提前烧录 deviceSecret 密钥。
|
||||
*
|
||||
* @author 芋道源码
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.auth;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
@@ -7,7 +8,7 @@ import lombok.NoArgsConstructor;
|
||||
/**
|
||||
* IoT 子设备动态注册 Response DTO
|
||||
* <p>
|
||||
* 用于 thing.auth.register.sub 响应的设备信息
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#SUB_DEVICE_REGISTER} 响应的设备信息
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/register-devices">阿里云 - 动态注册子设备</a>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.config;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IoT 设备配置推送 Request DTO
|
||||
* <p>
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#CONFIG_PUSH} 下行消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/remote-configuration-1">阿里云 - 远程配置</a>
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotDeviceConfigPushReqDTO {
|
||||
|
||||
/**
|
||||
* 配置编号
|
||||
*/
|
||||
private String configId;
|
||||
|
||||
/**
|
||||
* 配置文件大小(字节)
|
||||
*/
|
||||
private Long configSize;
|
||||
|
||||
/**
|
||||
* 签名方法
|
||||
*/
|
||||
private String signMethod;
|
||||
|
||||
/**
|
||||
* 签名
|
||||
*/
|
||||
private String sign;
|
||||
|
||||
/**
|
||||
* 配置文件下载地址
|
||||
*/
|
||||
private String url;
|
||||
|
||||
/**
|
||||
* 获取类型
|
||||
* <p>
|
||||
* file: 文件
|
||||
* content: 内容
|
||||
*/
|
||||
private String getType;
|
||||
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.event;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT 设备事件上报 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.event.post 消息的 params 参数
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#EVENT_POST} 消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="http://help.aliyun.com/zh/marketplace/device-reporting-events">阿里云 - 设备上报事件</a>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.ota;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IoT 设备 OTA 升级进度上报 Request DTO
|
||||
* <p>
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#OTA_PROGRESS} 上行消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/perform-ota-updates">阿里云 - OTA 升级</a>
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotDeviceOtaProgressReqDTO {
|
||||
|
||||
/**
|
||||
* 固件版本号
|
||||
*/
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 升级状态
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 描述信息
|
||||
*/
|
||||
private String description;
|
||||
|
||||
/**
|
||||
* 升级进度(0-100)
|
||||
*/
|
||||
private Integer progress;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.ota;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IoT 设备 OTA 固件升级推送 Request DTO
|
||||
* <p>
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#OTA_UPGRADE} 下行消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/perform-ota-updates">阿里云 - OTA 升级</a>
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotDeviceOtaUpgradeReqDTO {
|
||||
|
||||
/**
|
||||
* 固件版本号
|
||||
*/
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 固件文件下载地址
|
||||
*/
|
||||
private String fileUrl;
|
||||
|
||||
/**
|
||||
* 固件文件大小(字节)
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 固件文件摘要算法
|
||||
*/
|
||||
private String fileDigestAlgorithm;
|
||||
|
||||
/**
|
||||
* 固件文件摘要值
|
||||
*/
|
||||
private String fileDigestValue;
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.property;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -9,7 +10,7 @@ import java.util.Map;
|
||||
/**
|
||||
* IoT 设备属性批量上报 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.event.property.pack.post 消息的 params 参数
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#PROPERTY_PACK_POST} 消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="http://help.aliyun.com/zh/marketplace/gateway-reports-data-in-batches">阿里云 - 网关批量上报数据</a>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.property;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 设备属性上报 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.property.post 消息的 params 参数
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#PROPERTY_POST} 消息的 params 参数
|
||||
* <p>
|
||||
* 本质是一个 Map,key 为属性标识符,value 为属性值
|
||||
*
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.property;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 设备属性设置 Request DTO
|
||||
* <p>
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#PROPERTY_SET} 下行消息的 params 参数
|
||||
* <p>
|
||||
* 本质是一个 Map,key 为属性标识符,value 为属性值
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public class IotDevicePropertySetReqDTO extends HashMap<String, Object> {
|
||||
|
||||
public IotDevicePropertySetReqDTO() {
|
||||
super();
|
||||
}
|
||||
|
||||
public IotDevicePropertySetReqDTO(Map<String, Object> properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建属性设置 DTO
|
||||
*
|
||||
* @param properties 属性数据
|
||||
* @return DTO 对象
|
||||
*/
|
||||
public static IotDevicePropertySetReqDTO of(Map<String, Object> properties) {
|
||||
return new IotDevicePropertySetReqDTO(properties);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.service;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 设备服务调用 Request DTO
|
||||
* <p>
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#SERVICE_INVOKE} 下行消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotDeviceServiceInvokeReqDTO {
|
||||
|
||||
/**
|
||||
* 服务标识符
|
||||
*/
|
||||
private String identifier;
|
||||
|
||||
/**
|
||||
* 服务输入参数
|
||||
*/
|
||||
private Map<String, Object> inputParams;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.state;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IoT 设备状态更新 Request DTO
|
||||
* <p>
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#STATE_UPDATE} 消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class IotDeviceStateUpdateReqDTO {
|
||||
|
||||
/**
|
||||
* 设备状态
|
||||
*/
|
||||
private Integer state;
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.topo;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -9,7 +10,7 @@ import java.util.List;
|
||||
/**
|
||||
* IoT 设备拓扑添加 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.topo.add 消息的 params 参数
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#TOPO_ADD} 消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="http://help.aliyun.com/zh/marketplace/add-topological-relationship">阿里云 - 添加拓扑关系</a>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.topo;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
@@ -10,7 +11,7 @@ import java.util.List;
|
||||
/**
|
||||
* IoT 设备拓扑关系变更通知 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.topo.change 下行消息的 params 参数
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#TOPO_CHANGE} 下行消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/marketplace/notify-gateway-topology-changes">阿里云 - 通知网关拓扑关系变化</a>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.topo;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
@@ -10,7 +11,7 @@ import java.util.List;
|
||||
/**
|
||||
* IoT 设备拓扑删除 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.topo.delete 消息的 params 参数
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#TOPO_DELETE} 消息的 params 参数
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/marketplace/delete-a-topological-relationship">阿里云 - 删除拓扑关系</a>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.topo;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT 设备拓扑关系获取 Request DTO
|
||||
* <p>
|
||||
* 用于 thing.topo.get 请求的 params 参数(目前为空,预留扩展)
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#TOPO_GET} 请求的 params 参数(目前为空,预留扩展)
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/marketplace/obtain-topological-relationship">阿里云 - 获取拓扑关系</a>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.iocoder.yudao.module.iot.core.topic.topo;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -8,7 +9,7 @@ import java.util.List;
|
||||
/**
|
||||
* IoT 设备拓扑关系获取 Response DTO
|
||||
* <p>
|
||||
* 用于 thing.topo.get 响应
|
||||
* 用于 {@link IotDeviceMessageMethodEnum#TOPO_GET} 响应
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/marketplace/obtain-topological-relationship">阿里云 - 获取拓扑关系</a>
|
||||
|
||||
@@ -25,6 +25,14 @@ public class IotDeviceAuthUtils {
|
||||
return String.format("%s.%s", productKey, deviceName);
|
||||
}
|
||||
|
||||
public static String buildClientIdFromUsername(String username) {
|
||||
IotDeviceIdentity identity = parseUsername(username);
|
||||
if (identity == null) {
|
||||
return null;
|
||||
}
|
||||
return buildClientId(identity.getProductKey(), identity.getDeviceName());
|
||||
}
|
||||
|
||||
public static String buildUsername(String productKey, String deviceName) {
|
||||
return String.format("%s&%s", deviceName, productKey);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package cn.iocoder.yudao.module.iot.core.util;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.hutool.crypto.digest.HmacAlgorithm;
|
||||
|
||||
/**
|
||||
* IoT 产品【动态注册】认证工具类
|
||||
* <p>
|
||||
* 用于一型一密场景,使用 productSecret 生成签名
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public class IotProductAuthUtils {
|
||||
|
||||
/**
|
||||
* 生成设备动态注册签名
|
||||
*
|
||||
* @param productKey 产品标识
|
||||
* @param deviceName 设备名称
|
||||
* @param productSecret 产品密钥
|
||||
* @return 签名
|
||||
*/
|
||||
public static String buildSign(String productKey, String deviceName, String productSecret) {
|
||||
String content = buildContent(productKey, deviceName);
|
||||
return DigestUtil.hmac(HmacAlgorithm.HmacSHA256, StrUtil.utf8Bytes(productSecret))
|
||||
.digestHex(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证设备动态注册签名
|
||||
*
|
||||
* @param productKey 产品标识
|
||||
* @param deviceName 设备名称
|
||||
* @param productSecret 产品密钥
|
||||
* @param sign 待验证的签名
|
||||
* @return 是否验证通过
|
||||
*/
|
||||
public static boolean verifySign(String productKey, String deviceName, String productSecret, String sign) {
|
||||
String expectedSign = buildSign(productKey, deviceName, productSecret);
|
||||
return expectedSign.equals(sign);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建签名内容
|
||||
*
|
||||
* @param productKey 产品标识
|
||||
* @param deviceName 设备名称
|
||||
* @return 签名内容
|
||||
*/
|
||||
private static String buildContent(String productKey, String deviceName) {
|
||||
return "deviceName" + deviceName + "productKey" + productKey;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
<groupId>org.apache.rocketmq</groupId>
|
||||
<artifactId>rocketmq-spring-boot-starter</artifactId>
|
||||
<!-- TODO @芋艿:消息队列,后续可能去掉,默认不使用 rocketmq -->
|
||||
<!-- <optional>true</optional> -->
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- 工具类相关 -->
|
||||
@@ -48,6 +48,12 @@
|
||||
<artifactId>vertx-mqtt</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Modbus 相关 -->
|
||||
<dependency>
|
||||
<groupId>com.ghgande</groupId>
|
||||
<artifactId>j2mod</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- CoAP 相关 - Eclipse Californium -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.californium</groupId>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.codec;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
|
||||
/**
|
||||
* {@link IotDeviceMessage} 的编解码器
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public interface IotDeviceMessageCodec {
|
||||
|
||||
/**
|
||||
* 编码消息
|
||||
*
|
||||
* @param message 消息
|
||||
* @return 编码后的消息内容
|
||||
*/
|
||||
byte[] encode(IotDeviceMessage message);
|
||||
|
||||
/**
|
||||
* 解码消息
|
||||
*
|
||||
* @param bytes 消息内容
|
||||
* @return 解码后的消息内容
|
||||
*/
|
||||
IotDeviceMessage decode(byte[] bytes);
|
||||
|
||||
/**
|
||||
* @return 数据格式(编码器类型)
|
||||
*/
|
||||
String type();
|
||||
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.codec.alink;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 阿里云 Alink {@link IotDeviceMessage} 的编解码器
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Component
|
||||
public class IotAlinkDeviceMessageCodec implements IotDeviceMessageCodec {
|
||||
|
||||
public static final String TYPE = "Alink";
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
private static class AlinkMessage {
|
||||
|
||||
public static final String VERSION_1 = "1.0";
|
||||
|
||||
/**
|
||||
* 消息 ID,且每个消息 ID 在当前设备具有唯一性
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* 请求方法
|
||||
*/
|
||||
private String method;
|
||||
|
||||
/**
|
||||
* 请求参数
|
||||
*/
|
||||
private Object params;
|
||||
|
||||
/**
|
||||
* 响应结果
|
||||
*/
|
||||
private Object data;
|
||||
/**
|
||||
* 响应错误码
|
||||
*/
|
||||
private Integer code;
|
||||
/**
|
||||
* 响应提示
|
||||
*
|
||||
* 特殊:这里阿里云是 message,为了保持和项目的 {@link CommonResult#getMsg()} 一致。
|
||||
*/
|
||||
private String msg;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] encode(IotDeviceMessage message) {
|
||||
AlinkMessage alinkMessage = new AlinkMessage(message.getRequestId(), AlinkMessage.VERSION_1,
|
||||
message.getMethod(), message.getParams(), message.getData(), message.getCode(), message.getMsg());
|
||||
return JsonUtils.toJsonByte(alinkMessage);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("DataFlowIssue")
|
||||
public IotDeviceMessage decode(byte[] bytes) {
|
||||
AlinkMessage alinkMessage = JsonUtils.parseObject(bytes, AlinkMessage.class);
|
||||
Assert.notNull(alinkMessage, "消息不能为空");
|
||||
Assert.equals(alinkMessage.getVersion(), AlinkMessage.VERSION_1, "消息版本号必须是 1.0");
|
||||
return IotDeviceMessage.of(alinkMessage.getId(), alinkMessage.getMethod(), alinkMessage.getParams(),
|
||||
alinkMessage.getData(), alinkMessage.getCode(), alinkMessage.getMsg());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/**
|
||||
* 提供设备接入的各种数据(请求、响应)的编解码
|
||||
*/
|
||||
package cn.iocoder.yudao.module.iot.gateway.codec;
|
||||
@@ -1,4 +0,0 @@
|
||||
/**
|
||||
* TODO @芋艿:实现一个 alink 的 xml 版本
|
||||
*/
|
||||
package cn.iocoder.yudao.module.iot.gateway.codec.simple;
|
||||
@@ -1,110 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.codec.tcp;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.codec.IotDeviceMessageCodec;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* TCP/UDP JSON 格式 {@link IotDeviceMessage} 编解码器
|
||||
*
|
||||
* 采用纯 JSON 格式传输,格式如下:
|
||||
* {
|
||||
* "id": "消息 ID",
|
||||
* "method": "消息方法",
|
||||
* "params": {...}, // 请求参数
|
||||
* "data": {...}, // 响应结果
|
||||
* "code": 200, // 响应错误码
|
||||
* "msg": "success", // 响应提示
|
||||
* "timestamp": 时间戳
|
||||
* }
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Component
|
||||
public class IotTcpJsonDeviceMessageCodec implements IotDeviceMessageCodec {
|
||||
|
||||
public static final String TYPE = "TCP_JSON";
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
private static class TcpJsonMessage {
|
||||
|
||||
/**
|
||||
* 消息 ID,且每个消息 ID 在当前设备具有唯一性
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 请求方法
|
||||
*/
|
||||
private String method;
|
||||
|
||||
/**
|
||||
* 请求参数
|
||||
*/
|
||||
private Object params;
|
||||
|
||||
/**
|
||||
* 响应结果
|
||||
*/
|
||||
private Object data;
|
||||
|
||||
/**
|
||||
* 响应错误码
|
||||
*/
|
||||
private Integer code;
|
||||
|
||||
/**
|
||||
* 响应提示
|
||||
*/
|
||||
private String msg;
|
||||
|
||||
/**
|
||||
* 时间戳
|
||||
*/
|
||||
private Long timestamp;
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] encode(IotDeviceMessage message) {
|
||||
TcpJsonMessage tcpJsonMessage = new TcpJsonMessage(
|
||||
message.getRequestId(),
|
||||
message.getMethod(),
|
||||
message.getParams(),
|
||||
message.getData(),
|
||||
message.getCode(),
|
||||
message.getMsg(),
|
||||
System.currentTimeMillis());
|
||||
return JsonUtils.toJsonByte(tcpJsonMessage);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("DataFlowIssue")
|
||||
public IotDeviceMessage decode(byte[] bytes) {
|
||||
String jsonStr = StrUtil.utf8Str(bytes).trim();
|
||||
TcpJsonMessage tcpJsonMessage = JsonUtils.parseObject(jsonStr, TcpJsonMessage.class);
|
||||
Assert.notNull(tcpJsonMessage, "消息不能为空");
|
||||
Assert.notBlank(tcpJsonMessage.getMethod(), "消息方法不能为空");
|
||||
return IotDeviceMessage.of(
|
||||
tcpJsonMessage.getId(),
|
||||
tcpJsonMessage.getMethod(),
|
||||
tcpJsonMessage.getParams(),
|
||||
tcpJsonMessage.getData(),
|
||||
tcpJsonMessage.getCode(),
|
||||
tcpJsonMessage.getMsg());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,254 +1,28 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.config;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxAuthEventProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.manager.IotMqttConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.router.IotMqttDownstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.manager.IotTcpConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.manager.IotUdpSessionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.manager.IotWebSocketConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.core.Vertx;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocolManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.serialize.IotMessageSerializerManager;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* IoT 网关配置类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(IotGatewayProperties.class)
|
||||
@Slf4j
|
||||
public class IotGatewayConfiguration {
|
||||
|
||||
/**
|
||||
* IoT 网关 HTTP 协议配置类
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.http", name = "enabled", havingValue = "true")
|
||||
@Slf4j
|
||||
public static class HttpProtocolConfiguration {
|
||||
|
||||
@Bean(name = "httpVertx", destroyMethod = "close")
|
||||
public Vertx httpVertx() {
|
||||
return Vertx.vertx();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotHttpUpstreamProtocol iotHttpUpstreamProtocol(IotGatewayProperties gatewayProperties,
|
||||
@Qualifier("httpVertx") Vertx httpVertx) {
|
||||
return new IotHttpUpstreamProtocol(gatewayProperties.getProtocol().getHttp(), httpVertx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotHttpDownstreamSubscriber iotHttpDownstreamSubscriber(IotHttpUpstreamProtocol httpUpstreamProtocol,
|
||||
IotMessageBus messageBus) {
|
||||
return new IotHttpDownstreamSubscriber(httpUpstreamProtocol, messageBus);
|
||||
}
|
||||
@Bean
|
||||
public IotMessageSerializerManager iotMessageSerializerManager() {
|
||||
return new IotMessageSerializerManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT 网关 EMQX 协议配置类
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.emqx", name = "enabled", havingValue = "true")
|
||||
@Slf4j
|
||||
public static class EmqxProtocolConfiguration {
|
||||
|
||||
@Bean(name = "emqxVertx", destroyMethod = "close")
|
||||
public Vertx emqxVertx() {
|
||||
return Vertx.vertx();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotEmqxAuthEventProtocol iotEmqxAuthEventProtocol(IotGatewayProperties gatewayProperties,
|
||||
@Qualifier("emqxVertx") Vertx emqxVertx) {
|
||||
return new IotEmqxAuthEventProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotEmqxUpstreamProtocol iotEmqxUpstreamProtocol(IotGatewayProperties gatewayProperties,
|
||||
@Qualifier("emqxVertx") Vertx emqxVertx) {
|
||||
return new IotEmqxUpstreamProtocol(gatewayProperties.getProtocol().getEmqx(), emqxVertx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotEmqxDownstreamSubscriber iotEmqxDownstreamSubscriber(IotEmqxUpstreamProtocol mqttUpstreamProtocol,
|
||||
IotMessageBus messageBus) {
|
||||
return new IotEmqxDownstreamSubscriber(mqttUpstreamProtocol, messageBus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT 网关 TCP 协议配置类
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.tcp", name = "enabled", havingValue = "true")
|
||||
@Slf4j
|
||||
public static class TcpProtocolConfiguration {
|
||||
|
||||
@Bean(name = "tcpVertx", destroyMethod = "close")
|
||||
public Vertx tcpVertx() {
|
||||
return Vertx.vertx();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotTcpUpstreamProtocol iotTcpUpstreamProtocol(IotGatewayProperties gatewayProperties,
|
||||
IotDeviceService deviceService,
|
||||
IotDeviceMessageService messageService,
|
||||
IotTcpConnectionManager connectionManager,
|
||||
@Qualifier("tcpVertx") Vertx tcpVertx) {
|
||||
return new IotTcpUpstreamProtocol(gatewayProperties.getProtocol().getTcp(),
|
||||
deviceService, messageService, connectionManager, tcpVertx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotTcpDownstreamSubscriber iotTcpDownstreamSubscriber(IotTcpUpstreamProtocol protocolHandler,
|
||||
IotDeviceMessageService messageService,
|
||||
IotTcpConnectionManager connectionManager,
|
||||
IotMessageBus messageBus) {
|
||||
return new IotTcpDownstreamSubscriber(protocolHandler, messageService, connectionManager, messageBus);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT 网关 MQTT 协议配置类
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.mqtt", name = "enabled", havingValue = "true")
|
||||
@Slf4j
|
||||
public static class MqttProtocolConfiguration {
|
||||
|
||||
@Bean(name = "mqttVertx", destroyMethod = "close")
|
||||
public Vertx mqttVertx() {
|
||||
return Vertx.vertx();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotMqttUpstreamProtocol iotMqttUpstreamProtocol(IotGatewayProperties gatewayProperties,
|
||||
IotDeviceMessageService messageService,
|
||||
IotMqttConnectionManager connectionManager,
|
||||
@Qualifier("mqttVertx") Vertx mqttVertx) {
|
||||
return new IotMqttUpstreamProtocol(gatewayProperties.getProtocol().getMqtt(), messageService,
|
||||
connectionManager, mqttVertx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotMqttDownstreamHandler iotMqttDownstreamHandler(IotDeviceMessageService messageService,
|
||||
IotMqttConnectionManager connectionManager) {
|
||||
return new IotMqttDownstreamHandler(messageService, connectionManager);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotMqttDownstreamSubscriber iotMqttDownstreamSubscriber(IotMqttUpstreamProtocol mqttUpstreamProtocol,
|
||||
IotMqttDownstreamHandler downstreamHandler,
|
||||
IotMessageBus messageBus) {
|
||||
return new IotMqttDownstreamSubscriber(mqttUpstreamProtocol, downstreamHandler, messageBus);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT 网关 UDP 协议配置类
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.udp", name = "enabled", havingValue = "true")
|
||||
@Slf4j
|
||||
public static class UdpProtocolConfiguration {
|
||||
|
||||
@Bean(name = "udpVertx", destroyMethod = "close")
|
||||
public Vertx udpVertx() {
|
||||
return Vertx.vertx();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotUdpUpstreamProtocol iotUdpUpstreamProtocol(IotGatewayProperties gatewayProperties,
|
||||
IotDeviceService deviceService,
|
||||
IotDeviceMessageService messageService,
|
||||
IotUdpSessionManager sessionManager,
|
||||
@Qualifier("udpVertx") Vertx udpVertx) {
|
||||
return new IotUdpUpstreamProtocol(gatewayProperties.getProtocol().getUdp(),
|
||||
deviceService, messageService, sessionManager, udpVertx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotUdpDownstreamSubscriber iotUdpDownstreamSubscriber(IotUdpUpstreamProtocol protocolHandler,
|
||||
IotDeviceMessageService messageService,
|
||||
IotUdpSessionManager sessionManager,
|
||||
IotMessageBus messageBus) {
|
||||
return new IotUdpDownstreamSubscriber(protocolHandler, messageService, sessionManager, messageBus);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议配置类
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.coap", name = "enabled", havingValue = "true")
|
||||
@Slf4j
|
||||
public static class CoapProtocolConfiguration {
|
||||
|
||||
@Bean
|
||||
public IotCoapUpstreamProtocol iotCoapUpstreamProtocol(IotGatewayProperties gatewayProperties) {
|
||||
return new IotCoapUpstreamProtocol(gatewayProperties.getProtocol().getCoap());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotCoapDownstreamSubscriber iotCoapDownstreamSubscriber(IotCoapUpstreamProtocol coapUpstreamProtocol,
|
||||
IotMessageBus messageBus) {
|
||||
return new IotCoapDownstreamSubscriber(coapUpstreamProtocol, messageBus);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* IoT 网关 WebSocket 协议配置类
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "yudao.iot.gateway.protocol.websocket", name = "enabled", havingValue = "true")
|
||||
@Slf4j
|
||||
public static class WebSocketProtocolConfiguration {
|
||||
|
||||
@Bean(name = "websocketVertx", destroyMethod = "close")
|
||||
public Vertx websocketVertx() {
|
||||
return Vertx.vertx();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotWebSocketUpstreamProtocol iotWebSocketUpstreamProtocol(IotGatewayProperties gatewayProperties,
|
||||
IotDeviceService deviceService,
|
||||
IotDeviceMessageService messageService,
|
||||
IotWebSocketConnectionManager connectionManager,
|
||||
@Qualifier("websocketVertx") Vertx websocketVertx) {
|
||||
return new IotWebSocketUpstreamProtocol(gatewayProperties.getProtocol().getWebsocket(),
|
||||
deviceService, messageService, connectionManager, websocketVertx);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotWebSocketDownstreamSubscriber iotWebSocketDownstreamSubscriber(IotWebSocketUpstreamProtocol protocolHandler,
|
||||
IotDeviceMessageService messageService,
|
||||
IotWebSocketConnectionManager connectionManager,
|
||||
IotMessageBus messageBus) {
|
||||
return new IotWebSocketDownstreamSubscriber(protocolHandler, messageService, connectionManager, messageBus);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotProtocolManager iotProtocolManager(IotGatewayProperties gatewayProperties) {
|
||||
return new IotProtocolManager(gatewayProperties);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.config;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.IotModbusTcpServerConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpConfig;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketConfig;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
@@ -24,9 +35,9 @@ public class IotGatewayProperties {
|
||||
private TokenProperties token;
|
||||
|
||||
/**
|
||||
* 协议配置
|
||||
* 协议实例列表
|
||||
*/
|
||||
private ProtocolProperties protocol;
|
||||
private List<ProtocolProperties> protocols;
|
||||
|
||||
@Data
|
||||
public static class RpcProperties {
|
||||
@@ -65,582 +76,158 @@ public class IotGatewayProperties {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 协议实例配置
|
||||
*/
|
||||
@Data
|
||||
public static class ProtocolProperties {
|
||||
|
||||
/**
|
||||
* HTTP 组件配置
|
||||
* 协议实例 ID,如 "http-alink"、"tcp-binary"
|
||||
*/
|
||||
private HttpProperties http;
|
||||
|
||||
@NotEmpty(message = "协议实例 ID 不能为空")
|
||||
private String id;
|
||||
/**
|
||||
* EMQX 组件配置
|
||||
* 是否启用
|
||||
*/
|
||||
private EmqxProperties emqx;
|
||||
|
||||
@NotNull(message = "是否启用不能为空")
|
||||
private Boolean enabled = true;
|
||||
/**
|
||||
* TCP 组件配置
|
||||
* 协议类型
|
||||
*
|
||||
* @see cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum
|
||||
*/
|
||||
private TcpProperties tcp;
|
||||
|
||||
/**
|
||||
* MQTT 组件配置
|
||||
*/
|
||||
private MqttProperties mqtt;
|
||||
|
||||
/**
|
||||
* MQTT WebSocket 组件配置
|
||||
*/
|
||||
private MqttWsProperties mqttWs;
|
||||
|
||||
/**
|
||||
* UDP 组件配置
|
||||
*/
|
||||
private UdpProperties udp;
|
||||
|
||||
/**
|
||||
* CoAP 组件配置
|
||||
*/
|
||||
private CoapProperties coap;
|
||||
|
||||
/**
|
||||
* WebSocket 组件配置
|
||||
*/
|
||||
private WebSocketProperties websocket;
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class HttpProperties {
|
||||
|
||||
/**
|
||||
* 是否开启
|
||||
*/
|
||||
@NotNull(message = "是否开启不能为空")
|
||||
private Boolean enabled;
|
||||
@NotEmpty(message = "协议类型不能为空")
|
||||
private String protocol;
|
||||
/**
|
||||
* 服务端口
|
||||
*/
|
||||
private Integer serverPort;
|
||||
|
||||
/**
|
||||
* 是否开启 SSL
|
||||
*/
|
||||
@NotNull(message = "是否开启 SSL 不能为空")
|
||||
private Boolean sslEnabled = false;
|
||||
|
||||
/**
|
||||
* SSL 证书路径
|
||||
*/
|
||||
private String sslKeyPath;
|
||||
/**
|
||||
* SSL 证书路径
|
||||
*/
|
||||
private String sslCertPath;
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class EmqxProperties {
|
||||
|
||||
/**
|
||||
* 是否开启
|
||||
*/
|
||||
@NotNull(message = "是否开启不能为空")
|
||||
private Boolean enabled;
|
||||
|
||||
/**
|
||||
* HTTP 服务端口(默认:8090)
|
||||
*/
|
||||
private Integer httpPort = 8090;
|
||||
|
||||
/**
|
||||
* MQTT 服务器地址
|
||||
*/
|
||||
@NotEmpty(message = "MQTT 服务器地址不能为空")
|
||||
private String mqttHost;
|
||||
|
||||
/**
|
||||
* MQTT 服务器端口(默认:1883)
|
||||
*/
|
||||
@NotNull(message = "MQTT 服务器端口不能为空")
|
||||
private Integer mqttPort = 1883;
|
||||
|
||||
/**
|
||||
* MQTT 用户名
|
||||
*/
|
||||
@NotEmpty(message = "MQTT 用户名不能为空")
|
||||
private String mqttUsername;
|
||||
|
||||
/**
|
||||
* MQTT 密码
|
||||
*/
|
||||
@NotEmpty(message = "MQTT 密码不能为空")
|
||||
private String mqttPassword;
|
||||
|
||||
/**
|
||||
* MQTT 客户端的 SSL 开关
|
||||
*/
|
||||
@NotNull(message = "MQTT 是否开启 SSL 不能为空")
|
||||
private Boolean mqttSsl = false;
|
||||
|
||||
/**
|
||||
* MQTT 客户端 ID(如果为空,系统将自动生成)
|
||||
*/
|
||||
@NotEmpty(message = "MQTT 客户端 ID 不能为空")
|
||||
private String mqttClientId;
|
||||
|
||||
/**
|
||||
* MQTT 订阅的主题
|
||||
*/
|
||||
@NotEmpty(message = "MQTT 主题不能为空")
|
||||
private List<@NotEmpty(message = "MQTT 主题不能为空") String> mqttTopics;
|
||||
|
||||
/**
|
||||
* 默认 QoS 级别
|
||||
* <p>
|
||||
* 0 - 最多一次
|
||||
* 1 - 至少一次
|
||||
* 2 - 刚好一次
|
||||
* 不同协议含义不同:
|
||||
* 1. TCP/UDP/HTTP/WebSocket/MQTT/CoAP:对应网关自身监听的服务端口
|
||||
* 2. EMQX:对应网关提供给 EMQX 回调的 HTTP Hook 端口(/mqtt/auth、/mqtt/acl、/mqtt/event)
|
||||
*/
|
||||
private Integer mqttQos = 1;
|
||||
@NotNull(message = "服务端口不能为空")
|
||||
private Integer port;
|
||||
/**
|
||||
* 序列化类型(可选)
|
||||
*
|
||||
* @see cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum
|
||||
*
|
||||
* 为什么是可选的呢?
|
||||
* 1. {@link IotProtocolTypeEnum#HTTP}、{@link IotProtocolTypeEnum#COAP} 协议,目前强制是 JSON 格式
|
||||
* 2. {@link IotProtocolTypeEnum#EMQX} 协议,目前支持根据产品(设备)配置的序列化类型来解析
|
||||
*/
|
||||
private String serialize;
|
||||
|
||||
// ========== SSL 配置 ==========
|
||||
|
||||
/**
|
||||
* 连接超时时间(秒)
|
||||
* SSL 配置(可选,配置文件中不配置则为 null)
|
||||
*/
|
||||
private Integer connectTimeoutSeconds = 10;
|
||||
@Valid
|
||||
private SslConfig ssl;
|
||||
|
||||
// ========== 各协议配置 ==========
|
||||
|
||||
/**
|
||||
* 重连延迟时间(毫秒)
|
||||
* HTTP 协议配置
|
||||
*/
|
||||
private Long reconnectDelayMs = 5000L;
|
||||
@Valid
|
||||
private IotHttpConfig http;
|
||||
/**
|
||||
* WebSocket 协议配置
|
||||
*/
|
||||
@Valid
|
||||
private IotWebSocketConfig websocket;
|
||||
|
||||
/**
|
||||
* 是否启用 Clean Session (清理会话)
|
||||
* true: 每次连接都是新会话,Broker 不保留离线消息和订阅关系。
|
||||
* 对于网关这类“永远在线”且会主动重新订阅的应用,建议为 true。
|
||||
* TCP 协议配置
|
||||
*/
|
||||
private Boolean cleanSession = true;
|
||||
@Valid
|
||||
private IotTcpConfig tcp;
|
||||
/**
|
||||
* UDP 协议配置
|
||||
*/
|
||||
@Valid
|
||||
private IotUdpConfig udp;
|
||||
|
||||
/**
|
||||
* 心跳间隔(秒)
|
||||
* 用于保持连接活性,及时发现网络中断。
|
||||
* CoAP 协议配置
|
||||
*/
|
||||
private Integer keepAliveIntervalSeconds = 60;
|
||||
@Valid
|
||||
private IotCoapConfig coap;
|
||||
|
||||
/**
|
||||
* 最大未确认消息队列大小
|
||||
* 限制已发送但未收到 Broker 确认的 QoS 1/2 消息数量,用于流量控制。
|
||||
* MQTT 协议配置
|
||||
*/
|
||||
private Integer maxInflightQueue = 10000;
|
||||
@Valid
|
||||
private IotMqttConfig mqtt;
|
||||
/**
|
||||
* EMQX 协议配置
|
||||
*/
|
||||
@Valid
|
||||
private IotEmqxConfig emqx;
|
||||
|
||||
/**
|
||||
* 是否信任所有 SSL 证书
|
||||
* 警告:此配置会绕过证书验证,仅建议在开发和测试环境中使用!
|
||||
* 在生产环境中,应设置为 false,并配置正确的信任库。
|
||||
* Modbus TCP Client 协议配置
|
||||
*/
|
||||
private Boolean trustAll = false;
|
||||
@Valid
|
||||
private IotModbusTcpClientConfig modbusTcpClient;
|
||||
|
||||
/**
|
||||
* 遗嘱消息配置 (用于网关异常下线时通知其他系统)
|
||||
* Modbus TCP Server 协议配置
|
||||
*/
|
||||
private final Will will = new Will();
|
||||
|
||||
/**
|
||||
* 高级 SSL/TLS 配置 (用于生产环境)
|
||||
*/
|
||||
private final Ssl sslOptions = new Ssl();
|
||||
|
||||
/**
|
||||
* 遗嘱消息 (Last Will and Testament)
|
||||
*/
|
||||
@Data
|
||||
public static class Will {
|
||||
|
||||
/**
|
||||
* 是否启用遗嘱消息
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
/**
|
||||
* 遗嘱消息主题
|
||||
*/
|
||||
private String topic;
|
||||
/**
|
||||
* 遗嘱消息内容
|
||||
*/
|
||||
private String payload;
|
||||
/**
|
||||
* 遗嘱消息 QoS 等级
|
||||
*/
|
||||
private Integer qos = 1;
|
||||
/**
|
||||
* 遗嘱消息是否作为保留消息发布
|
||||
*/
|
||||
private boolean retain = true;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 高级 SSL/TLS 配置
|
||||
*/
|
||||
@Data
|
||||
public static class Ssl {
|
||||
|
||||
/**
|
||||
* 密钥库(KeyStore)路径,例如:classpath:certs/client.jks
|
||||
* 包含客户端自己的证书和私钥,用于向服务端证明身份(双向认证)。
|
||||
*/
|
||||
private String keyStorePath;
|
||||
/**
|
||||
* 密钥库密码
|
||||
*/
|
||||
private String keyStorePassword;
|
||||
/**
|
||||
* 信任库(TrustStore)路径,例如:classpath:certs/trust.jks
|
||||
* 包含服务端信任的 CA 证书,用于验证服务端的身份,防止中间人攻击。
|
||||
*/
|
||||
private String trustStorePath;
|
||||
/**
|
||||
* 信任库密码
|
||||
*/
|
||||
private String trustStorePassword;
|
||||
|
||||
}
|
||||
@Valid
|
||||
private IotModbusTcpServerConfig modbusTcpServer;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* SSL 配置
|
||||
*/
|
||||
@Data
|
||||
public static class TcpProperties {
|
||||
|
||||
/**
|
||||
* 是否开启
|
||||
*/
|
||||
@NotNull(message = "是否开启不能为空")
|
||||
private Boolean enabled;
|
||||
|
||||
/**
|
||||
* 服务器端口
|
||||
*/
|
||||
private Integer port = 8091;
|
||||
|
||||
/**
|
||||
* 心跳超时时间(毫秒)
|
||||
*/
|
||||
private Long keepAliveTimeoutMs = 30000L;
|
||||
|
||||
/**
|
||||
* 最大连接数
|
||||
*/
|
||||
private Integer maxConnections = 1000;
|
||||
|
||||
/**
|
||||
* 是否启用SSL
|
||||
*/
|
||||
private Boolean sslEnabled = false;
|
||||
|
||||
/**
|
||||
* SSL证书路径
|
||||
*/
|
||||
private String sslCertPath;
|
||||
|
||||
/**
|
||||
* SSL私钥路径
|
||||
*/
|
||||
private String sslKeyPath;
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MqttProperties {
|
||||
|
||||
/**
|
||||
* 是否开启
|
||||
*/
|
||||
@NotNull(message = "是否开启不能为空")
|
||||
private Boolean enabled;
|
||||
|
||||
/**
|
||||
* 服务器端口
|
||||
*/
|
||||
private Integer port = 1883;
|
||||
|
||||
/**
|
||||
* 最大消息大小(字节)
|
||||
*/
|
||||
private Integer maxMessageSize = 8192;
|
||||
|
||||
/**
|
||||
* 连接超时时间(秒)
|
||||
*/
|
||||
private Integer connectTimeoutSeconds = 60;
|
||||
/**
|
||||
* 保持连接超时时间(秒)
|
||||
*/
|
||||
private Integer keepAliveTimeoutSeconds = 300;
|
||||
public static class SslConfig {
|
||||
|
||||
/**
|
||||
* 是否启用 SSL
|
||||
*/
|
||||
private Boolean sslEnabled = false;
|
||||
/**
|
||||
* SSL 配置
|
||||
*/
|
||||
private SslOptions sslOptions = new SslOptions();
|
||||
|
||||
/**
|
||||
* SSL 配置选项
|
||||
*/
|
||||
@Data
|
||||
public static class SslOptions {
|
||||
|
||||
/**
|
||||
* 密钥证书选项
|
||||
*/
|
||||
private io.vertx.core.net.KeyCertOptions keyCertOptions;
|
||||
/**
|
||||
* 信任选项
|
||||
*/
|
||||
private io.vertx.core.net.TrustOptions trustOptions;
|
||||
/**
|
||||
* SSL 证书路径
|
||||
*/
|
||||
private String certPath;
|
||||
/**
|
||||
* SSL 私钥路径
|
||||
*/
|
||||
private String keyPath;
|
||||
/**
|
||||
* 信任存储路径
|
||||
*/
|
||||
private String trustStorePath;
|
||||
/**
|
||||
* 信任存储密码
|
||||
*/
|
||||
private String trustStorePassword;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class MqttWsProperties {
|
||||
|
||||
/**
|
||||
* 是否开启
|
||||
*/
|
||||
@NotNull(message = "是否开启不能为空")
|
||||
private Boolean enabled;
|
||||
|
||||
/**
|
||||
* WebSocket 服务器端口(默认:8083)
|
||||
*/
|
||||
private Integer port = 8083;
|
||||
|
||||
/**
|
||||
* WebSocket 路径(默认:/mqtt)
|
||||
*/
|
||||
@NotEmpty(message = "WebSocket 路径不能为空")
|
||||
private String path = "/mqtt";
|
||||
|
||||
/**
|
||||
* 最大消息大小(字节)
|
||||
*/
|
||||
private Integer maxMessageSize = 8192;
|
||||
|
||||
/**
|
||||
* 连接超时时间(秒)
|
||||
*/
|
||||
private Integer connectTimeoutSeconds = 60;
|
||||
|
||||
/**
|
||||
* 保持连接超时时间(秒)
|
||||
*/
|
||||
private Integer keepAliveTimeoutSeconds = 300;
|
||||
|
||||
/**
|
||||
* 是否启用 SSL(wss://)
|
||||
*/
|
||||
private Boolean sslEnabled = false;
|
||||
|
||||
/**
|
||||
* SSL 配置
|
||||
*/
|
||||
private SslOptions sslOptions = new SslOptions();
|
||||
|
||||
/**
|
||||
* WebSocket 子协议(通常为 "mqtt" 或 "mqttv3.1")
|
||||
*/
|
||||
@NotEmpty(message = "WebSocket 子协议不能为空")
|
||||
private String subProtocol = "mqtt";
|
||||
|
||||
/**
|
||||
* 最大帧大小(字节)
|
||||
*/
|
||||
private Integer maxFrameSize = 65536;
|
||||
|
||||
/**
|
||||
* SSL 配置选项
|
||||
*/
|
||||
@Data
|
||||
public static class SslOptions {
|
||||
|
||||
/**
|
||||
* 密钥证书选项
|
||||
*/
|
||||
private io.vertx.core.net.KeyCertOptions keyCertOptions;
|
||||
|
||||
/**
|
||||
* 信任选项
|
||||
*/
|
||||
private io.vertx.core.net.TrustOptions trustOptions;
|
||||
|
||||
/**
|
||||
* SSL 证书路径
|
||||
*/
|
||||
private String certPath;
|
||||
|
||||
/**
|
||||
* SSL 私钥路径
|
||||
*/
|
||||
private String keyPath;
|
||||
|
||||
/**
|
||||
* 信任存储路径
|
||||
*/
|
||||
private String trustStorePath;
|
||||
|
||||
/**
|
||||
* 信任存储密码
|
||||
*/
|
||||
private String trustStorePassword;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class UdpProperties {
|
||||
|
||||
/**
|
||||
* 是否开启
|
||||
*/
|
||||
@NotNull(message = "是否开启不能为空")
|
||||
private Boolean enabled;
|
||||
|
||||
/**
|
||||
* 服务端口(默认 8093)
|
||||
*/
|
||||
private Integer port = 8093;
|
||||
|
||||
/**
|
||||
* 接收缓冲区大小(默认 64KB)
|
||||
*/
|
||||
private Integer receiveBufferSize = 65536;
|
||||
|
||||
/**
|
||||
* 发送缓冲区大小(默认 64KB)
|
||||
*/
|
||||
private Integer sendBufferSize = 65536;
|
||||
|
||||
/**
|
||||
* 会话超时时间(毫秒,默认 60 秒)
|
||||
* <p>
|
||||
* 用于清理不活跃的设备地址映射
|
||||
*/
|
||||
private Long sessionTimeoutMs = 60000L;
|
||||
|
||||
/**
|
||||
* 会话清理间隔(毫秒,默认 30 秒)
|
||||
*/
|
||||
private Long sessionCleanIntervalMs = 30000L;
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CoapProperties {
|
||||
|
||||
/**
|
||||
* 是否开启
|
||||
*/
|
||||
@NotNull(message = "是否开启不能为空")
|
||||
private Boolean enabled;
|
||||
|
||||
/**
|
||||
* 服务端口(CoAP 默认端口 5683)
|
||||
*/
|
||||
@NotNull(message = "服务端口不能为空")
|
||||
private Integer port = 5683;
|
||||
|
||||
/**
|
||||
* 最大消息大小(字节)
|
||||
*/
|
||||
@NotNull(message = "最大消息大小不能为空")
|
||||
private Integer maxMessageSize = 1024;
|
||||
|
||||
/**
|
||||
* ACK 超时时间(毫秒)
|
||||
*/
|
||||
@NotNull(message = "ACK 超时时间不能为空")
|
||||
private Integer ackTimeout = 2000;
|
||||
|
||||
/**
|
||||
* 最大重传次数
|
||||
*/
|
||||
@NotNull(message = "最大重传次数不能为空")
|
||||
private Integer maxRetransmit = 4;
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class WebSocketProperties {
|
||||
|
||||
/**
|
||||
* 是否开启
|
||||
*/
|
||||
@NotNull(message = "是否开启不能为空")
|
||||
private Boolean enabled;
|
||||
|
||||
/**
|
||||
* 服务器端口(默认:8094)
|
||||
*/
|
||||
private Integer port = 8094;
|
||||
|
||||
/**
|
||||
* WebSocket 路径(默认:/ws)
|
||||
*/
|
||||
@NotEmpty(message = "WebSocket 路径不能为空")
|
||||
private String path = "/ws";
|
||||
|
||||
/**
|
||||
* 最大消息大小(字节,默认 64KB)
|
||||
*/
|
||||
private Integer maxMessageSize = 65536;
|
||||
|
||||
/**
|
||||
* 最大帧大小(字节,默认 64KB)
|
||||
*/
|
||||
private Integer maxFrameSize = 65536;
|
||||
|
||||
/**
|
||||
* 空闲超时时间(秒,默认 60)
|
||||
*/
|
||||
private Integer idleTimeoutSeconds = 60;
|
||||
|
||||
/**
|
||||
* 是否启用 SSL(wss://)
|
||||
*/
|
||||
private Boolean sslEnabled = false;
|
||||
@NotNull(message = "是否启用 SSL 不能为空")
|
||||
private Boolean ssl = false;
|
||||
|
||||
/**
|
||||
* SSL 证书路径
|
||||
*/
|
||||
@NotEmpty(message = "SSL 证书路径不能为空")
|
||||
private String sslCertPath;
|
||||
|
||||
/**
|
||||
* SSL 私钥路径
|
||||
*/
|
||||
@NotEmpty(message = "SSL 私钥路径不能为空")
|
||||
private String sslKeyPath;
|
||||
|
||||
/**
|
||||
* 密钥库(KeyStore)路径
|
||||
* <p>
|
||||
* 包含客户端自己的证书和私钥,用于向服务端证明身份(双向认证)
|
||||
*/
|
||||
private String keyStorePath;
|
||||
/**
|
||||
* 密钥库密码
|
||||
*/
|
||||
private String keyStorePassword;
|
||||
|
||||
/**
|
||||
* 信任库(TrustStore)路径
|
||||
* <p>
|
||||
* 包含服务端信任的 CA 证书,用于验证服务端的身份
|
||||
*/
|
||||
private String trustStorePath;
|
||||
/**
|
||||
* 信任库密码
|
||||
*/
|
||||
private String trustStorePassword;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,49 +1,53 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router.IotEmqxDownstreamHandler;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT 网关 EMQX 订阅者:接收下行给设备的消息
|
||||
* IoT 协议下行消息订阅者抽象类
|
||||
*
|
||||
* 负责接收来自消息总线的下行消息,并委托给子类进行业务处理
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@Slf4j
|
||||
public class IotEmqxDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> {
|
||||
public abstract class AbstractIotProtocolDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> {
|
||||
|
||||
private final IotEmqxDownstreamHandler downstreamHandler;
|
||||
private final IotProtocol protocol;
|
||||
|
||||
private final IotMessageBus messageBus;
|
||||
|
||||
private final IotEmqxUpstreamProtocol protocol;
|
||||
|
||||
public IotEmqxDownstreamSubscriber(IotEmqxUpstreamProtocol protocol, IotMessageBus messageBus) {
|
||||
this.protocol = protocol;
|
||||
this.messageBus = messageBus;
|
||||
this.downstreamHandler = new IotEmqxDownstreamHandler(protocol);
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
messageBus.register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTopic() {
|
||||
return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group
|
||||
*/
|
||||
@Override
|
||||
public String getGroup() {
|
||||
// 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group
|
||||
return getTopic();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
messageBus.register(this);
|
||||
log.info("[start][{} 下行消息订阅成功,Topic:{}]", protocol.getType().name(), getTopic());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
messageBus.unregister(this);
|
||||
log.info("[stop][{} 下行消息订阅已停止,Topic:{}]", protocol.getType().name(), getTopic());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(IotDeviceMessage message) {
|
||||
log.debug("[onMessage][接收到下行消息, messageId: {}, method: {}, deviceId: {}]",
|
||||
@@ -51,18 +55,25 @@ public class IotEmqxDownstreamSubscriber implements IotMessageSubscriber<IotDevi
|
||||
try {
|
||||
// 1. 校验
|
||||
String method = message.getMethod();
|
||||
if (method == null) {
|
||||
if (StrUtil.isBlank(method)) {
|
||||
log.warn("[onMessage][消息方法为空, messageId: {}, deviceId: {}]",
|
||||
message.getId(), message.getDeviceId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 处理下行消息
|
||||
downstreamHandler.handle(message);
|
||||
handleMessage(message);
|
||||
} catch (Exception e) {
|
||||
log.error("[onMessage][处理下行消息失败, messageId: {}, method: {}, deviceId: {}]",
|
||||
message.getId(), message.getMethod(), message.getDeviceId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* 处理下行消息
|
||||
*
|
||||
* @param message 下行消息
|
||||
*/
|
||||
protected abstract void handleMessage(IotDeviceMessage message);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
|
||||
/**
|
||||
* IoT 协议接口
|
||||
*
|
||||
* 定义传输层协议的生命周期管理
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public interface IotProtocol {
|
||||
|
||||
/**
|
||||
* 获取协议实例 ID
|
||||
*
|
||||
* @return 协议实例 ID,如 "http-alink"、"tcp-binary"
|
||||
*/
|
||||
String getId();
|
||||
|
||||
/**
|
||||
* 获取服务器 ID(用于消息追踪,全局唯一)
|
||||
*
|
||||
* @return 服务器 ID
|
||||
*/
|
||||
String getServerId();
|
||||
|
||||
/**
|
||||
* 获取协议类型
|
||||
*
|
||||
* @return 协议类型枚举
|
||||
*/
|
||||
IotProtocolTypeEnum getType();
|
||||
|
||||
/**
|
||||
* 启动协议服务
|
||||
*/
|
||||
void start();
|
||||
|
||||
/**
|
||||
* 停止协议服务
|
||||
*/
|
||||
void stop();
|
||||
|
||||
/**
|
||||
* 检查协议服务是否正在运行
|
||||
*
|
||||
* @return 是否正在运行
|
||||
*/
|
||||
boolean isRunning();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.IotModbusTcpServerProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.mqtt.IotMqttProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.tcp.IotTcpProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.udp.IotUdpProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.websocket.IotWebSocketProtocol;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.SmartLifecycle;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 协议管理器:负责根据配置创建和管理协议实例
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotProtocolManager implements SmartLifecycle {
|
||||
|
||||
private final IotGatewayProperties gatewayProperties;
|
||||
|
||||
/**
|
||||
* 协议实例列表
|
||||
*/
|
||||
private final List<IotProtocol> protocols = new ArrayList<>();
|
||||
|
||||
@Getter
|
||||
private volatile boolean running = false;
|
||||
|
||||
public IotProtocolManager(IotGatewayProperties gatewayProperties) {
|
||||
this.gatewayProperties = gatewayProperties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
List<IotGatewayProperties.ProtocolProperties> protocolConfigs = gatewayProperties.getProtocols();
|
||||
if (CollUtil.isEmpty(protocolConfigs)) {
|
||||
log.info("[start][没有配置协议实例,跳过启动]");
|
||||
return;
|
||||
}
|
||||
|
||||
for (IotGatewayProperties.ProtocolProperties config : protocolConfigs) {
|
||||
if (BooleanUtil.isFalse(config.getEnabled())) {
|
||||
log.info("[start][协议实例 {} 未启用,跳过]", config.getId());
|
||||
continue;
|
||||
}
|
||||
IotProtocol protocol = createProtocol(config);
|
||||
if (protocol == null) {
|
||||
continue;
|
||||
}
|
||||
protocol.start();
|
||||
protocols.add(protocol);
|
||||
}
|
||||
running = true;
|
||||
log.info("[start][协议管理器启动完成,共启动 {} 个协议实例]", protocols.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
for (IotProtocol protocol : protocols) {
|
||||
try {
|
||||
protocol.stop();
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][协议实例 {} 停止失败]", protocol.getId(), e);
|
||||
}
|
||||
}
|
||||
protocols.clear();
|
||||
running = false;
|
||||
log.info("[stop][协议管理器已停止]");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return 协议实例
|
||||
*/
|
||||
@SuppressWarnings({"EnhancedSwitchMigration"})
|
||||
private IotProtocol createProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
IotProtocolTypeEnum protocolType = IotProtocolTypeEnum.of(config.getProtocol());
|
||||
if (protocolType == null) {
|
||||
log.error("[createProtocol][协议实例 {} 的协议类型 {} 不存在]", config.getId(), config.getProtocol());
|
||||
return null;
|
||||
}
|
||||
switch (protocolType) {
|
||||
case HTTP:
|
||||
return createHttpProtocol(config);
|
||||
case TCP:
|
||||
return createTcpProtocol(config);
|
||||
case UDP:
|
||||
return createUdpProtocol(config);
|
||||
case COAP:
|
||||
return createCoapProtocol(config);
|
||||
case WEBSOCKET:
|
||||
return createWebSocketProtocol(config);
|
||||
case MQTT:
|
||||
return createMqttProtocol(config);
|
||||
case EMQX:
|
||||
return createEmqxProtocol(config);
|
||||
case MODBUS_TCP_CLIENT:
|
||||
return createModbusTcpClientProtocol(config);
|
||||
case MODBUS_TCP_SERVER:
|
||||
return createModbusTcpServerProtocol(config);
|
||||
default:
|
||||
throw new IllegalArgumentException(String.format(
|
||||
"[createProtocol][协议实例 %s 的协议类型 %s 暂不支持]", config.getId(), protocolType));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 HTTP 协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return HTTP 协议实例
|
||||
*/
|
||||
private IotHttpProtocol createHttpProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotHttpProtocol(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 TCP 协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return TCP 协议实例
|
||||
*/
|
||||
private IotTcpProtocol createTcpProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotTcpProtocol(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 UDP 协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return UDP 协议实例
|
||||
*/
|
||||
private IotUdpProtocol createUdpProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotUdpProtocol(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 CoAP 协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return CoAP 协议实例
|
||||
*/
|
||||
private IotCoapProtocol createCoapProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotCoapProtocol(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 WebSocket 协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return WebSocket 协议实例
|
||||
*/
|
||||
private IotWebSocketProtocol createWebSocketProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotWebSocketProtocol(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 MQTT 协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return MQTT 协议实例
|
||||
*/
|
||||
private IotMqttProtocol createMqttProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotMqttProtocol(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 EMQX 协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return EMQX 协议实例
|
||||
*/
|
||||
private IotEmqxProtocol createEmqxProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotEmqxProtocol(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Modbus TCP Client 协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return Modbus TCP Client 协议实例
|
||||
*/
|
||||
private IotModbusTcpClientProtocol createModbusTcpClientProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotModbusTcpClientProtocol(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Modbus TCP Server 协议实例
|
||||
*
|
||||
* @param config 协议实例配置
|
||||
* @return Modbus TCP Server 协议实例
|
||||
*/
|
||||
private IotModbusTcpServerProtocol createModbusTcpServerProtocol(IotGatewayProperties.ProtocolProperties config) {
|
||||
return new IotModbusTcpServerProtocol(config);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT CoAP 协议配置
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
public class IotCoapConfig {
|
||||
|
||||
/**
|
||||
* 最大消息大小(字节)
|
||||
*/
|
||||
@NotNull(message = "最大消息大小不能为空")
|
||||
@Min(value = 64, message = "最大消息大小必须大于 64 字节")
|
||||
private Integer maxMessageSize = 1024;
|
||||
|
||||
/**
|
||||
* ACK 超时时间(毫秒)
|
||||
*/
|
||||
@NotNull(message = "ACK 超时时间不能为空")
|
||||
@Min(value = 100, message = "ACK 超时时间必须大于 100 毫秒")
|
||||
private Integer ackTimeoutMs = 2000;
|
||||
|
||||
/**
|
||||
* 最大重传次数
|
||||
*/
|
||||
@NotNull(message = "最大重传次数不能为空")
|
||||
@Min(value = 0, message = "最大重传次数必须大于等于 0")
|
||||
private Integer maxRetransmit = 4;
|
||||
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 订阅者:接收下行给设备的消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IotCoapDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> {
|
||||
|
||||
private final IotCoapUpstreamProtocol protocol;
|
||||
|
||||
private final IotMessageBus messageBus;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
messageBus.register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTopic() {
|
||||
return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGroup() {
|
||||
// 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group
|
||||
return getTopic();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(IotDeviceMessage message) {
|
||||
// 如需支持,可通过 CoAP Observe 模式实现(设备订阅资源,服务器推送变更)
|
||||
log.warn("[onMessage][IoT 网关 CoAP 协议暂不支持下行消息,忽略消息:{}]", message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.downstream.IotCoapDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream.*;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapResource;
|
||||
import org.eclipse.californium.core.CoapServer;
|
||||
import org.eclipse.californium.core.config.CoapConfig;
|
||||
import org.eclipse.californium.elements.config.Configuration;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* IoT CoAP 协议实现
|
||||
* <p>
|
||||
* 基于 Eclipse Californium 实现,支持:
|
||||
* 1. 认证:POST /auth
|
||||
* 2. 设备动态注册:POST /auth/register/device
|
||||
* 3. 子设备动态注册:POST /auth/register/sub-device/{productKey}/{deviceName}
|
||||
* 4. 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post
|
||||
* 5. 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapProtocol implements IotProtocol {
|
||||
|
||||
/**
|
||||
* 协议配置
|
||||
*/
|
||||
private final ProtocolProperties properties;
|
||||
/**
|
||||
* 服务器 ID(用于消息追踪,全局唯一)
|
||||
*/
|
||||
@Getter
|
||||
private final String serverId;
|
||||
|
||||
/**
|
||||
* 运行状态
|
||||
*/
|
||||
@Getter
|
||||
private volatile boolean running = false;
|
||||
|
||||
/**
|
||||
* CoAP 服务器
|
||||
*/
|
||||
private CoapServer coapServer;
|
||||
|
||||
/**
|
||||
* 下行消息订阅者
|
||||
*/
|
||||
private IotCoapDownstreamSubscriber downstreamSubscriber;
|
||||
|
||||
public IotCoapProtocol(ProtocolProperties properties) {
|
||||
IotCoapConfig coapConfig = properties.getCoap();
|
||||
Assert.notNull(coapConfig, "CoAP 协议配置(coap)不能为空");
|
||||
this.properties = properties;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return properties.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IotProtocolTypeEnum getType() {
|
||||
return IotProtocolTypeEnum.COAP;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (running) {
|
||||
log.warn("[start][IoT CoAP 协议 {} 已经在运行中]", getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1.1 创建 CoAP 配置
|
||||
IotCoapConfig coapConfig = properties.getCoap();
|
||||
Configuration config = Configuration.createStandardWithoutFile();
|
||||
config.set(CoapConfig.COAP_PORT, properties.getPort());
|
||||
config.set(CoapConfig.MAX_MESSAGE_SIZE, coapConfig.getMaxMessageSize());
|
||||
config.set(CoapConfig.ACK_TIMEOUT, coapConfig.getAckTimeoutMs(), TimeUnit.MILLISECONDS);
|
||||
config.set(CoapConfig.MAX_RETRANSMIT, coapConfig.getMaxRetransmit());
|
||||
// 1.2 创建 CoAP 服务器
|
||||
coapServer = new CoapServer(config);
|
||||
|
||||
// 2.1 添加 /auth 认证资源
|
||||
IotCoapAuthHandler authHandler = new IotCoapAuthHandler(serverId);
|
||||
IotCoapAuthResource authResource = new IotCoapAuthResource(authHandler);
|
||||
coapServer.add(authResource);
|
||||
// 2.2 添加 /auth/register/device 设备动态注册资源(一型一密)
|
||||
IotCoapRegisterHandler registerHandler = new IotCoapRegisterHandler();
|
||||
IotCoapRegisterResource registerResource = new IotCoapRegisterResource(registerHandler);
|
||||
// 2.3 添加 /auth/register/sub-device/{productKey}/{deviceName} 子设备动态注册资源
|
||||
IotCoapRegisterSubHandler registerSubHandler = new IotCoapRegisterSubHandler();
|
||||
IotCoapRegisterSubResource registerSubResource = new IotCoapRegisterSubResource(registerSubHandler);
|
||||
authResource.add(new CoapResource("register") {{
|
||||
add(registerResource);
|
||||
add(registerSubResource);
|
||||
}});
|
||||
// 2.4 添加 /topic 根资源(用于上行消息)
|
||||
IotCoapUpstreamHandler upstreamHandler = new IotCoapUpstreamHandler(serverId);
|
||||
IotCoapUpstreamTopicResource topicResource = new IotCoapUpstreamTopicResource(serverId, upstreamHandler);
|
||||
coapServer.add(topicResource);
|
||||
|
||||
// 3. 启动服务器
|
||||
coapServer.start();
|
||||
running = true;
|
||||
log.info("[start][IoT CoAP 协议 {} 启动成功,端口:{},serverId:{}]",
|
||||
getId(), properties.getPort(), serverId);
|
||||
|
||||
// 4. 启动下行消息订阅者
|
||||
IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class);
|
||||
this.downstreamSubscriber = new IotCoapDownstreamSubscriber(this, messageBus);
|
||||
this.downstreamSubscriber.start();
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT CoAP 协议 {} 启动失败]", getId(), e);
|
||||
stop0();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
stop0();
|
||||
}
|
||||
|
||||
private void stop0() {
|
||||
// 1. 停止下行消息订阅者
|
||||
if (downstreamSubscriber != null) {
|
||||
try {
|
||||
downstreamSubscriber.stop();
|
||||
log.info("[stop][IoT CoAP 协议 {} 下行消息订阅者已停止]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT CoAP 协议 {} 下行消息订阅者停止失败]", getId(), e);
|
||||
}
|
||||
downstreamSubscriber = null;
|
||||
}
|
||||
|
||||
// 2. 关闭 CoAP 服务器
|
||||
if (coapServer != null) {
|
||||
try {
|
||||
coapServer.stop();
|
||||
coapServer.destroy();
|
||||
coapServer = null;
|
||||
log.info("[stop][IoT CoAP 协议 {} 服务器已停止]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT CoAP 协议 {} 服务器停止失败]", getId(), e);
|
||||
}
|
||||
}
|
||||
running = false;
|
||||
log.info("[stop][IoT CoAP 协议 {} 已停止]", getId());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.router.*;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapResource;
|
||||
import org.eclipse.californium.core.CoapServer;
|
||||
import org.eclipse.californium.core.config.CoapConfig;
|
||||
import org.eclipse.californium.elements.config.Configuration;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议:接收设备上行消息
|
||||
*
|
||||
* 基于 Eclipse Californium 实现,支持:
|
||||
* 1. 认证:POST /auth
|
||||
* 2. 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post
|
||||
* 3. 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapUpstreamProtocol {
|
||||
|
||||
private final IotGatewayProperties.CoapProperties coapProperties;
|
||||
|
||||
private CoapServer coapServer;
|
||||
|
||||
@Getter
|
||||
private final String serverId;
|
||||
|
||||
public IotCoapUpstreamProtocol(IotGatewayProperties.CoapProperties coapProperties) {
|
||||
this.coapProperties = coapProperties;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(coapProperties.getPort());
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void start() {
|
||||
try {
|
||||
// 1.1 创建网络配置(Californium 3.x API)
|
||||
Configuration config = Configuration.createStandardWithoutFile();
|
||||
config.set(CoapConfig.COAP_PORT, coapProperties.getPort());
|
||||
config.set(CoapConfig.MAX_MESSAGE_SIZE, coapProperties.getMaxMessageSize());
|
||||
config.set(CoapConfig.ACK_TIMEOUT, coapProperties.getAckTimeout(), TimeUnit.MILLISECONDS);
|
||||
config.set(CoapConfig.MAX_RETRANSMIT, coapProperties.getMaxRetransmit());
|
||||
// 1.2 创建 CoAP 服务器
|
||||
coapServer = new CoapServer(config);
|
||||
|
||||
// 2.1 添加 /auth 认证资源
|
||||
IotCoapAuthHandler authHandler = new IotCoapAuthHandler();
|
||||
IotCoapAuthResource authResource = new IotCoapAuthResource(this, authHandler);
|
||||
coapServer.add(authResource);
|
||||
// 2.2 添加 /auth/register/device 设备动态注册资源(一型一密)
|
||||
IotCoapRegisterHandler registerHandler = new IotCoapRegisterHandler();
|
||||
IotCoapRegisterResource registerResource = new IotCoapRegisterResource(registerHandler);
|
||||
authResource.add(new CoapResource("register") {{
|
||||
add(registerResource);
|
||||
}});
|
||||
// 2.3 添加 /topic 根资源(用于上行消息)
|
||||
IotCoapUpstreamHandler upstreamHandler = new IotCoapUpstreamHandler();
|
||||
IotCoapUpstreamTopicResource topicResource = new IotCoapUpstreamTopicResource(this, upstreamHandler);
|
||||
coapServer.add(topicResource);
|
||||
|
||||
// 3. 启动服务器
|
||||
coapServer.start();
|
||||
log.info("[start][IoT 网关 CoAP 协议启动成功,端口:{},资源:/auth, /auth/register/device, /topic]", coapProperties.getPort());
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT 网关 CoAP 协议启动失败]", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
if (coapServer != null) {
|
||||
try {
|
||||
coapServer.stop();
|
||||
log.info("[stop][IoT 网关 CoAP 协议已停止]");
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT 网关 CoAP 协议停止失败]", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.downstream;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapProtocol;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 订阅者:接收下行给设备的消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber {
|
||||
|
||||
public IotCoapDownstreamSubscriber(IotCoapProtocol protocol, IotMessageBus messageBus) {
|
||||
super(protocol, messageBus);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleMessage(IotDeviceMessage message) {
|
||||
// 如需支持,可通过 CoAP Observe 模式实现(设备订阅资源,服务器推送变更)
|
||||
log.warn("[handleMessage][IoT 网关 CoAP 协议暂不支持下行消息,忽略消息:{}]", message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.coap.CoAP;
|
||||
import org.eclipse.californium.core.coap.MediaTypeRegistry;
|
||||
import org.eclipse.californium.core.coap.Option;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的处理器抽象基类:提供通用的前置处理(认证)、请求解析、响应处理、全局的异常捕获等
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public abstract class IotCoapAbstractHandler {
|
||||
|
||||
/**
|
||||
* 自定义 CoAP Option 编号,用于携带 Token
|
||||
* <p>
|
||||
* CoAP Option 范围 2048-65535 属于实验/自定义范围
|
||||
*/
|
||||
public static final int OPTION_TOKEN = 2088;
|
||||
|
||||
private final IotDeviceTokenService deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class);
|
||||
|
||||
/**
|
||||
* 处理 CoAP 请求(模板方法)
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
*/
|
||||
public final void handle(CoapExchange exchange) {
|
||||
try {
|
||||
// 1. 前置处理
|
||||
beforeHandle(exchange);
|
||||
|
||||
// 2. 执行业务逻辑
|
||||
CommonResult<Object> result = handle0(exchange);
|
||||
writeResponse(exchange, result);
|
||||
} catch (ServiceException e) {
|
||||
// 业务异常,返回对应的错误码和消息
|
||||
writeResponse(exchange, CommonResult.error(e.getCode(), e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 参数校验异常(hutool Assert 抛出),返回 BAD_REQUEST
|
||||
writeResponse(exchange, CommonResult.error(BAD_REQUEST.getCode(), e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
// 其他未知异常,返回 INTERNAL_SERVER_ERROR
|
||||
log.error("[handle][CoAP 请求处理异常]", e);
|
||||
writeResponse(exchange, CommonResult.error(INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 CoAP 请求(子类实现)
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @return 处理结果
|
||||
*/
|
||||
protected abstract CommonResult<Object> handle0(CoapExchange exchange);
|
||||
|
||||
/**
|
||||
* 前置处理:认证等
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
*/
|
||||
private void beforeHandle(CoapExchange exchange) {
|
||||
// 1.1 如果不需要认证,则不走前置处理
|
||||
if (!requiresAuthentication()) {
|
||||
return;
|
||||
}
|
||||
// 1.2 从自定义 Option 获取 token
|
||||
String token = getTokenFromOption(exchange);
|
||||
if (StrUtil.isEmpty(token)) {
|
||||
throw exception(UNAUTHORIZED);
|
||||
}
|
||||
// 1.3 校验 token
|
||||
IotDeviceIdentity deviceInfo = deviceTokenService.verifyToken(token);
|
||||
if (deviceInfo == null) {
|
||||
throw exception(UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// 2.1 解析 productKey 和 deviceName
|
||||
List<String> uriPath = exchange.getRequestOptions().getUriPath();
|
||||
String productKey = getProductKey(uriPath);
|
||||
String deviceName = getDeviceName(uriPath);
|
||||
if (StrUtil.isEmpty(productKey) || StrUtil.isEmpty(deviceName)) {
|
||||
throw exception(BAD_REQUEST);
|
||||
}
|
||||
// 2.2 校验设备信息是否匹配
|
||||
if (ObjUtil.notEqual(productKey, deviceInfo.getProductKey())
|
||||
|| ObjUtil.notEqual(deviceName, deviceInfo.getDeviceName())) {
|
||||
throw exception(FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Token 相关方法 ==========
|
||||
|
||||
/**
|
||||
* 是否需要认证(子类可覆盖)
|
||||
* <p>
|
||||
* 默认不需要认证
|
||||
*
|
||||
* @return 是否需要认证
|
||||
*/
|
||||
protected boolean requiresAuthentication() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URI 路径中获取 productKey(子类实现)
|
||||
* <p>
|
||||
* 默认抛出异常,需要认证的子类必须实现此方法
|
||||
*
|
||||
* @param uriPath URI 路径
|
||||
* @return productKey
|
||||
*/
|
||||
protected String getProductKey(List<String> uriPath) {
|
||||
throw new UnsupportedOperationException("子类需要实现 getProductKey 方法");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URI 路径中获取 deviceName(子类实现)
|
||||
* <p>
|
||||
* 默认抛出异常,需要认证的子类必须实现此方法
|
||||
*
|
||||
* @param uriPath URI 路径
|
||||
* @return deviceName
|
||||
*/
|
||||
protected String getDeviceName(List<String> uriPath) {
|
||||
throw new UnsupportedOperationException("子类需要实现 getDeviceName 方法");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从自定义 CoAP Option 中获取 Token
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @return Token 值,如果不存在则返回 null
|
||||
*/
|
||||
protected String getTokenFromOption(CoapExchange exchange) {
|
||||
Option option = CollUtil.findOne(exchange.getRequestOptions().getOthers(),
|
||||
o -> o.getNumber() == OPTION_TOKEN);
|
||||
return option != null ? new String(option.getValue()) : null;
|
||||
}
|
||||
|
||||
// ========== 序列化相关方法 ==========
|
||||
|
||||
/**
|
||||
* 解析请求体为指定类型
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @param clazz 目标类型
|
||||
* @param <T> 目标类型泛型
|
||||
* @return 解析后的对象,解析失败返回 null
|
||||
*/
|
||||
protected <T> T deserializeRequest(CoapExchange exchange, Class<T> clazz) {
|
||||
byte[] payload = exchange.getRequestPayload();
|
||||
if (ArrayUtil.isEmpty(payload)) {
|
||||
return null;
|
||||
}
|
||||
return JsonUtils.parseObject(payload, clazz);
|
||||
}
|
||||
|
||||
private static String serializeResponse(Object data) {
|
||||
return JsonUtils.toJsonString(data);
|
||||
}
|
||||
|
||||
protected void writeResponse(CoapExchange exchange, CommonResult<?> data) {
|
||||
String json = serializeResponse(data);
|
||||
exchange.respond(CoAP.ResponseCode.CONTENT, json, MediaTypeRegistry.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的【认证】处理器
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapAuthHandler extends IotCoapAbstractHandler {
|
||||
|
||||
private final String serverId;
|
||||
|
||||
private final IotDeviceTokenService deviceTokenService;
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
public IotCoapAuthHandler(String serverId) {
|
||||
this.serverId = serverId;
|
||||
this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class);
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("DuplicatedCode")
|
||||
protected CommonResult<Object> handle0(CoapExchange exchange) {
|
||||
// 1. 解析参数
|
||||
IotDeviceAuthReqDTO request = deserializeRequest(exchange, IotDeviceAuthReqDTO.class);
|
||||
Assert.notNull(request, "请求体不能为空");
|
||||
Assert.notBlank(request.getClientId(), "clientId 不能为空");
|
||||
Assert.notBlank(request.getUsername(), "username 不能为空");
|
||||
Assert.notBlank(request.getPassword(), "password 不能为空");
|
||||
|
||||
// 2.1 执行认证
|
||||
CommonResult<Boolean> result = deviceApi.authDevice(request);
|
||||
result.checkError();
|
||||
if (BooleanUtil.isFalse(result.getData())) {
|
||||
throw exception(DEVICE_AUTH_FAIL);
|
||||
}
|
||||
// 2.2 生成 Token
|
||||
IotDeviceIdentity deviceInfo = deviceTokenService.parseUsername(request.getUsername());
|
||||
Assert.notNull(deviceInfo, "设备信息不能为空");
|
||||
String token = deviceTokenService.createToken(deviceInfo.getProductKey(), deviceInfo.getDeviceName());
|
||||
Assert.notBlank(token, "生成 token 不能为空");
|
||||
|
||||
// 3. 执行上线
|
||||
IotDeviceMessage message = IotDeviceMessage.buildStateUpdateOnline();
|
||||
deviceMessageService.sendDeviceMessage(message,
|
||||
deviceInfo.getProductKey(), deviceInfo.getDeviceName(), serverId);
|
||||
|
||||
// 4. 构建响应数据
|
||||
return CommonResult.success(MapUtil.of("token", token));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapUpstreamProtocol;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapResource;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
@@ -17,13 +16,10 @@ public class IotCoapAuthResource extends CoapResource {
|
||||
|
||||
public static final String PATH = "auth";
|
||||
|
||||
private final IotCoapUpstreamProtocol protocol;
|
||||
private final IotCoapAuthHandler authHandler;
|
||||
|
||||
public IotCoapAuthResource(IotCoapUpstreamProtocol protocol,
|
||||
IotCoapAuthHandler authHandler) {
|
||||
public IotCoapAuthResource(IotCoapAuthHandler authHandler) {
|
||||
super(PATH);
|
||||
this.protocol = protocol;
|
||||
this.authHandler = authHandler;
|
||||
log.info("[IotCoapAuthResource][创建 CoAP 认证资源: /{}]", PATH);
|
||||
}
|
||||
@@ -31,7 +27,7 @@ public class IotCoapAuthResource extends CoapResource {
|
||||
@Override
|
||||
public void handlePOST(CoapExchange exchange) {
|
||||
log.debug("[handlePOST][收到 /auth POST 请求]");
|
||||
authHandler.handle(exchange, protocol);
|
||||
authHandler.handle(exchange);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的【设备动态注册】处理器
|
||||
* <p>
|
||||
* 用于直连设备/网关的一型一密动态注册,不需要认证
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification">阿里云 - 一型一密</a>
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapRegisterHandler extends IotCoapAbstractHandler {
|
||||
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
|
||||
public IotCoapRegisterHandler() {
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected CommonResult<Object> handle0(CoapExchange exchange) {
|
||||
// 1. 解析参数
|
||||
IotDeviceRegisterReqDTO request = deserializeRequest(exchange, IotDeviceRegisterReqDTO.class);
|
||||
Assert.notNull(request, "请求体不能为空");
|
||||
Assert.notBlank(request.getProductKey(), "productKey 不能为空");
|
||||
Assert.notBlank(request.getDeviceName(), "deviceName 不能为空");
|
||||
Assert.notBlank(request.getSign(), "sign 不能为空");
|
||||
|
||||
// 2. 调用动态注册
|
||||
CommonResult<IotDeviceRegisterRespDTO> result = deviceApi.registerDevice(request);
|
||||
result.checkError();
|
||||
|
||||
// 3. 构建响应数据
|
||||
return CommonResult.success(result.getData());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapResource;
|
||||
@@ -0,0 +1,84 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的【子设备动态注册】处理器
|
||||
* <p>
|
||||
* 用于子设备的动态注册,需要网关认证
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/register-devices">阿里云 - 动态注册子设备</a>
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapRegisterSubHandler extends IotCoapAbstractHandler {
|
||||
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
|
||||
public IotCoapRegisterSubHandler() {
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("DuplicatedCode")
|
||||
protected CommonResult<Object> handle0(CoapExchange exchange) {
|
||||
// 1.1 解析通用参数(从 URI 路径获取网关设备信息)
|
||||
List<String> uriPath = exchange.getRequestOptions().getUriPath();
|
||||
String productKey = getProductKey(uriPath);
|
||||
String deviceName = getDeviceName(uriPath);
|
||||
// 1.2 解析子设备列表
|
||||
SubDeviceRegisterRequest request = deserializeRequest(exchange, SubDeviceRegisterRequest.class);
|
||||
Assert.notNull(request, "请求参数不能为空");
|
||||
Assert.notEmpty(request.getParams(), "params 不能为空");
|
||||
|
||||
// 2. 调用子设备动态注册
|
||||
IotSubDeviceRegisterFullReqDTO reqDTO = new IotSubDeviceRegisterFullReqDTO()
|
||||
.setGatewayProductKey(productKey)
|
||||
.setGatewayDeviceName(deviceName)
|
||||
.setSubDevices(request.getParams());
|
||||
CommonResult<List<IotSubDeviceRegisterRespDTO>> result = deviceApi.registerSubDevices(reqDTO);
|
||||
result.checkError();
|
||||
|
||||
// 3. 返回结果
|
||||
return success(result.getData());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean requiresAuthentication() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getProductKey(List<String> uriPath) {
|
||||
// 路径格式:/auth/register/sub-device/{productKey}/{deviceName}
|
||||
return CollUtil.get(uriPath, 3);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getDeviceName(List<String> uriPath) {
|
||||
// 路径格式:/auth/register/sub-device/{productKey}/{deviceName}
|
||||
return CollUtil.get(uriPath, 4);
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SubDeviceRegisterRequest {
|
||||
|
||||
private List<IotSubDeviceRegisterReqDTO> params;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapResource;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
import org.eclipse.californium.core.server.resources.Resource;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的子设备动态注册资源(/auth/register/sub-device/{productKey}/{deviceName})
|
||||
* <p>
|
||||
* 用于子设备的动态注册,需要网关认证
|
||||
* <p>
|
||||
* 支持动态路径匹配:productKey 和 deviceName 是网关设备的标识
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapRegisterSubResource extends CoapResource {
|
||||
|
||||
public static final String PATH = "sub-device";
|
||||
|
||||
private final IotCoapRegisterSubHandler registerSubHandler;
|
||||
|
||||
/**
|
||||
* 创建根资源(/auth/register/sub-device)
|
||||
*/
|
||||
public IotCoapRegisterSubResource(IotCoapRegisterSubHandler registerSubHandler) {
|
||||
this(PATH, registerSubHandler);
|
||||
log.info("[IotCoapRegisterSubResource][创建 CoAP 子设备动态注册资源: /auth/register/{}]", PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建子资源(动态路径)
|
||||
*/
|
||||
private IotCoapRegisterSubResource(String name, IotCoapRegisterSubHandler registerSubHandler) {
|
||||
super(name);
|
||||
this.registerSubHandler = registerSubHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resource getChild(String name) {
|
||||
// 递归创建动态子资源,支持 /sub-device/{productKey}/{deviceName} 路径
|
||||
return new IotCoapRegisterSubResource(name, registerSubHandler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlePOST(CoapExchange exchange) {
|
||||
log.debug("[handlePOST][收到子设备动态注册请求]");
|
||||
registerSubHandler.handle(exchange);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.text.StrPool;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的【上行】处理器
|
||||
*
|
||||
* 处理设备通过 CoAP 协议发送的上行消息,包括:
|
||||
* 1. 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post
|
||||
* 2. 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post
|
||||
*
|
||||
* Token 通过自定义 CoAP Option 2088 携带
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapUpstreamHandler extends IotCoapAbstractHandler {
|
||||
|
||||
private final String serverId;
|
||||
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
public IotCoapUpstreamHandler(String serverId) {
|
||||
this.serverId = serverId;
|
||||
this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("DuplicatedCode")
|
||||
protected CommonResult<Object> handle0(CoapExchange exchange) {
|
||||
// 1.1 解析通用参数
|
||||
List<String> uriPath = exchange.getRequestOptions().getUriPath();
|
||||
String productKey = getProductKey(uriPath);
|
||||
String deviceName = getDeviceName(uriPath);
|
||||
String method = String.join(StrPool.DOT, uriPath.subList(4, uriPath.size()));
|
||||
// 1.2 解析消息
|
||||
IotDeviceMessage message = deserializeRequest(exchange, IotDeviceMessage.class);
|
||||
Assert.notNull(message, "请求参数不能为空");
|
||||
Assert.equals(method, message.getMethod(), "method 不匹配");
|
||||
|
||||
// 2. 发送消息
|
||||
deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId);
|
||||
|
||||
// 3. 返回结果
|
||||
return CommonResult.success(MapUtil.of("messageId", message.getId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean requiresAuthentication() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getProductKey(List<String> uriPath) {
|
||||
// 路径格式:/topic/sys/{productKey}/{deviceName}/...
|
||||
return CollUtil.get(uriPath, 2);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getDeviceName(List<String> uriPath) {
|
||||
// 路径格式:/topic/sys/{productKey}/{deviceName}/...
|
||||
return CollUtil.get(uriPath, 3);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.handler.upstream;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapUpstreamProtocol;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.CoapResource;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
@@ -20,15 +19,15 @@ public class IotCoapUpstreamTopicResource extends CoapResource {
|
||||
|
||||
public static final String PATH = "topic";
|
||||
|
||||
private final IotCoapUpstreamProtocol protocol;
|
||||
private final String serverId;
|
||||
private final IotCoapUpstreamHandler upstreamHandler;
|
||||
|
||||
/**
|
||||
* 创建根资源(/topic)
|
||||
*/
|
||||
public IotCoapUpstreamTopicResource(IotCoapUpstreamProtocol protocol,
|
||||
public IotCoapUpstreamTopicResource(String serverId,
|
||||
IotCoapUpstreamHandler upstreamHandler) {
|
||||
this(PATH, protocol, upstreamHandler);
|
||||
this(PATH, serverId, upstreamHandler);
|
||||
log.info("[IotCoapUpstreamTopicResource][创建 CoAP 上行 Topic 资源: /{}]", PATH);
|
||||
}
|
||||
|
||||
@@ -36,32 +35,32 @@ public class IotCoapUpstreamTopicResource extends CoapResource {
|
||||
* 创建子资源(动态路径)
|
||||
*/
|
||||
private IotCoapUpstreamTopicResource(String name,
|
||||
IotCoapUpstreamProtocol protocol,
|
||||
String serverId,
|
||||
IotCoapUpstreamHandler upstreamHandler) {
|
||||
super(name);
|
||||
this.protocol = protocol;
|
||||
this.serverId = serverId;
|
||||
this.upstreamHandler = upstreamHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Resource getChild(String name) {
|
||||
// 递归创建动态子资源,支持任意深度路径
|
||||
return new IotCoapUpstreamTopicResource(name, protocol, upstreamHandler);
|
||||
return new IotCoapUpstreamTopicResource(name, serverId, upstreamHandler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleGET(CoapExchange exchange) {
|
||||
upstreamHandler.handle(exchange, protocol);
|
||||
upstreamHandler.handle(exchange);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlePOST(CoapExchange exchange) {
|
||||
upstreamHandler.handle(exchange, protocol);
|
||||
upstreamHandler.handle(exchange);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlePUT(CoapExchange exchange) {
|
||||
upstreamHandler.handle(exchange, protocol);
|
||||
upstreamHandler.handle(exchange);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,12 +2,5 @@
|
||||
* CoAP 协议实现包
|
||||
* <p>
|
||||
* 提供基于 Eclipse Californium 的 IoT 设备连接和消息处理功能
|
||||
* <p>
|
||||
* URI 路径:
|
||||
* - 认证:POST /auth
|
||||
* - 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post
|
||||
* - 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post
|
||||
* <p>
|
||||
* Token 通过 CoAP Option 2088 携带
|
||||
*/
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap;
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.util.IotCoapUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.coap.CoAP;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的【认证】处理器
|
||||
*
|
||||
* 参考 {@link cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpAuthHandler}
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapAuthHandler {
|
||||
|
||||
private final IotDeviceTokenService deviceTokenService;
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
public IotCoapAuthHandler() {
|
||||
this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class);
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理认证请求
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @param protocol 协议对象
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public void handle(CoapExchange exchange, IotCoapUpstreamProtocol protocol) {
|
||||
try {
|
||||
// 1.1 解析请求体
|
||||
byte[] payload = exchange.getRequestPayload();
|
||||
if (payload == null || payload.length == 0) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "请求体不能为空");
|
||||
return;
|
||||
}
|
||||
Map<String, Object> body;
|
||||
try {
|
||||
body = JsonUtils.parseObject(new String(payload), Map.class);
|
||||
} catch (Exception e) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "请求体 JSON 格式错误");
|
||||
return;
|
||||
}
|
||||
// 1.2 解析参数
|
||||
String clientId = MapUtil.getStr(body, "clientId");
|
||||
if (StrUtil.isEmpty(clientId)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "clientId 不能为空");
|
||||
return;
|
||||
}
|
||||
String username = MapUtil.getStr(body, "username");
|
||||
if (StrUtil.isEmpty(username)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "username 不能为空");
|
||||
return;
|
||||
}
|
||||
String password = MapUtil.getStr(body, "password");
|
||||
if (StrUtil.isEmpty(password)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "password 不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.1 执行认证
|
||||
CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO()
|
||||
.setClientId(clientId).setUsername(username).setPassword(password));
|
||||
if (result.isError()) {
|
||||
log.warn("[handle][认证失败,clientId: {}, 错误: {}]", clientId, result.getMsg());
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.UNAUTHORIZED, "认证失败:" + result.getMsg());
|
||||
return;
|
||||
}
|
||||
if (!BooleanUtil.isTrue(result.getData())) {
|
||||
log.warn("[handle][认证失败,clientId: {}]", clientId);
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.UNAUTHORIZED, "认证失败");
|
||||
return;
|
||||
}
|
||||
// 2.2 生成 Token
|
||||
IotDeviceIdentity deviceInfo = deviceTokenService.parseUsername(username);
|
||||
Assert.notNull(deviceInfo, "设备信息不能为空");
|
||||
String token = deviceTokenService.createToken(deviceInfo.getProductKey(), deviceInfo.getDeviceName());
|
||||
Assert.notBlank(token, "生成 token 不能为空");
|
||||
|
||||
// 3. 执行上线
|
||||
IotDeviceMessage message = IotDeviceMessage.buildStateUpdateOnline();
|
||||
deviceMessageService.sendDeviceMessage(message,
|
||||
deviceInfo.getProductKey(), deviceInfo.getDeviceName(), protocol.getServerId());
|
||||
|
||||
// 4. 返回成功响应
|
||||
log.info("[handle][认证成功,productKey: {}, deviceName: {}]",
|
||||
deviceInfo.getProductKey(), deviceInfo.getDeviceName());
|
||||
IotCoapUtils.respondSuccess(exchange, MapUtil.of("token", token));
|
||||
} catch (Exception e) {
|
||||
log.error("[handle][认证处理异常]", e);
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.INTERNAL_SERVER_ERROR, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.util.IotCoapUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.coap.CoAP;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的【设备动态注册】处理器
|
||||
* <p>
|
||||
* 用于直连设备/网关的一型一密动态注册,不需要认证
|
||||
*
|
||||
* @author 芋道源码
|
||||
* @see <a href="https://help.aliyun.com/zh/iot/user-guide/unique-certificate-per-product-verification">阿里云 - 一型一密</a>
|
||||
* @see cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpRegisterHandler
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapRegisterHandler {
|
||||
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
|
||||
public IotCoapRegisterHandler() {
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理设备动态注册请求
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public void handle(CoapExchange exchange) {
|
||||
try {
|
||||
// 1.1 解析请求体
|
||||
byte[] payload = exchange.getRequestPayload();
|
||||
if (payload == null || payload.length == 0) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "请求体不能为空");
|
||||
return;
|
||||
}
|
||||
Map<String, Object> body;
|
||||
try {
|
||||
body = JsonUtils.parseObject(new String(payload), Map.class);
|
||||
} catch (Exception e) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "请求体 JSON 格式错误");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1.2 解析参数
|
||||
String productKey = MapUtil.getStr(body, "productKey");
|
||||
if (StrUtil.isEmpty(productKey)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "productKey 不能为空");
|
||||
return;
|
||||
}
|
||||
String deviceName = MapUtil.getStr(body, "deviceName");
|
||||
if (StrUtil.isEmpty(deviceName)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "deviceName 不能为空");
|
||||
return;
|
||||
}
|
||||
String productSecret = MapUtil.getStr(body, "productSecret");
|
||||
if (StrUtil.isEmpty(productSecret)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "productSecret 不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 调用动态注册
|
||||
IotDeviceRegisterReqDTO reqDTO = new IotDeviceRegisterReqDTO()
|
||||
.setProductKey(productKey)
|
||||
.setDeviceName(deviceName)
|
||||
.setProductSecret(productSecret);
|
||||
CommonResult<IotDeviceRegisterRespDTO> result = deviceApi.registerDevice(reqDTO);
|
||||
if (result.isError()) {
|
||||
log.warn("[handle][设备动态注册失败,productKey: {}, deviceName: {}, 错误: {}]",
|
||||
productKey, deviceName, result.getMsg());
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST,
|
||||
"设备动态注册失败:" + result.getMsg());
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 返回成功响应
|
||||
log.info("[handle][设备动态注册成功,productKey: {}, deviceName: {}]", productKey, deviceName);
|
||||
IotCoapUtils.respondSuccess(exchange, result.getData());
|
||||
} catch (Exception e) {
|
||||
log.error("[handle][设备动态注册处理异常]", e);
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.INTERNAL_SERVER_ERROR, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.router;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.text.StrPool;
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.IotCoapUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.coap.util.IotCoapUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.eclipse.californium.core.coap.CoAP;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT 网关 CoAP 协议的【上行】处理器
|
||||
*
|
||||
* 处理设备通过 CoAP 协议发送的上行消息,包括:
|
||||
* 1. 属性上报:POST /topic/sys/{productKey}/{deviceName}/thing/property/post
|
||||
* 2. 事件上报:POST /topic/sys/{productKey}/{deviceName}/thing/event/post
|
||||
*
|
||||
* Token 通过自定义 CoAP Option 2088 携带
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotCoapUpstreamHandler {
|
||||
|
||||
private final IotDeviceTokenService deviceTokenService;
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
public IotCoapUpstreamHandler() {
|
||||
this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class);
|
||||
this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 CoAP 请求
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @param protocol 协议对象
|
||||
*/
|
||||
public void handle(CoapExchange exchange, IotCoapUpstreamProtocol protocol) {
|
||||
try {
|
||||
// 1. 解析通用参数
|
||||
List<String> uriPath = exchange.getRequestOptions().getUriPath();
|
||||
String productKey = CollUtil.get(uriPath, 2);
|
||||
String deviceName = CollUtil.get(uriPath, 3);
|
||||
byte[] payload = exchange.getRequestPayload();
|
||||
if (StrUtil.isEmpty(productKey)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "productKey 不能为空");
|
||||
return;
|
||||
}
|
||||
if (StrUtil.isEmpty(deviceName)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "deviceName 不能为空");
|
||||
return;
|
||||
}
|
||||
if (ArrayUtil.isEmpty(payload)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "请求体不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 认证:从自定义 Option 获取 token
|
||||
String token = IotCoapUtils.getTokenFromOption(exchange, IotCoapUtils.OPTION_TOKEN);
|
||||
if (StrUtil.isEmpty(token)) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.UNAUTHORIZED, "token 不能为空");
|
||||
return;
|
||||
}
|
||||
// 验证 token
|
||||
IotDeviceIdentity deviceInfo = deviceTokenService.verifyToken(token);
|
||||
if (deviceInfo == null) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.UNAUTHORIZED, "token 无效或已过期");
|
||||
return;
|
||||
}
|
||||
// 验证设备信息匹配
|
||||
if (ObjUtil.notEqual(productKey, deviceInfo.getProductKey())
|
||||
|| ObjUtil.notEqual(deviceName, deviceInfo.getDeviceName())) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.FORBIDDEN, "设备信息与 token 不匹配");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.1 解析 method:deviceName 后面的路径,用 . 拼接
|
||||
// 路径格式:[topic, sys, productKey, deviceName, thing, property, post]
|
||||
String method = String.join(StrPool.DOT, uriPath.subList(4, uriPath.size()));
|
||||
|
||||
// 2.2 解码消息
|
||||
IotDeviceMessage message = deviceMessageService.decodeDeviceMessage(payload, productKey, deviceName);
|
||||
if (ObjUtil.notEqual(method, message.getMethod())) {
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.BAD_REQUEST, "method 不匹配");
|
||||
return;
|
||||
}
|
||||
// 2.3 发送消息到消息总线
|
||||
deviceMessageService.sendDeviceMessage(message, productKey, deviceName, protocol.getServerId());
|
||||
|
||||
// 3. 返回成功响应
|
||||
IotCoapUtils.respondSuccess(exchange, MapUtil.of("messageId", message.getId()));
|
||||
} catch (Exception e) {
|
||||
log.error("[handle][CoAP 请求处理异常]", e);
|
||||
IotCoapUtils.respondError(exchange, CoAP.ResponseCode.INTERNAL_SERVER_ERROR, "服务器内部错误");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.coap.util;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import org.eclipse.californium.core.coap.CoAP;
|
||||
import org.eclipse.californium.core.coap.MediaTypeRegistry;
|
||||
import org.eclipse.californium.core.coap.Option;
|
||||
import org.eclipse.californium.core.server.resources.CoapExchange;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* IoT CoAP 协议工具类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public class IotCoapUtils {
|
||||
|
||||
/**
|
||||
* 自定义 CoAP Option 编号,用于携带 Token
|
||||
* <p>
|
||||
* CoAP Option 范围 2048-65535 属于实验/自定义范围
|
||||
*/
|
||||
public static final int OPTION_TOKEN = 2088;
|
||||
|
||||
/**
|
||||
* 返回成功响应
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @param data 响应数据
|
||||
*/
|
||||
public static void respondSuccess(CoapExchange exchange, Object data) {
|
||||
CommonResult<Object> result = CommonResult.success(data);
|
||||
String json = JsonUtils.toJsonString(result);
|
||||
exchange.respond(CoAP.ResponseCode.CONTENT, json, MediaTypeRegistry.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回错误响应
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @param code CoAP 响应码
|
||||
* @param message 错误消息
|
||||
*/
|
||||
public static void respondError(CoapExchange exchange, CoAP.ResponseCode code, String message) {
|
||||
int errorCode = mapCoapCodeToErrorCode(code);
|
||||
CommonResult<Object> result = CommonResult.error(errorCode, message);
|
||||
String json = JsonUtils.toJsonString(result);
|
||||
exchange.respond(code, json, MediaTypeRegistry.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从自定义 CoAP Option 中获取 Token
|
||||
*
|
||||
* @param exchange CoAP 交换对象
|
||||
* @param optionNumber Option 编号
|
||||
* @return Token 值,如果不存在则返回 null
|
||||
*/
|
||||
public static String getTokenFromOption(CoapExchange exchange, int optionNumber) {
|
||||
Option option = CollUtil.findOne(exchange.getRequestOptions().getOthers(),
|
||||
o -> o.getNumber() == optionNumber);
|
||||
return option != null ? new String(option.getValue()) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 CoAP 响应码映射到业务错误码
|
||||
*
|
||||
* @param code CoAP 响应码
|
||||
* @return 业务错误码
|
||||
*/
|
||||
public static int mapCoapCodeToErrorCode(CoAP.ResponseCode code) {
|
||||
if (code == CoAP.ResponseCode.BAD_REQUEST) {
|
||||
return BAD_REQUEST.getCode();
|
||||
} else if (code == CoAP.ResponseCode.UNAUTHORIZED) {
|
||||
return UNAUTHORIZED.getCode();
|
||||
} else if (code == CoAP.ResponseCode.FORBIDDEN) {
|
||||
return FORBIDDEN.getCode();
|
||||
} else {
|
||||
return INTERNAL_SERVER_ERROR.getCode();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router.IotEmqxAuthEventHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.http.HttpServer;
|
||||
import io.vertx.ext.web.Router;
|
||||
import io.vertx.ext.web.handler.BodyHandler;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT 网关 EMQX 认证事件协议服务
|
||||
* <p>
|
||||
* 为 EMQX 提供 HTTP 接口服务,包括:
|
||||
* 1. 设备认证接口 - 对应 EMQX HTTP 认证插件
|
||||
* 2. 设备事件处理接口 - 对应 EMQX Webhook 事件通知
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotEmqxAuthEventProtocol {
|
||||
|
||||
private final IotGatewayProperties.EmqxProperties emqxProperties;
|
||||
|
||||
private final String serverId;
|
||||
|
||||
private final Vertx vertx;
|
||||
|
||||
private HttpServer httpServer;
|
||||
|
||||
public IotEmqxAuthEventProtocol(IotGatewayProperties.EmqxProperties emqxProperties,
|
||||
Vertx vertx) {
|
||||
this.emqxProperties = emqxProperties;
|
||||
this.vertx = vertx;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(emqxProperties.getMqttPort());
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void start() {
|
||||
try {
|
||||
startHttpServer();
|
||||
log.info("[start][IoT 网关 EMQX 认证事件协议服务启动成功, 端口: {}]", emqxProperties.getHttpPort());
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT 网关 EMQX 认证事件协议服务启动失败]", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
stopHttpServer();
|
||||
log.info("[stop][IoT 网关 EMQX 认证事件协议服务已停止]");
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 HTTP 服务器
|
||||
*/
|
||||
private void startHttpServer() {
|
||||
int port = emqxProperties.getHttpPort();
|
||||
|
||||
// 1. 创建路由
|
||||
Router router = Router.router(vertx);
|
||||
router.route().handler(BodyHandler.create());
|
||||
|
||||
// 2. 创建处理器,传入 serverId
|
||||
IotEmqxAuthEventHandler handler = new IotEmqxAuthEventHandler(serverId);
|
||||
router.post(IotMqttTopicUtils.MQTT_AUTH_PATH).handler(handler::handleAuth);
|
||||
router.post(IotMqttTopicUtils.MQTT_EVENT_PATH).handler(handler::handleEvent);
|
||||
// TODO @haohao:/mqtt/acl 需要处理么?
|
||||
// TODO @芋艿:已在 EMQX 处理,如果是“设备直连”模式需要处理
|
||||
|
||||
// 3. 启动 HTTP 服务器
|
||||
try {
|
||||
httpServer = vertx.createHttpServer()
|
||||
.requestHandler(router)
|
||||
.listen(port)
|
||||
.result();
|
||||
} catch (Exception e) {
|
||||
log.error("[startHttpServer][HTTP 服务器启动失败, 端口: {}]", port, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 HTTP 服务器
|
||||
*/
|
||||
private void stopHttpServer() {
|
||||
if (httpServer == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
httpServer.close().result();
|
||||
log.info("[stopHttpServer][HTTP 服务器已停止]");
|
||||
} catch (Exception e) {
|
||||
log.error("[stopHttpServer][HTTP 服务器停止失败]", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* IoT EMQX 协议配置
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
public class IotEmqxConfig {
|
||||
|
||||
// ========== MQTT Client 配置(连接 EMQX Broker) ==========
|
||||
|
||||
/**
|
||||
* MQTT 服务器地址
|
||||
*/
|
||||
@NotEmpty(message = "MQTT 服务器地址不能为空")
|
||||
private String mqttHost;
|
||||
|
||||
/**
|
||||
* MQTT 服务器端口(默认:1883)
|
||||
*/
|
||||
@NotNull(message = "MQTT 服务器端口不能为空")
|
||||
private Integer mqttPort = 1883;
|
||||
|
||||
/**
|
||||
* MQTT 用户名
|
||||
*/
|
||||
@NotEmpty(message = "MQTT 用户名不能为空")
|
||||
private String mqttUsername;
|
||||
|
||||
/**
|
||||
* MQTT 密码
|
||||
*/
|
||||
@NotEmpty(message = "MQTT 密码不能为空")
|
||||
private String mqttPassword;
|
||||
|
||||
/**
|
||||
* MQTT 客户端的 SSL 开关
|
||||
*/
|
||||
@NotNull(message = "MQTT 是否开启 SSL 不能为空")
|
||||
private Boolean mqttSsl = false;
|
||||
|
||||
/**
|
||||
* MQTT 客户端 ID
|
||||
*/
|
||||
@NotEmpty(message = "MQTT 客户端 ID 不能为空")
|
||||
private String mqttClientId;
|
||||
|
||||
/**
|
||||
* MQTT 订阅的主题
|
||||
*/
|
||||
@NotEmpty(message = "MQTT 主题不能为空")
|
||||
private List<@NotEmpty(message = "MQTT 主题不能为空") String> mqttTopics;
|
||||
|
||||
/**
|
||||
* 默认 QoS 级别
|
||||
* <p>
|
||||
* 0 - 最多一次
|
||||
* 1 - 至少一次
|
||||
* 2 - 刚好一次
|
||||
*/
|
||||
@NotNull(message = "MQTT QoS 不能为空")
|
||||
@Min(value = 0, message = "MQTT QoS 不能小于 0")
|
||||
@Max(value = 2, message = "MQTT QoS 不能大于 2")
|
||||
private Integer mqttQos = 1;
|
||||
|
||||
/**
|
||||
* 连接超时时间(秒)
|
||||
*/
|
||||
@NotNull(message = "连接超时时间不能为空")
|
||||
@Min(value = 1, message = "连接超时时间不能小于 1 秒")
|
||||
private Integer connectTimeoutSeconds = 10;
|
||||
|
||||
/**
|
||||
* 重连延迟时间(毫秒)
|
||||
*/
|
||||
@NotNull(message = "重连延迟时间不能为空")
|
||||
@Min(value = 0, message = "重连延迟时间不能小于 0 毫秒")
|
||||
private Long reconnectDelayMs = 5000L;
|
||||
|
||||
/**
|
||||
* 是否启用 Clean Session (清理会话)
|
||||
* true: 每次连接都是新会话,Broker 不保留离线消息和订阅关系。
|
||||
* 对于网关这类“永远在线”且会主动重新订阅的应用,建议为 true。
|
||||
*/
|
||||
@NotNull(message = "是否启用 Clean Session 不能为空")
|
||||
private Boolean cleanSession = true;
|
||||
|
||||
/**
|
||||
* 心跳间隔(秒)
|
||||
* 用于保持连接活性,及时发现网络中断。
|
||||
*/
|
||||
@NotNull(message = "心跳间隔不能为空")
|
||||
@Min(value = 1, message = "心跳间隔不能小于 1 秒")
|
||||
private Integer keepAliveIntervalSeconds = 60;
|
||||
|
||||
/**
|
||||
* 最大未确认消息队列大小
|
||||
* 限制已发送但未收到 Broker 确认的 QoS 1/2 消息数量,用于流量控制。
|
||||
*/
|
||||
@NotNull(message = "最大未确认消息队列大小不能为空")
|
||||
@Min(value = 1, message = "最大未确认消息队列大小不能小于 1")
|
||||
private Integer maxInflightQueue = 10000;
|
||||
|
||||
/**
|
||||
* 是否信任所有 SSL 证书
|
||||
* 警告:此配置会绕过证书验证,仅建议在开发和测试环境中使用!
|
||||
* 在生产环境中,应设置为 false,并配置正确的信任库。
|
||||
*/
|
||||
@NotNull(message = "是否信任所有 SSL 证书不能为空")
|
||||
private Boolean trustAll = false;
|
||||
|
||||
// ========== MQTT Will / SSL 高级配置 ==========
|
||||
|
||||
/**
|
||||
* 遗嘱消息配置 (用于网关异常下线时通知其他系统)
|
||||
*/
|
||||
@Valid
|
||||
private Will will = new Will();
|
||||
|
||||
/**
|
||||
* 高级 SSL/TLS 配置 (用于生产环境)
|
||||
*/
|
||||
@Valid
|
||||
private Ssl sslOptions = new Ssl();
|
||||
|
||||
// ========== HTTP Hook 配置(网关提供给 EMQX 调用) ==========
|
||||
|
||||
/**
|
||||
* HTTP Hook 服务配置(用于 /mqtt/auth、/mqtt/event)
|
||||
*/
|
||||
@Valid
|
||||
private Http http = new Http();
|
||||
|
||||
/**
|
||||
* 遗嘱消息 (Last Will and Testament)
|
||||
*/
|
||||
@Data
|
||||
public static class Will {
|
||||
|
||||
/**
|
||||
* 是否启用遗嘱消息
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
/**
|
||||
* 遗嘱消息主题
|
||||
*/
|
||||
private String topic;
|
||||
/**
|
||||
* 遗嘱消息内容
|
||||
*/
|
||||
private String payload;
|
||||
/**
|
||||
* 遗嘱消息 QoS 等级
|
||||
*/
|
||||
@Min(value = 0, message = "遗嘱消息 QoS 不能小于 0")
|
||||
@Max(value = 2, message = "遗嘱消息 QoS 不能大于 2")
|
||||
private Integer qos = 1;
|
||||
/**
|
||||
* 遗嘱消息是否作为保留消息发布
|
||||
*/
|
||||
private boolean retain = true;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 高级 SSL/TLS 配置
|
||||
*/
|
||||
@Data
|
||||
public static class Ssl {
|
||||
|
||||
/**
|
||||
* 密钥库(KeyStore)路径,例如:classpath:certs/client.jks
|
||||
* 包含客户端自己的证书和私钥,用于向服务端证明身份(双向认证)。
|
||||
*/
|
||||
private String keyStorePath;
|
||||
/**
|
||||
* 密钥库密码
|
||||
*/
|
||||
private String keyStorePassword;
|
||||
/**
|
||||
* 信任库(TrustStore)路径,例如:classpath:certs/trust.jks
|
||||
* 包含服务端信任的 CA 证书,用于验证服务端的身份,防止中间人攻击。
|
||||
*/
|
||||
private String trustStorePath;
|
||||
/**
|
||||
* 信任库密码
|
||||
*/
|
||||
private String trustStorePassword;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP Hook 服务 SSL 配置
|
||||
*/
|
||||
@Data
|
||||
public static class Http {
|
||||
|
||||
/**
|
||||
* 是否启用 SSL
|
||||
*/
|
||||
private Boolean sslEnabled = false;
|
||||
|
||||
/**
|
||||
* SSL 证书路径
|
||||
*/
|
||||
private String sslCertPath;
|
||||
|
||||
/**
|
||||
* SSL 私钥路径
|
||||
*/
|
||||
private String sslKeyPath;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream.IotEmqxDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxAuthEventHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream.IotEmqxUpstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
|
||||
import io.netty.handler.codec.mqtt.MqttQoS;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.http.HttpServer;
|
||||
import io.vertx.core.http.HttpServerOptions;
|
||||
import io.vertx.core.net.JksOptions;
|
||||
import io.vertx.core.net.PemKeyCertOptions;
|
||||
import io.vertx.ext.web.Router;
|
||||
import io.vertx.ext.web.handler.BodyHandler;
|
||||
import io.vertx.mqtt.MqttClient;
|
||||
import io.vertx.mqtt.MqttClientOptions;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
|
||||
|
||||
/**
|
||||
* IoT 网关 EMQX 协议实现:
|
||||
* <p>
|
||||
* 1. 提供 HTTP Hook 服务(/mqtt/auth、/mqtt/acl、/mqtt/event)给 EMQX 调用
|
||||
* 2. 通过 MQTT Client 订阅设备上行消息,并发布下行消息到 Broker
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotEmqxProtocol implements IotProtocol {
|
||||
|
||||
/**
|
||||
* 协议配置
|
||||
*/
|
||||
private final ProtocolProperties properties;
|
||||
/**
|
||||
* EMQX 配置
|
||||
*/
|
||||
private final IotEmqxConfig emqxConfig;
|
||||
/**
|
||||
* 服务器 ID
|
||||
*/
|
||||
@Getter
|
||||
private final String serverId;
|
||||
|
||||
/**
|
||||
* 运行状态
|
||||
*/
|
||||
@Getter
|
||||
private volatile boolean running = false;
|
||||
|
||||
/**
|
||||
* Vert.x 实例
|
||||
*/
|
||||
private Vertx vertx;
|
||||
/**
|
||||
* HTTP Hook 服务器
|
||||
*/
|
||||
private HttpServer httpServer;
|
||||
|
||||
/**
|
||||
* MQTT Client
|
||||
*/
|
||||
private volatile MqttClient mqttClient;
|
||||
/**
|
||||
* MQTT 重连定时器 ID
|
||||
*/
|
||||
private volatile Long reconnectTimerId;
|
||||
|
||||
/**
|
||||
* 上行消息处理器
|
||||
*/
|
||||
private final IotEmqxUpstreamHandler upstreamHandler;
|
||||
|
||||
/**
|
||||
* 下行消息订阅者
|
||||
*/
|
||||
private IotEmqxDownstreamSubscriber downstreamSubscriber;
|
||||
|
||||
public IotEmqxProtocol(ProtocolProperties properties) {
|
||||
Assert.notNull(properties, "协议实例配置不能为空");
|
||||
Assert.notNull(properties.getEmqx(), "EMQX 协议配置(emqx)不能为空");
|
||||
this.properties = properties;
|
||||
this.emqxConfig = properties.getEmqx();
|
||||
Assert.notNull(emqxConfig.getConnectTimeoutSeconds(),
|
||||
"MQTT 连接超时时间(emqx.connect-timeout-seconds)不能为空");
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort());
|
||||
this.upstreamHandler = new IotEmqxUpstreamHandler(serverId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return properties.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IotProtocolTypeEnum getType() {
|
||||
return IotProtocolTypeEnum.EMQX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (running) {
|
||||
log.warn("[start][IoT EMQX 协议 {} 已经在运行中]", getId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 1.1 创建 Vertx 实例 和 下行消息订阅者
|
||||
this.vertx = Vertx.vertx();
|
||||
|
||||
try {
|
||||
// 1.2 启动 HTTP Hook 服务
|
||||
startHttpServer();
|
||||
|
||||
// 1.3 启动 MQTT Client
|
||||
startMqttClient();
|
||||
running = true;
|
||||
log.info("[start][IoT EMQX 协议 {} 启动成功,hookPort:{},serverId:{}]",
|
||||
getId(), properties.getPort(), serverId);
|
||||
|
||||
// 2. 启动下行消息订阅者
|
||||
IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class);
|
||||
this.downstreamSubscriber = new IotEmqxDownstreamSubscriber(this, messageBus);
|
||||
this.downstreamSubscriber.start();
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT EMQX 协议 {} 启动失败]", getId(), e);
|
||||
// 启动失败时,关闭资源
|
||||
stop0();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
stop0();
|
||||
}
|
||||
|
||||
private void stop0() {
|
||||
// 1. 停止下行消息订阅者
|
||||
if (downstreamSubscriber != null) {
|
||||
try {
|
||||
downstreamSubscriber.stop();
|
||||
log.info("[stop][IoT EMQX 协议 {} 下行消息订阅者已停止]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT EMQX 协议 {} 下行消息订阅者停止失败]", getId(), e);
|
||||
}
|
||||
downstreamSubscriber = null;
|
||||
}
|
||||
|
||||
// 2.1 先置为 false:避免 closeHandler 触发重连
|
||||
running = false;
|
||||
stopMqttClientReconnectChecker();
|
||||
// 2.2 停止 MQTT Client
|
||||
stopMqttClient();
|
||||
|
||||
// 2.3 停止 HTTP Hook 服务
|
||||
stopHttpServer();
|
||||
|
||||
// 2.4 关闭 Vertx
|
||||
if (vertx != null) {
|
||||
try {
|
||||
vertx.close().toCompletionStage().toCompletableFuture()
|
||||
.get(10, TimeUnit.SECONDS);
|
||||
log.info("[stop][IoT EMQX 协议 {} Vertx 已关闭]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT EMQX 协议 {} Vertx 关闭失败]", getId(), e);
|
||||
}
|
||||
vertx = null;
|
||||
}
|
||||
|
||||
log.info("[stop][IoT EMQX 协议 {} 已停止]", getId());
|
||||
}
|
||||
|
||||
// ======================================= HTTP Hook Server =======================================
|
||||
|
||||
/**
|
||||
* 启动 HTTP Hook 服务(/mqtt/auth、/mqtt/acl、/mqtt/event)
|
||||
*/
|
||||
private void startHttpServer() {
|
||||
// 1. 创建路由
|
||||
Router router = Router.router(vertx);
|
||||
router.route().handler(BodyHandler.create().setBodyLimit(1024 * 1024)); // 限制 body 大小为 1MB,防止大包攻击
|
||||
|
||||
// 2. 创建处理器
|
||||
IotEmqxAuthEventHandler handler = new IotEmqxAuthEventHandler(serverId, this);
|
||||
router.post(IotMqttTopicUtils.MQTT_AUTH_PATH).handler(handler::handleAuth);
|
||||
router.post(IotMqttTopicUtils.MQTT_ACL_PATH).handler(handler::handleAcl);
|
||||
router.post(IotMqttTopicUtils.MQTT_EVENT_PATH).handler(handler::handleEvent);
|
||||
|
||||
// 3. 启动 HTTP Server(支持 HTTPS)
|
||||
IotEmqxConfig.Http httpConfig = emqxConfig.getHttp();
|
||||
HttpServerOptions options = new HttpServerOptions().setPort(properties.getPort());
|
||||
if (httpConfig != null && Boolean.TRUE.equals(httpConfig.getSslEnabled())) {
|
||||
Assert.notBlank(httpConfig.getSslCertPath(), "EMQX HTTP SSL 证书路径(emqx.http.ssl-cert-path)不能为空");
|
||||
Assert.notBlank(httpConfig.getSslKeyPath(), "EMQX HTTP SSL 私钥路径(emqx.http.ssl-key-path)不能为空");
|
||||
PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions()
|
||||
.setKeyPath(httpConfig.getSslKeyPath())
|
||||
.setCertPath(httpConfig.getSslCertPath());
|
||||
options.setSsl(true).setKeyCertOptions(pemKeyCertOptions);
|
||||
}
|
||||
try {
|
||||
httpServer = vertx.createHttpServer(options)
|
||||
.requestHandler(router)
|
||||
.listen()
|
||||
.toCompletionStage().toCompletableFuture()
|
||||
.get(10, TimeUnit.SECONDS);
|
||||
log.info("[startHttpServer][IoT EMQX 协议 {} HTTP Hook 服务启动成功, port: {}, ssl: {}]",
|
||||
getId(), properties.getPort(), httpConfig != null && Boolean.TRUE.equals(httpConfig.getSslEnabled()));
|
||||
} catch (Exception e) {
|
||||
log.error("[startHttpServer][IoT EMQX 协议 {} HTTP Hook 服务启动失败, port: {}]", getId(), properties.getPort(), e);
|
||||
throw new RuntimeException("HTTP Hook 服务启动失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void stopHttpServer() {
|
||||
if (httpServer == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
httpServer.close().toCompletionStage().toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
log.info("[stopHttpServer][IoT EMQX 协议 {} HTTP Hook 服务已停止]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stopHttpServer][IoT EMQX 协议 {} HTTP Hook 服务停止失败]", getId(), e);
|
||||
} finally {
|
||||
httpServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================= MQTT Client ======================================
|
||||
|
||||
private void startMqttClient() {
|
||||
// 1.1 创建 MQTT Client
|
||||
MqttClient client = createMqttClient();
|
||||
this.mqttClient = client;
|
||||
// 1.2 连接 MQTT Broker
|
||||
if (!connectMqttClient(client)) {
|
||||
throw new RuntimeException("MQTT Client 启动失败: 连接 Broker 失败");
|
||||
}
|
||||
|
||||
// 2. 启动定时重连检查
|
||||
startMqttClientReconnectChecker();
|
||||
}
|
||||
|
||||
private void stopMqttClient() {
|
||||
MqttClient client = this.mqttClient;
|
||||
this.mqttClient = null; // 先清理引用
|
||||
if (client == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 批量取消订阅(仅在连接时)
|
||||
if (client.isConnected()) {
|
||||
List<String> topicList = emqxConfig.getMqttTopics();
|
||||
if (CollUtil.isNotEmpty(topicList)) {
|
||||
try {
|
||||
client.unsubscribe(topicList).toCompletionStage().toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
log.warn("[stopMqttClient][IoT EMQX 协议 {} 取消订阅异常]", getId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 断开 MQTT 连接
|
||||
try {
|
||||
client.disconnect().toCompletionStage().toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
} catch (Exception e) {
|
||||
log.warn("[stopMqttClient][IoT EMQX 协议 {} 断开连接异常]", getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================= MQTT 基础方法 ======================================
|
||||
|
||||
/**
|
||||
* 创建 MQTT 客户端
|
||||
*
|
||||
* @return 新创建的 MqttClient
|
||||
*/
|
||||
private MqttClient createMqttClient() {
|
||||
// 1.1 基础配置
|
||||
MqttClientOptions options = new MqttClientOptions()
|
||||
.setClientId(emqxConfig.getMqttClientId())
|
||||
.setUsername(emqxConfig.getMqttUsername())
|
||||
.setPassword(emqxConfig.getMqttPassword())
|
||||
.setSsl(Boolean.TRUE.equals(emqxConfig.getMqttSsl()))
|
||||
.setCleanSession(Boolean.TRUE.equals(emqxConfig.getCleanSession()))
|
||||
.setKeepAliveInterval(emqxConfig.getKeepAliveIntervalSeconds())
|
||||
.setMaxInflightQueue(emqxConfig.getMaxInflightQueue());
|
||||
options.setConnectTimeout(emqxConfig.getConnectTimeoutSeconds() * 1000); // Vert.x 需要毫秒
|
||||
options.setTrustAll(Boolean.TRUE.equals(emqxConfig.getTrustAll()));
|
||||
// 1.2 配置遗嘱消息
|
||||
IotEmqxConfig.Will will = emqxConfig.getWill();
|
||||
if (will != null && will.isEnabled()) {
|
||||
Assert.notBlank(will.getTopic(), "遗嘱消息主题(emqx.will.topic)不能为空");
|
||||
Assert.notNull(will.getPayload(), "遗嘱消息内容(emqx.will.payload)不能为空");
|
||||
options.setWillFlag(true)
|
||||
.setWillTopic(will.getTopic())
|
||||
.setWillMessageBytes(Buffer.buffer(will.getPayload()))
|
||||
.setWillQoS(will.getQos())
|
||||
.setWillRetain(will.isRetain());
|
||||
}
|
||||
// 1.3 配置高级 SSL/TLS(仅在启用 SSL 且不信任所有证书时生效,且需要 sslOptions 非空)
|
||||
IotEmqxConfig.Ssl sslOptions = emqxConfig.getSslOptions();
|
||||
if (Boolean.TRUE.equals(emqxConfig.getMqttSsl())
|
||||
&& Boolean.FALSE.equals(emqxConfig.getTrustAll())
|
||||
&& sslOptions != null) {
|
||||
if (StrUtil.isNotBlank(sslOptions.getTrustStorePath())) {
|
||||
options.setTrustStoreOptions(new JksOptions()
|
||||
.setPath(sslOptions.getTrustStorePath())
|
||||
.setPassword(sslOptions.getTrustStorePassword()));
|
||||
}
|
||||
if (StrUtil.isNotBlank(sslOptions.getKeyStorePath())) {
|
||||
options.setKeyStoreOptions(new JksOptions()
|
||||
.setPath(sslOptions.getKeyStorePath())
|
||||
.setPassword(sslOptions.getKeyStorePassword()));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 创建客户端
|
||||
return MqttClient.create(vertx, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 MQTT Broker(同步等待)
|
||||
*
|
||||
* @param client MQTT 客户端
|
||||
* @return 连接成功返回 true,失败返回 false
|
||||
*/
|
||||
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
|
||||
private synchronized boolean connectMqttClient(MqttClient client) {
|
||||
String host = emqxConfig.getMqttHost();
|
||||
int port = emqxConfig.getMqttPort();
|
||||
int timeoutSeconds = emqxConfig.getConnectTimeoutSeconds();
|
||||
try {
|
||||
// 1. 连接 Broker
|
||||
client.connect(port, host).toCompletionStage().toCompletableFuture()
|
||||
.get(timeoutSeconds, TimeUnit.SECONDS);
|
||||
log.info("[connectMqttClient][IoT EMQX 协议 {} 连接成功, host: {}, port: {}]",
|
||||
getId(), host, port);
|
||||
|
||||
// 2. 设置处理器
|
||||
setupMqttClientHandlers(client);
|
||||
subscribeMqttClientTopics(client);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("[connectMqttClient][IoT EMQX 协议 {} 连接发生异常]", getId(), e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭 MQTT 客户端
|
||||
*/
|
||||
private void closeMqttClient() {
|
||||
MqttClient oldClient = this.mqttClient;
|
||||
this.mqttClient = null; // 先清理引用
|
||||
if (oldClient == null) {
|
||||
return;
|
||||
}
|
||||
// 尽力释放(无论是否连接都尝试 disconnect)
|
||||
try {
|
||||
oldClient.disconnect().toCompletionStage().toCompletableFuture()
|
||||
.get(5, TimeUnit.SECONDS);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================= MQTT 重连机制 ======================================
|
||||
|
||||
/**
|
||||
* 启动 MQTT Client 周期性重连检查器
|
||||
*/
|
||||
private void startMqttClientReconnectChecker() {
|
||||
long interval = emqxConfig.getReconnectDelayMs();
|
||||
this.reconnectTimerId = vertx.setPeriodic(interval, timerId -> {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
if (mqttClient != null && mqttClient.isConnected()) {
|
||||
return;
|
||||
}
|
||||
log.info("[startMqttClientReconnectChecker][IoT EMQX 协议 {} 检测到断开,尝试重连]", getId());
|
||||
// 用 executeBlocking 避免阻塞 event-loop(tryReconnectMqttClient 内部有同步等待)
|
||||
vertx.executeBlocking(() -> {
|
||||
tryReconnectMqttClient();
|
||||
return null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 MQTT Client 重连检查器
|
||||
*/
|
||||
private void stopMqttClientReconnectChecker() {
|
||||
if (reconnectTimerId != null && vertx != null) {
|
||||
try {
|
||||
vertx.cancelTimer(reconnectTimerId);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
reconnectTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试重连 MQTT Client
|
||||
*/
|
||||
private synchronized void tryReconnectMqttClient() {
|
||||
// 1. 前置检查
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
if (mqttClient != null && mqttClient.isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("[tryReconnectMqttClient][IoT EMQX 协议 {} 开始重连]", getId());
|
||||
try {
|
||||
// 2. 关闭旧客户端
|
||||
closeMqttClient();
|
||||
|
||||
// 3.1 创建新客户端
|
||||
MqttClient client = createMqttClient();
|
||||
this.mqttClient = client;
|
||||
// 3.2 连接(失败只打印日志,等下次定时)
|
||||
if (!connectMqttClient(client)) {
|
||||
log.warn("[tryReconnectMqttClient][IoT EMQX 协议 {} 重连失败,等待下次重试]", getId());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[tryReconnectMqttClient][IoT EMQX 协议 {} 重连异常]", getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================= MQTT Handler ======================================
|
||||
|
||||
/**
|
||||
* 设置 MQTT Client 事件处理器
|
||||
*/
|
||||
private void setupMqttClientHandlers(MqttClient client) {
|
||||
// 1. 断开重连监听
|
||||
client.closeHandler(closeEvent -> {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
log.warn("[setupMqttClientHandlers][IoT EMQX 协议 {} 连接断开,立即尝试重连]", getId());
|
||||
// 用 executeBlocking 避免阻塞 event-loop(tryReconnectMqttClient 内部有同步等待)
|
||||
vertx.executeBlocking(() -> {
|
||||
tryReconnectMqttClient();
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
// 2. 异常处理
|
||||
client.exceptionHandler(exception ->
|
||||
log.error("[setupMqttClientHandlers][IoT EMQX 协议 {} MQTT Client 异常]", getId(), exception));
|
||||
|
||||
// 3. 上行消息处理
|
||||
client.publishHandler(upstreamHandler::handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 MQTT Client 主题(同步等待)
|
||||
*/
|
||||
private void subscribeMqttClientTopics(MqttClient client) {
|
||||
List<String> topicList = emqxConfig.getMqttTopics();
|
||||
if (!client.isConnected()) {
|
||||
log.warn("[subscribeMqttClientTopics][IoT EMQX 协议 {} MQTT Client 未连接, 跳过订阅]", getId());
|
||||
return;
|
||||
}
|
||||
if (CollUtil.isEmpty(topicList)) {
|
||||
log.warn("[subscribeMqttClientTopics][IoT EMQX 协议 {} 未配置订阅主题, 跳过订阅]", getId());
|
||||
return;
|
||||
}
|
||||
// 执行订阅
|
||||
Map<String, Integer> topics = convertMap(emqxConfig.getMqttTopics(), topic -> topic,
|
||||
topic -> emqxConfig.getMqttQos());
|
||||
try {
|
||||
client.subscribe(topics).toCompletionStage().toCompletableFuture()
|
||||
.get(10, TimeUnit.SECONDS);
|
||||
log.info("[subscribeMqttClientTopics][IoT EMQX 协议 {} 订阅成功, 共 {} 个主题]", getId(), topicList.size());
|
||||
} catch (Exception e) {
|
||||
log.error("[subscribeMqttClientTopics][IoT EMQX 协议 {} 订阅失败]", getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布消息到 MQTT Broker
|
||||
*
|
||||
* @param topic 主题
|
||||
* @param payload 消息内容
|
||||
*/
|
||||
public void publishMessage(String topic, byte[] payload) {
|
||||
if (mqttClient == null || !mqttClient.isConnected()) {
|
||||
log.warn("[publishMessage][IoT EMQX 协议 {} MQTT Client 未连接, 无法发布消息]", getId());
|
||||
return;
|
||||
}
|
||||
MqttQoS qos = MqttQoS.valueOf(emqxConfig.getMqttQos());
|
||||
mqttClient.publish(topic, Buffer.buffer(payload), qos, false, false)
|
||||
.onFailure(e -> log.error("[publishMessage][IoT EMQX 协议 {} 发布失败, topic: {}]", getId(), topic, e));
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟发布消息到 MQTT Broker
|
||||
*
|
||||
* @param topic 主题
|
||||
* @param payload 消息内容
|
||||
* @param delayMs 延迟时间(毫秒)
|
||||
*/
|
||||
public void publishDelayMessage(String topic, byte[] payload, long delayMs) {
|
||||
vertx.setTimer(delayMs, id -> publishMessage(topic, payload));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router.IotEmqxUpstreamHandler;
|
||||
import io.netty.handler.codec.mqtt.MqttQoS;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.buffer.Buffer;
|
||||
import io.vertx.core.net.JksOptions;
|
||||
import io.vertx.mqtt.MqttClient;
|
||||
import io.vertx.mqtt.MqttClientOptions;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* IoT 网关 EMQX 协议:接收设备上行消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotEmqxUpstreamProtocol {
|
||||
|
||||
private final IotGatewayProperties.EmqxProperties emqxProperties;
|
||||
|
||||
private volatile boolean isRunning = false;
|
||||
|
||||
private final Vertx vertx;
|
||||
|
||||
@Getter
|
||||
private final String serverId;
|
||||
|
||||
private MqttClient mqttClient;
|
||||
|
||||
private IotEmqxUpstreamHandler upstreamHandler;
|
||||
|
||||
public IotEmqxUpstreamProtocol(IotGatewayProperties.EmqxProperties emqxProperties,
|
||||
Vertx vertx) {
|
||||
this.emqxProperties = emqxProperties;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(emqxProperties.getMqttPort());
|
||||
this.vertx = vertx;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void start() {
|
||||
if (isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 启动 MQTT 客户端
|
||||
startMqttClient();
|
||||
|
||||
// 2. 标记服务为运行状态
|
||||
isRunning = true;
|
||||
log.info("[start][IoT 网关 EMQX 协议启动成功]");
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT 网关 EMQX 协议服务启动失败,应用将关闭]", e);
|
||||
stop();
|
||||
|
||||
// 异步关闭应用
|
||||
Thread shutdownThread = new Thread(() -> {
|
||||
try {
|
||||
// 确保日志输出完成,使用更优雅的方式
|
||||
log.error("[start][由于 MQTT 连接失败,正在关闭应用]");
|
||||
// 等待日志输出完成
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("[start][应用关闭被中断]");
|
||||
}
|
||||
System.exit(1);
|
||||
});
|
||||
shutdownThread.setDaemon(true);
|
||||
shutdownThread.setName("emergency-shutdown");
|
||||
shutdownThread.start();
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
if (!isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 停止 MQTT 客户端
|
||||
stopMqttClient();
|
||||
|
||||
// 2. 标记服务为停止状态
|
||||
isRunning = false;
|
||||
log.info("[stop][IoT 网关 MQTT 协议服务已停止]");
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 MQTT 客户端
|
||||
*/
|
||||
private void startMqttClient() {
|
||||
try {
|
||||
// 1. 初始化消息处理器
|
||||
this.upstreamHandler = new IotEmqxUpstreamHandler(this);
|
||||
|
||||
// 2. 创建 MQTT 客户端
|
||||
createMqttClient();
|
||||
|
||||
// 3. 同步连接 MQTT Broker
|
||||
connectMqttSync();
|
||||
} catch (Exception e) {
|
||||
log.error("[startMqttClient][MQTT 客户端启动失败]", e);
|
||||
throw new RuntimeException("MQTT 客户端启动失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步连接 MQTT Broker
|
||||
*/
|
||||
private void connectMqttSync() {
|
||||
String host = emqxProperties.getMqttHost();
|
||||
int port = emqxProperties.getMqttPort();
|
||||
// 1. 连接 MQTT Broker
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicBoolean success = new AtomicBoolean(false);
|
||||
mqttClient.connect(port, host, connectResult -> {
|
||||
if (connectResult.succeeded()) {
|
||||
log.info("[connectMqttSync][MQTT 客户端连接成功, host: {}, port: {}]", host, port);
|
||||
setupMqttHandlers();
|
||||
subscribeToTopics();
|
||||
success.set(true);
|
||||
} else {
|
||||
log.error("[connectMqttSync][连接 MQTT Broker 失败, host: {}, port: {}]",
|
||||
host, port, connectResult.cause());
|
||||
}
|
||||
latch.countDown();
|
||||
});
|
||||
|
||||
// 2. 等待连接结果
|
||||
try {
|
||||
// 应用层超时控制:防止启动过程无限阻塞,与MQTT客户端的网络超时是不同层次的控制
|
||||
boolean awaitResult = latch.await(10, java.util.concurrent.TimeUnit.SECONDS);
|
||||
if (!awaitResult) {
|
||||
log.error("[connectMqttSync][等待连接结果超时]");
|
||||
throw new RuntimeException("连接 MQTT Broker 超时");
|
||||
}
|
||||
if (!success.get()) {
|
||||
throw new RuntimeException(String.format("首次连接 MQTT Broker 失败,地址: %s, 端口: %d", host, port));
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.error("[connectMqttSync][等待连接结果被中断]", e);
|
||||
throw new RuntimeException("连接 MQTT Broker 被中断", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步连接 MQTT Broker
|
||||
*/
|
||||
private void connectMqttAsync() {
|
||||
String host = emqxProperties.getMqttHost();
|
||||
int port = emqxProperties.getMqttPort();
|
||||
mqttClient.connect(port, host, connectResult -> {
|
||||
if (connectResult.succeeded()) {
|
||||
log.info("[connectMqttAsync][MQTT 客户端重连成功]");
|
||||
setupMqttHandlers();
|
||||
subscribeToTopics();
|
||||
} else {
|
||||
log.error("[connectMqttAsync][连接 MQTT Broker 失败, host: {}, port: {}]",
|
||||
host, port, connectResult.cause());
|
||||
log.warn("[connectMqttAsync][重连失败,将再次尝试]");
|
||||
reconnectWithDelay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟重连
|
||||
*/
|
||||
private void reconnectWithDelay() {
|
||||
if (!isRunning) {
|
||||
return;
|
||||
}
|
||||
if (mqttClient != null && mqttClient.isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
long delay = emqxProperties.getReconnectDelayMs();
|
||||
log.info("[reconnectWithDelay][将在 {} 毫秒后尝试重连 MQTT Broker]", delay);
|
||||
vertx.setTimer(delay, timerId -> {
|
||||
if (!isRunning) {
|
||||
return;
|
||||
}
|
||||
if (mqttClient != null && mqttClient.isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("[reconnectWithDelay][开始重连 MQTT Broker]");
|
||||
try {
|
||||
createMqttClient();
|
||||
connectMqttAsync();
|
||||
} catch (Exception e) {
|
||||
log.error("[reconnectWithDelay][重连过程中发生异常]", e);
|
||||
vertx.setTimer(delay, t -> reconnectWithDelay());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止 MQTT 客户端
|
||||
*/
|
||||
private void stopMqttClient() {
|
||||
if (mqttClient == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (mqttClient.isConnected()) {
|
||||
// 1. 取消订阅所有主题
|
||||
List<String> topicList = emqxProperties.getMqttTopics();
|
||||
for (String topic : topicList) {
|
||||
try {
|
||||
mqttClient.unsubscribe(topic);
|
||||
} catch (Exception e) {
|
||||
log.warn("[stopMqttClient][取消订阅主题({})异常]", topic, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 断开 MQTT 客户端连接
|
||||
try {
|
||||
CountDownLatch disconnectLatch = new CountDownLatch(1);
|
||||
mqttClient.disconnect(ar -> disconnectLatch.countDown());
|
||||
if (!disconnectLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)) {
|
||||
log.warn("[stopMqttClient][断开 MQTT 连接超时]");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[stopMqttClient][关闭 MQTT 客户端异常]", e);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[stopMqttClient][停止 MQTT 客户端过程中发生异常]", e);
|
||||
} finally {
|
||||
mqttClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 MQTT 客户端
|
||||
*/
|
||||
private void createMqttClient() {
|
||||
// 1.1 创建基础配置
|
||||
MqttClientOptions options = (MqttClientOptions) new MqttClientOptions()
|
||||
.setClientId(emqxProperties.getMqttClientId())
|
||||
.setUsername(emqxProperties.getMqttUsername())
|
||||
.setPassword(emqxProperties.getMqttPassword())
|
||||
.setSsl(emqxProperties.getMqttSsl())
|
||||
.setCleanSession(emqxProperties.getCleanSession())
|
||||
.setKeepAliveInterval(emqxProperties.getKeepAliveIntervalSeconds())
|
||||
.setMaxInflightQueue(emqxProperties.getMaxInflightQueue())
|
||||
.setConnectTimeout(emqxProperties.getConnectTimeoutSeconds() * 1000) // Vert.x 需要毫秒
|
||||
.setTrustAll(emqxProperties.getTrustAll());
|
||||
// 1.2 配置遗嘱消息
|
||||
IotGatewayProperties.EmqxProperties.Will will = emqxProperties.getWill();
|
||||
if (will.isEnabled()) {
|
||||
Assert.notBlank(will.getTopic(), "遗嘱消息主题(will.topic)不能为空");
|
||||
Assert.notNull(will.getPayload(), "遗嘱消息内容(will.payload)不能为空");
|
||||
options.setWillFlag(true)
|
||||
.setWillTopic(will.getTopic())
|
||||
.setWillMessageBytes(Buffer.buffer(will.getPayload()))
|
||||
.setWillQoS(will.getQos())
|
||||
.setWillRetain(will.isRetain());
|
||||
}
|
||||
// 1.3 配置高级 SSL/TLS (仅在启用 SSL 且不信任所有证书时生效)
|
||||
if (Boolean.TRUE.equals(emqxProperties.getMqttSsl()) && !Boolean.TRUE.equals(emqxProperties.getTrustAll())) {
|
||||
IotGatewayProperties.EmqxProperties.Ssl sslOptions = emqxProperties.getSslOptions();
|
||||
if (StrUtil.isNotBlank(sslOptions.getTrustStorePath())) {
|
||||
options.setTrustStoreOptions(new JksOptions()
|
||||
.setPath(sslOptions.getTrustStorePath())
|
||||
.setPassword(sslOptions.getTrustStorePassword()));
|
||||
}
|
||||
if (StrUtil.isNotBlank(sslOptions.getKeyStorePath())) {
|
||||
options.setKeyStoreOptions(new JksOptions()
|
||||
.setPath(sslOptions.getKeyStorePath())
|
||||
.setPassword(sslOptions.getKeyStorePassword()));
|
||||
}
|
||||
}
|
||||
// 1.4 安全警告日志
|
||||
if (Boolean.TRUE.equals(emqxProperties.getTrustAll())) {
|
||||
log.warn("[createMqttClient][安全警告:当前配置信任所有 SSL 证书(trustAll=true),这在生产环境中存在严重安全风险!]");
|
||||
}
|
||||
|
||||
// 2. 创建客户端实例
|
||||
this.mqttClient = MqttClient.create(vertx, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 MQTT 处理器
|
||||
*/
|
||||
private void setupMqttHandlers() {
|
||||
// 1. 设置断开重连监听器
|
||||
mqttClient.closeHandler(closeEvent -> {
|
||||
if (!isRunning) {
|
||||
return;
|
||||
}
|
||||
log.warn("[closeHandler][MQTT 连接已断开, 准备重连]");
|
||||
reconnectWithDelay();
|
||||
});
|
||||
|
||||
// 2. 设置异常处理器
|
||||
mqttClient.exceptionHandler(exception ->
|
||||
log.error("[exceptionHandler][MQTT 客户端异常]", exception));
|
||||
|
||||
// 3. 设置消息处理器
|
||||
mqttClient.publishHandler(upstreamHandler::handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅设备上行消息主题
|
||||
*/
|
||||
private void subscribeToTopics() {
|
||||
// 1. 校验 MQTT 客户端是否连接
|
||||
List<String> topicList = emqxProperties.getMqttTopics();
|
||||
if (mqttClient == null || !mqttClient.isConnected()) {
|
||||
log.warn("[subscribeToTopics][MQTT 客户端未连接, 跳过订阅]");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 批量订阅所有主题
|
||||
Map<String, Integer> topics = new HashMap<>();
|
||||
int qos = emqxProperties.getMqttQos();
|
||||
for (String topic : topicList) {
|
||||
topics.put(topic, qos);
|
||||
}
|
||||
mqttClient.subscribe(topics, subscribeResult -> {
|
||||
if (subscribeResult.succeeded()) {
|
||||
log.info("[subscribeToTopics][订阅主题成功, 共 {} 个主题]", topicList.size());
|
||||
} else {
|
||||
log.error("[subscribeToTopics][订阅主题失败, 共 {} 个主题, 原因: {}]",
|
||||
topicList.size(), subscribeResult.cause().getMessage(), subscribeResult.cause());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布消息到 MQTT Broker
|
||||
*
|
||||
* @param topic 主题
|
||||
* @param payload 消息内容
|
||||
*/
|
||||
public void publishMessage(String topic, byte[] payload) {
|
||||
if (mqttClient == null || !mqttClient.isConnected()) {
|
||||
log.warn("[publishMessage][MQTT 客户端未连接, 无法发布消息]");
|
||||
return;
|
||||
}
|
||||
MqttQoS qos = MqttQoS.valueOf(emqxProperties.getMqttQos());
|
||||
mqttClient.publish(topic, Buffer.buffer(payload), qos, false, false);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
|
||||
@@ -21,13 +21,13 @@ import lombok.extern.slf4j.Slf4j;
|
||||
@Slf4j
|
||||
public class IotEmqxDownstreamHandler {
|
||||
|
||||
private final IotEmqxUpstreamProtocol protocol;
|
||||
private final IotEmqxProtocol protocol;
|
||||
|
||||
private final IotDeviceService deviceService;
|
||||
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
public IotEmqxDownstreamHandler(IotEmqxUpstreamProtocol protocol) {
|
||||
public IotEmqxDownstreamHandler(IotEmqxProtocol protocol) {
|
||||
this.protocol = protocol;
|
||||
this.deviceService = SpringUtil.getBean(IotDeviceService.class);
|
||||
this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
@@ -53,9 +53,10 @@ public class IotEmqxDownstreamHandler {
|
||||
return;
|
||||
}
|
||||
// 2.2 构建载荷
|
||||
byte[] payload = deviceMessageService.encodeDeviceMessage(message, deviceInfo.getProductKey(),
|
||||
byte[] payload = deviceMessageService.serializeDeviceMessage(message, deviceInfo.getProductKey(),
|
||||
deviceInfo.getDeviceName());
|
||||
// 2.3 发布消息
|
||||
|
||||
// 3. 发布消息
|
||||
protocol.publishMessage(topic, payload);
|
||||
}
|
||||
|
||||
@@ -74,4 +75,4 @@ public class IotEmqxDownstreamHandler {
|
||||
return IotMqttTopicUtils.buildTopicByMethod(message.getMethod(), productKey, deviceName, isReply);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.downstream;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT 网关 EMQX 订阅者:接收下行给设备的消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotEmqxDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber {
|
||||
|
||||
private final IotEmqxDownstreamHandler downstreamHandler;
|
||||
|
||||
public IotEmqxDownstreamSubscriber(IotEmqxProtocol protocol, IotMessageBus messageBus) {
|
||||
super(protocol, messageBus);
|
||||
this.downstreamHandler = new IotEmqxDownstreamHandler(protocol);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleMessage(IotDeviceMessage message) {
|
||||
downstreamHandler.handle(message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,25 +1,35 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceAuthUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* IoT 网关 EMQX 认证事件处理器
|
||||
* <p>
|
||||
* 为 EMQX 提供 HTTP 接口服务,包括:
|
||||
* 1. 设备认证接口 - 对应 EMQX HTTP 认证插件
|
||||
* 2. 设备事件处理接口 - 对应 EMQX Webhook 事件通知
|
||||
* 1. 设备认证接口 - 对应 EMQX HTTP 认证插件 {@link #handleAuth(RoutingContext)}
|
||||
* 2. 设备事件处理接口 - 对应 EMQX Webhook 事件通知 {@link #handleEvent(RoutingContext)}
|
||||
* 3. 设备 ACL 权限接口 - 对应 EMQX HTTP ACL 插件 {@link #handleAcl(RoutingContext)}
|
||||
* 4. 设备注册接口 - 集成一型一密设备注册 {@link #handleDeviceRegister(RoutingContext, String, String)}
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@@ -45,30 +55,43 @@ public class IotEmqxAuthEventHandler {
|
||||
private static final String RESULT_IGNORE = "ignore";
|
||||
|
||||
/**
|
||||
* EMQX 事件类型常量
|
||||
* EMQX 事件类型常量 - 客户端连接
|
||||
*/
|
||||
private static final String EVENT_CLIENT_CONNECTED = "client.connected";
|
||||
/**
|
||||
* EMQX 事件类型常量 - 客户端断开连接
|
||||
*/
|
||||
private static final String EVENT_CLIENT_DISCONNECTED = "client.disconnected";
|
||||
|
||||
/**
|
||||
* 认证类型标识 - 设备注册
|
||||
*/
|
||||
private static final String AUTH_TYPE_REGISTER = "|authType=register|";
|
||||
|
||||
private final String serverId;
|
||||
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
private final IotEmqxProtocol protocol;
|
||||
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
|
||||
public IotEmqxAuthEventHandler(String serverId) {
|
||||
public IotEmqxAuthEventHandler(String serverId, IotEmqxProtocol protocol) {
|
||||
this.serverId = serverId;
|
||||
this.protocol = protocol;
|
||||
this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
}
|
||||
|
||||
// ========== 认证处理 ==========
|
||||
|
||||
/**
|
||||
* EMQX 认证接口
|
||||
*/
|
||||
public void handleAuth(RoutingContext context) {
|
||||
JsonObject body = null;
|
||||
try {
|
||||
// 1. 参数校验
|
||||
JsonObject body = parseRequestBody(context);
|
||||
body = parseRequestBody(context);
|
||||
if (body == null) {
|
||||
return;
|
||||
}
|
||||
@@ -82,7 +105,13 @@ public class IotEmqxAuthEventHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 执行认证
|
||||
// 2.1 情况一:判断是否为注册请求
|
||||
if (StrUtil.endWith(clientId, AUTH_TYPE_REGISTER)) {
|
||||
handleDeviceRegister(context, username, password);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.2 情况二:执行认证
|
||||
boolean authResult = handleDeviceAuth(clientId, username, password);
|
||||
log.info("[handleAuth][设备认证结果: {} -> {}]", username, authResult);
|
||||
if (authResult) {
|
||||
@@ -91,11 +120,179 @@ public class IotEmqxAuthEventHandler {
|
||||
sendAuthResponse(context, RESULT_DENY);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[handleAuth][设备认证异常]", e);
|
||||
log.error("[handleAuth][设备认证异常][body={}]", body, e);
|
||||
sendAuthResponse(context, RESULT_IGNORE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析认证接口请求体
|
||||
* <p>
|
||||
* 认证接口解析失败时返回 JSON 格式响应(包含 result 字段)
|
||||
*
|
||||
* @param context 路由上下文
|
||||
* @return 请求体JSON对象,解析失败时返回null
|
||||
*/
|
||||
private JsonObject parseRequestBody(RoutingContext context) {
|
||||
try {
|
||||
JsonObject body = context.body().asJsonObject();
|
||||
if (body == null) {
|
||||
log.info("[parseRequestBody][请求体为空]");
|
||||
sendAuthResponse(context, RESULT_IGNORE);
|
||||
return null;
|
||||
}
|
||||
return body;
|
||||
} catch (Exception e) {
|
||||
log.error("[parseRequestBody][body({}) 解析请求体失败]", context.body().asString(), e);
|
||||
sendAuthResponse(context, RESULT_IGNORE);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行设备认证
|
||||
*
|
||||
* @param clientId 客户端ID
|
||||
* @param username 用户名
|
||||
* @param password 密码
|
||||
* @return 认证是否成功
|
||||
*/
|
||||
private boolean handleDeviceAuth(String clientId, String username, String password) {
|
||||
try {
|
||||
CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO()
|
||||
.setClientId(clientId).setUsername(username).setPassword(password));
|
||||
result.checkError();
|
||||
return BooleanUtil.isTrue(result.getData());
|
||||
} catch (Exception e) {
|
||||
log.error("[handleDeviceAuth][设备({}) 认证接口调用失败]", username, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 EMQX 认证响应
|
||||
* 根据 EMQX 官方文档要求,必须返回 JSON 格式响应
|
||||
*
|
||||
* @param context 路由上下文
|
||||
* @param result 认证结果:allow、deny、ignore
|
||||
*/
|
||||
private void sendAuthResponse(RoutingContext context, String result) {
|
||||
// 构建符合 EMQX 官方规范的响应
|
||||
JsonObject response = new JsonObject()
|
||||
.put("result", result)
|
||||
.put("is_superuser", false);
|
||||
// 可以根据业务需求添加客户端属性
|
||||
// response.put("client_attrs", new JsonObject().put("role", "device"));
|
||||
// 可以添加认证过期时间(可选)
|
||||
// response.put("expire_at", System.currentTimeMillis() / 1000 + 3600);
|
||||
|
||||
// 回复响应
|
||||
context.response()
|
||||
.setStatusCode(SUCCESS_STATUS_CODE)
|
||||
.putHeader("Content-Type", "application/json; charset=utf-8")
|
||||
.end(response.encode());
|
||||
}
|
||||
|
||||
// ========== ACL 处理 ==========
|
||||
|
||||
/**
|
||||
* EMQX ACL 接口
|
||||
* <p>
|
||||
* 用于 EMQX 的 HTTP ACL 插件校验设备的 publish/subscribe 权限。
|
||||
* 若请求参数无法识别,则返回 ignore 交给 EMQX 自身 ACL 规则处理。
|
||||
*/
|
||||
public void handleAcl(RoutingContext context) {
|
||||
JsonObject body = null;
|
||||
try {
|
||||
// 1.1 解析请求体
|
||||
body = parseRequestBody(context);
|
||||
if (body == null) {
|
||||
return;
|
||||
}
|
||||
String username = body.getString("username");
|
||||
String topic = body.getString("topic");
|
||||
if (StrUtil.hasBlank(username, topic)) {
|
||||
log.info("[handleAcl][ACL 参数不完整: username={}, topic={}]", username, topic);
|
||||
sendAuthResponse(context, RESULT_IGNORE);
|
||||
return;
|
||||
}
|
||||
// 1.2 解析设备身份
|
||||
IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(username);
|
||||
if (deviceInfo == null) {
|
||||
sendAuthResponse(context, RESULT_IGNORE);
|
||||
return;
|
||||
}
|
||||
// 1.3 解析 ACL 动作(兼容多种 EMQX 版本/插件字段)
|
||||
Boolean subscribe = parseAclSubscribeFlag(body);
|
||||
if (subscribe == null) {
|
||||
sendAuthResponse(context, RESULT_IGNORE);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 执行 ACL 校验
|
||||
boolean allowed = subscribe
|
||||
? IotMqttTopicUtils.isTopicSubscribeAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName())
|
||||
: IotMqttTopicUtils.isTopicPublishAllowed(topic, deviceInfo.getProductKey(), deviceInfo.getDeviceName());
|
||||
sendAuthResponse(context, allowed ? RESULT_ALLOW : RESULT_DENY);
|
||||
} catch (Exception e) {
|
||||
log.error("[handleAcl][ACL 处理失败][body={}]", body, e);
|
||||
sendAuthResponse(context, RESULT_IGNORE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 ACL 动作类型:订阅/发布
|
||||
*
|
||||
* @param body ACL 请求体
|
||||
* @return true 订阅;false 发布;null 不识别
|
||||
*/
|
||||
private static Boolean parseAclSubscribeFlag(JsonObject body) {
|
||||
// 1. action 字段(常见为 publish/subscribe)
|
||||
String action = body.getString("action");
|
||||
if (StrUtil.isNotBlank(action)) {
|
||||
String lower = action.toLowerCase(Locale.ROOT);
|
||||
if (lower.contains("sub")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.contains("pub")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. access 字段:可能是数字或字符串
|
||||
Integer access = body.getInteger("access");
|
||||
if (access != null) {
|
||||
if (access == 1) {
|
||||
return true;
|
||||
}
|
||||
if (access == 2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
String accessText = body.getString("access");
|
||||
if (StrUtil.isNotBlank(accessText)) {
|
||||
String lower = accessText.toLowerCase(Locale.ROOT);
|
||||
if (lower.contains("sub")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.contains("pub")) {
|
||||
return false;
|
||||
}
|
||||
if (StrUtil.isNumeric(accessText)) {
|
||||
int value = Integer.parseInt(accessText);
|
||||
if (value == 1) {
|
||||
return true;
|
||||
}
|
||||
if (value == 2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ========== 事件处理 ==========
|
||||
|
||||
/**
|
||||
* EMQX 统一事件处理接口:根据 EMQX 官方 Webhook 设计,统一处理所有客户端事件
|
||||
* 支持的事件类型:client.connected、client.disconnected 等
|
||||
@@ -124,58 +321,15 @@ public class IotEmqxAuthEventHandler {
|
||||
break;
|
||||
}
|
||||
|
||||
// EMQX Webhook 只需要 200 状态码,无需响应体
|
||||
// 3. EMQX Webhook 只需要 200 状态码,无需响应体
|
||||
context.response().setStatusCode(SUCCESS_STATUS_CODE).end();
|
||||
} catch (Exception e) {
|
||||
log.error("[handleEvent][事件处理失败][body={}]", body != null ? body.encode() : "null", e);
|
||||
// 即使处理失败,也返回 200 避免EMQX重试
|
||||
log.error("[handleEvent][事件处理失败][body={}]", body, e);
|
||||
// 即使处理失败,也返回 200 避免 EMQX 重试
|
||||
context.response().setStatusCode(SUCCESS_STATUS_CODE).end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端连接事件
|
||||
*/
|
||||
private void handleClientConnected(JsonObject body) {
|
||||
String username = body.getString("username");
|
||||
log.info("[handleClientConnected][设备上线: {}]", username);
|
||||
handleDeviceStateChange(username, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端断开连接事件
|
||||
*/
|
||||
private void handleClientDisconnected(JsonObject body) {
|
||||
String username = body.getString("username");
|
||||
String reason = body.getString("reason");
|
||||
log.info("[handleClientDisconnected][设备下线: {} ({})]", username, reason);
|
||||
handleDeviceStateChange(username, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析认证接口请求体
|
||||
* <p>
|
||||
* 认证接口解析失败时返回 JSON 格式响应(包含 result 字段)
|
||||
*
|
||||
* @param context 路由上下文
|
||||
* @return 请求体JSON对象,解析失败时返回null
|
||||
*/
|
||||
private JsonObject parseRequestBody(RoutingContext context) {
|
||||
try {
|
||||
JsonObject body = context.body().asJsonObject();
|
||||
if (body == null) {
|
||||
log.info("[parseRequestBody][请求体为空]");
|
||||
sendAuthResponse(context, RESULT_IGNORE);
|
||||
return null;
|
||||
}
|
||||
return body;
|
||||
} catch (Exception e) {
|
||||
log.error("[parseRequestBody][body({}) 解析请求体失败]", context.body().asString(), e);
|
||||
sendAuthResponse(context, RESULT_IGNORE);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析事件接口请求体
|
||||
* <p>
|
||||
@@ -201,23 +355,22 @@ public class IotEmqxAuthEventHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行设备认证
|
||||
*
|
||||
* @param clientId 客户端ID
|
||||
* @param username 用户名
|
||||
* @param password 密码
|
||||
* @return 认证是否成功
|
||||
* 处理客户端连接事件
|
||||
*/
|
||||
private boolean handleDeviceAuth(String clientId, String username, String password) {
|
||||
try {
|
||||
CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO()
|
||||
.setClientId(clientId).setUsername(username).setPassword(password));
|
||||
result.checkError();
|
||||
return BooleanUtil.isTrue(result.getData());
|
||||
} catch (Exception e) {
|
||||
log.error("[handleDeviceAuth][设备({}) 认证接口调用失败]", username, e);
|
||||
throw e;
|
||||
}
|
||||
private void handleClientConnected(JsonObject body) {
|
||||
String username = body.getString("username");
|
||||
log.info("[handleClientConnected][设备上线: {}]", username);
|
||||
handleDeviceStateChange(username, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端断开连接事件
|
||||
*/
|
||||
private void handleClientDisconnected(JsonObject body) {
|
||||
String username = body.getString("username");
|
||||
String reason = body.getString("reason");
|
||||
log.info("[handleClientDisconnected][设备下线: {} ({})]", username, reason);
|
||||
handleDeviceStateChange(username, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,29 +400,74 @@ public class IotEmqxAuthEventHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// ========= 注册处理 =========
|
||||
|
||||
/**
|
||||
* 发送 EMQX 认证响应
|
||||
* 根据 EMQX 官方文档要求,必须返回 JSON 格式响应
|
||||
* 处理设备注册请求(一型一密)
|
||||
*
|
||||
* @param context 路由上下文
|
||||
* @param result 认证结果:allow、deny、ignore
|
||||
* @param context 路由上下文
|
||||
* @param username 用户名
|
||||
* @param password 密码(签名)
|
||||
*/
|
||||
private void sendAuthResponse(RoutingContext context, String result) {
|
||||
// 构建符合 EMQX 官方规范的响应
|
||||
JsonObject response = new JsonObject()
|
||||
.put("result", result)
|
||||
.put("is_superuser", false);
|
||||
private void handleDeviceRegister(RoutingContext context, String username, String password) {
|
||||
try {
|
||||
// 1. 解析设备信息
|
||||
IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(username);
|
||||
if (deviceInfo == null) {
|
||||
log.warn("[handleDeviceRegister][设备注册失败: 无法解析 username={}]", username);
|
||||
sendAuthResponse(context, RESULT_DENY);
|
||||
return;
|
||||
}
|
||||
|
||||
// 可以根据业务需求添加客户端属性
|
||||
// response.put("client_attrs", new JsonObject().put("role", "device"));
|
||||
// 2. 调用注册 API
|
||||
IotDeviceRegisterReqDTO params = new IotDeviceRegisterReqDTO()
|
||||
.setProductKey(deviceInfo.getProductKey())
|
||||
.setDeviceName(deviceInfo.getDeviceName())
|
||||
.setSign(password);
|
||||
CommonResult<IotDeviceRegisterRespDTO> result = deviceApi.registerDevice(params);
|
||||
result.checkError();
|
||||
|
||||
// 可以添加认证过期时间(可选)
|
||||
// response.put("expire_at", System.currentTimeMillis() / 1000 + 3600);
|
||||
// 3. 允许连接
|
||||
log.info("[handleDeviceRegister][设备注册成功: {}]", username);
|
||||
sendAuthResponse(context, RESULT_ALLOW);
|
||||
|
||||
context.response()
|
||||
.setStatusCode(SUCCESS_STATUS_CODE)
|
||||
.putHeader("Content-Type", "application/json; charset=utf-8")
|
||||
.end(response.encode());
|
||||
// 4. 延迟 5 秒发送注册结果(等待设备连接成功并完成订阅)
|
||||
sendRegisterResultMessage(username, result.getData());
|
||||
} catch (Exception e) {
|
||||
log.warn("[handleDeviceRegister][设备注册失败: {}, 错误: {}]", username, e.getMessage());
|
||||
sendAuthResponse(context, RESULT_DENY);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* 发送注册结果消息给设备
|
||||
* <p>
|
||||
* 注意:延迟 5 秒发送,等待设备连接成功并完成订阅。
|
||||
*
|
||||
* @param username 用户名
|
||||
* @param result 注册结果
|
||||
*/
|
||||
@SuppressWarnings("DataFlowIssue")
|
||||
private void sendRegisterResultMessage(String username, IotDeviceRegisterRespDTO result) {
|
||||
IotDeviceIdentity deviceInfo = IotDeviceAuthUtils.parseUsername(username);
|
||||
Assert.notNull(deviceInfo, "设备信息不能为空");
|
||||
try {
|
||||
// 1.1 构建响应消息
|
||||
String method = IotDeviceMessageMethodEnum.DEVICE_REGISTER.getMethod();
|
||||
IotDeviceMessage responseMessage = IotDeviceMessage.replyOf(null, method, result, 0, null);
|
||||
// 1.2 序列化消息
|
||||
byte[] encodedData = deviceMessageService.serializeDeviceMessage(responseMessage,
|
||||
cn.iocoder.yudao.module.iot.core.enums.IotSerializeTypeEnum.JSON);
|
||||
// 1.3 构建响应主题
|
||||
String replyTopic = IotMqttTopicUtils.buildTopicByMethod(method,
|
||||
deviceInfo.getProductKey(), deviceInfo.getDeviceName(), true);
|
||||
|
||||
// 2. 构建响应主题,并延迟发布(等待设备连接成功并完成订阅)
|
||||
protocol.publishDelayMessage(replyTopic, encodedData, 5000);
|
||||
log.info("[sendRegisterResultMessage][发送注册结果: topic={}]", replyTopic);
|
||||
} catch (Exception e) {
|
||||
log.error("[sendRegisterResultMessage][发送注册结果失败: {}]", username, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.router;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.emqx.handler.upstream;
|
||||
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.emqx.IotEmqxUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.util.IotMqttTopicUtils;
|
||||
import io.vertx.mqtt.messages.MqttPublishMessage;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -20,41 +21,42 @@ public class IotEmqxUpstreamHandler {
|
||||
|
||||
private final String serverId;
|
||||
|
||||
public IotEmqxUpstreamHandler(IotEmqxUpstreamProtocol protocol) {
|
||||
public IotEmqxUpstreamHandler(String serverId) {
|
||||
this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
this.serverId = protocol.getServerId();
|
||||
this.serverId = serverId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 MQTT 发布消息
|
||||
*/
|
||||
public void handle(MqttPublishMessage mqttMessage) {
|
||||
log.info("[handle][收到 MQTT 消息, topic: {}, payload: {}]", mqttMessage.topicName(), mqttMessage.payload());
|
||||
log.debug("[handle][收到 MQTT 消息, topic: {}, payload: {}]", mqttMessage.topicName(), mqttMessage.payload());
|
||||
String topic = mqttMessage.topicName();
|
||||
byte[] payload = mqttMessage.payload().getBytes();
|
||||
try {
|
||||
// 1. 解析主题,一次性获取所有信息
|
||||
String[] topicParts = topic.split("/");
|
||||
if (topicParts.length < 4 || StrUtil.hasBlank(topicParts[2], topicParts[3])) {
|
||||
String productKey = ArrayUtil.get(topicParts, 2);
|
||||
String deviceName = ArrayUtil.get(topicParts, 3);
|
||||
if (topicParts.length < 4 || StrUtil.hasBlank(productKey, deviceName)) {
|
||||
log.warn("[handle][topic({}) 格式不正确,无法解析有效的 productKey 和 deviceName]", topic);
|
||||
return;
|
||||
}
|
||||
|
||||
String productKey = topicParts[2];
|
||||
String deviceName = topicParts[3];
|
||||
|
||||
// 3. 解码消息
|
||||
IotDeviceMessage message = deviceMessageService.decodeDeviceMessage(payload, productKey, deviceName);
|
||||
// 2.1 反序列化消息
|
||||
IotDeviceMessage message = deviceMessageService.deserializeDeviceMessage(payload, productKey, deviceName);
|
||||
if (message == null) {
|
||||
log.warn("[handle][topic({}) payload({}) 消息解码失败]", topic, new String(payload));
|
||||
return;
|
||||
}
|
||||
// 2.2 标准化回复消息的 method(MQTT 协议中,设备回复消息的 method 会携带 _reply 后缀)
|
||||
IotMqttTopicUtils.normalizeReplyMethod(message);
|
||||
|
||||
// 4. 发送消息到队列
|
||||
// 3. 发送消息到队列
|
||||
deviceMessageService.sendDeviceMessage(message, productKey, deviceName, serverId);
|
||||
} catch (Exception e) {
|
||||
log.error("[handle][topic({}) payload({}) 处理异常]", topic, new String(payload), e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT HTTP 协议配置
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
public class IotHttpConfig {
|
||||
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT 网关 HTTP 订阅者:接收下行给设备的消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IotHttpDownstreamSubscriber implements IotMessageSubscriber<IotDeviceMessage> {
|
||||
|
||||
private final IotHttpUpstreamProtocol protocol;
|
||||
|
||||
private final IotMessageBus messageBus;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
messageBus.register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTopic() {
|
||||
return IotDeviceMessageUtils.buildMessageBusGatewayDeviceMessageTopic(protocol.getServerId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getGroup() {
|
||||
// 保证点对点消费,需要保证独立的 Group,所以使用 Topic 作为 Group
|
||||
return getTopic();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessage(IotDeviceMessage message) {
|
||||
log.info("[onMessage][IoT 网关 HTTP 协议不支持下行消息,忽略消息:{}]", message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.downstream.IotHttpDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream.IotHttpAuthHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream.IotHttpRegisterHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream.IotHttpRegisterSubHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream.IotHttpUpstreamHandler;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.http.HttpServer;
|
||||
import io.vertx.core.http.HttpServerOptions;
|
||||
import io.vertx.core.net.PemKeyCertOptions;
|
||||
import io.vertx.ext.web.Router;
|
||||
import io.vertx.ext.web.handler.BodyHandler;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT HTTP 协议实现
|
||||
* <p>
|
||||
* 基于 Vert.x 实现 HTTP 服务器,接收设备上行消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotHttpProtocol implements IotProtocol {
|
||||
|
||||
/**
|
||||
* 协议配置
|
||||
*/
|
||||
private final ProtocolProperties properties;
|
||||
/**
|
||||
* 服务器 ID(用于消息追踪,全局唯一)
|
||||
*/
|
||||
@Getter
|
||||
private final String serverId;
|
||||
|
||||
/**
|
||||
* 运行状态
|
||||
*/
|
||||
@Getter
|
||||
private volatile boolean running = false;
|
||||
|
||||
/**
|
||||
* Vert.x 实例
|
||||
*/
|
||||
private Vertx vertx;
|
||||
/**
|
||||
* HTTP 服务器
|
||||
*/
|
||||
private HttpServer httpServer;
|
||||
|
||||
/**
|
||||
* 下行消息订阅者
|
||||
*/
|
||||
private IotHttpDownstreamSubscriber downstreamSubscriber;
|
||||
|
||||
public IotHttpProtocol(ProtocolProperties properties) {
|
||||
this.properties = properties;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return properties.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IotProtocolTypeEnum getType() {
|
||||
return IotProtocolTypeEnum.HTTP;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (running) {
|
||||
log.warn("[start][IoT HTTP 协议 {} 已经在运行中]", getId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 1.1 创建 Vertx 实例
|
||||
this.vertx = Vertx.vertx();
|
||||
|
||||
// 1.2 创建路由
|
||||
Router router = Router.router(vertx);
|
||||
router.route().handler(BodyHandler.create());
|
||||
|
||||
// 1.3 创建处理器,添加路由处理器
|
||||
IotHttpAuthHandler authHandler = new IotHttpAuthHandler(this);
|
||||
router.post(IotHttpAuthHandler.PATH).handler(authHandler);
|
||||
IotHttpRegisterHandler registerHandler = new IotHttpRegisterHandler();
|
||||
router.post(IotHttpRegisterHandler.PATH).handler(registerHandler);
|
||||
IotHttpRegisterSubHandler registerSubHandler = new IotHttpRegisterSubHandler();
|
||||
router.post(IotHttpRegisterSubHandler.PATH).handler(registerSubHandler);
|
||||
IotHttpUpstreamHandler upstreamHandler = new IotHttpUpstreamHandler(this);
|
||||
router.post(IotHttpUpstreamHandler.PATH).handler(upstreamHandler);
|
||||
|
||||
// 1.4 启动 HTTP 服务器
|
||||
HttpServerOptions options = new HttpServerOptions().setPort(properties.getPort());
|
||||
IotGatewayProperties.SslConfig sslConfig = properties.getSsl();
|
||||
if (sslConfig != null && Boolean.TRUE.equals(sslConfig.getSsl())) {
|
||||
PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions()
|
||||
.setKeyPath(sslConfig.getSslKeyPath())
|
||||
.setCertPath(sslConfig.getSslCertPath());
|
||||
options = options.setSsl(true).setKeyCertOptions(pemKeyCertOptions);
|
||||
}
|
||||
try {
|
||||
httpServer = vertx.createHttpServer(options)
|
||||
.requestHandler(router)
|
||||
.listen()
|
||||
.result();
|
||||
running = true;
|
||||
log.info("[start][IoT HTTP 协议 {} 启动成功,端口:{},serverId:{}]",
|
||||
getId(), properties.getPort(), serverId);
|
||||
|
||||
// 2. 启动下行消息订阅者
|
||||
IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class);
|
||||
this.downstreamSubscriber = new IotHttpDownstreamSubscriber(this, messageBus);
|
||||
this.downstreamSubscriber.start();
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT HTTP 协议 {} 启动失败]", getId(), e);
|
||||
stop0();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
stop0();
|
||||
}
|
||||
|
||||
private void stop0() {
|
||||
// 1. 停止下行消息订阅者
|
||||
if (downstreamSubscriber != null) {
|
||||
try {
|
||||
downstreamSubscriber.stop();
|
||||
log.info("[stop][IoT HTTP 协议 {} 下行消息订阅者已停止]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT HTTP 协议 {} 下行消息订阅者停止失败]", getId(), e);
|
||||
}
|
||||
downstreamSubscriber = null;
|
||||
}
|
||||
|
||||
// 2.1 关闭 HTTP 服务器
|
||||
if (httpServer != null) {
|
||||
try {
|
||||
httpServer.close().result();
|
||||
log.info("[stop][IoT HTTP 协议 {} 服务器已停止]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT HTTP 协议 {} 服务器停止失败]", getId(), e);
|
||||
}
|
||||
httpServer = null;
|
||||
}
|
||||
// 2.2 关闭 Vertx 实例
|
||||
if (vertx != null) {
|
||||
try {
|
||||
vertx.close().result();
|
||||
log.info("[stop][IoT HTTP 协议 {} Vertx 已关闭]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT HTTP 协议 {} Vertx 关闭失败]", getId(), e);
|
||||
}
|
||||
vertx = null;
|
||||
}
|
||||
running = false;
|
||||
log.info("[stop][IoT HTTP 协议 {} 已停止]", getId());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpAuthHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpRegisterHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpRegisterSubHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.router.IotHttpUpstreamHandler;
|
||||
import io.vertx.core.Vertx;
|
||||
import io.vertx.core.http.HttpServer;
|
||||
import io.vertx.core.http.HttpServerOptions;
|
||||
import io.vertx.core.net.PemKeyCertOptions;
|
||||
import io.vertx.ext.web.Router;
|
||||
import io.vertx.ext.web.handler.BodyHandler;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT 网关 HTTP 协议:接收设备上行消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotHttpUpstreamProtocol {
|
||||
|
||||
private final IotGatewayProperties.HttpProperties httpProperties;
|
||||
|
||||
private final Vertx vertx;
|
||||
|
||||
private HttpServer httpServer;
|
||||
|
||||
@Getter
|
||||
private final String serverId;
|
||||
|
||||
public IotHttpUpstreamProtocol(IotGatewayProperties.HttpProperties httpProperties, Vertx vertx) {
|
||||
this.httpProperties = httpProperties;
|
||||
this.vertx = vertx;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(httpProperties.getServerPort());
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void start() {
|
||||
// 创建路由
|
||||
Router router = Router.router(vertx);
|
||||
router.route().handler(BodyHandler.create());
|
||||
|
||||
// 创建处理器,添加路由处理器
|
||||
IotHttpAuthHandler authHandler = new IotHttpAuthHandler(this);
|
||||
router.post(IotHttpAuthHandler.PATH).handler(authHandler);
|
||||
IotHttpRegisterHandler registerHandler = new IotHttpRegisterHandler();
|
||||
router.post(IotHttpRegisterHandler.PATH).handler(registerHandler);
|
||||
IotHttpRegisterSubHandler registerSubHandler = new IotHttpRegisterSubHandler();
|
||||
router.post(IotHttpRegisterSubHandler.PATH).handler(registerSubHandler);
|
||||
IotHttpUpstreamHandler upstreamHandler = new IotHttpUpstreamHandler(this);
|
||||
router.post(IotHttpUpstreamHandler.PATH).handler(upstreamHandler);
|
||||
|
||||
// 启动 HTTP 服务器
|
||||
HttpServerOptions options = new HttpServerOptions()
|
||||
.setPort(httpProperties.getServerPort());
|
||||
if (Boolean.TRUE.equals(httpProperties.getSslEnabled())) {
|
||||
PemKeyCertOptions pemKeyCertOptions = new PemKeyCertOptions().setKeyPath(httpProperties.getSslKeyPath())
|
||||
.setCertPath(httpProperties.getSslCertPath());
|
||||
options = options.setSsl(true).setKeyCertOptions(pemKeyCertOptions);
|
||||
}
|
||||
try {
|
||||
httpServer = vertx.createHttpServer(options)
|
||||
.requestHandler(router)
|
||||
.listen()
|
||||
.result();
|
||||
log.info("[start][IoT 网关 HTTP 协议启动成功,端口:{}]", httpProperties.getServerPort());
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT 网关 HTTP 协议启动失败]", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
if (httpServer != null) {
|
||||
try {
|
||||
httpServer.close().result();
|
||||
log.info("[stop][IoT 网关 HTTP 协议已停止]");
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT 网关 HTTP 协议停止失败]", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.downstream;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT 网关 HTTP 订阅者:接收下行给设备的消息
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
|
||||
@Slf4j
|
||||
public class IotHttpDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber {
|
||||
|
||||
public IotHttpDownstreamSubscriber(IotProtocol protocol, IotMessageBus messageBus) {
|
||||
super(protocol, messageBus);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleMessage(IotDeviceMessage message) {
|
||||
log.info("[handleMessage][IoT 网关 HTTP 协议不支持下行消息,忽略消息:{}]", message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.router;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
@@ -13,12 +14,10 @@ import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
|
||||
import io.vertx.core.Handler;
|
||||
import io.vertx.core.http.HttpHeaders;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN;
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR;
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.*;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
|
||||
|
||||
@@ -27,7 +26,6 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public abstract class IotHttpAbstractHandler implements Handler<RoutingContext> {
|
||||
|
||||
@@ -43,15 +41,31 @@ public abstract class IotHttpAbstractHandler implements Handler<RoutingContext>
|
||||
CommonResult<Object> result = handle0(context);
|
||||
writeResponse(context, result);
|
||||
} catch (ServiceException e) {
|
||||
// 已知异常,返回对应的错误码和错误信息
|
||||
writeResponse(context, CommonResult.error(e.getCode(), e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
// 参数校验异常,返回 400 错误
|
||||
writeResponse(context, CommonResult.error(BAD_REQUEST.getCode(), e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
// 其他未知异常,返回 500 错误
|
||||
log.error("[handle][path({}) 处理异常]", context.request().path(), e);
|
||||
writeResponse(context, CommonResult.error(INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 请求(子类实现)
|
||||
*
|
||||
* @param context RoutingContext 对象
|
||||
* @return 处理结果
|
||||
*/
|
||||
protected abstract CommonResult<Object> handle0(RoutingContext context);
|
||||
|
||||
/**
|
||||
* 前置处理:认证等
|
||||
*
|
||||
* @param context RoutingContext 对象
|
||||
*/
|
||||
private void beforeHandle(RoutingContext context) {
|
||||
// 如果不需要认证,则不走前置处理
|
||||
String path = context.request().path();
|
||||
@@ -83,12 +97,26 @@ public abstract class IotHttpAbstractHandler implements Handler<RoutingContext>
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 序列化相关方法 ==========
|
||||
|
||||
protected static <T> T deserializeRequest(RoutingContext context, Class<T> clazz) {
|
||||
byte[] body = context.body().buffer() != null ? context.body().buffer().getBytes() : null;
|
||||
if (ArrayUtil.isEmpty(body)) {
|
||||
throw invalidParamException("请求体不能为空");
|
||||
}
|
||||
return JsonUtils.parseObject(body, clazz);
|
||||
}
|
||||
|
||||
private static String serializeResponse(Object data) {
|
||||
return JsonUtils.toJsonString(data);
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public static void writeResponse(RoutingContext context, Object data) {
|
||||
public static void writeResponse(RoutingContext context, CommonResult<?> data) {
|
||||
context.response()
|
||||
.setStatusCode(200)
|
||||
.putHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_UTF8_VALUE)
|
||||
.end(JsonUtils.toJsonString(data));
|
||||
.end(serializeResponse(data));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,23 +1,20 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.router;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotDeviceAuthReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.IotDeviceIdentity;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.auth.IotDeviceTokenService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.yudao.module.iot.gateway.enums.ErrorCodeConstants.DEVICE_AUTH_FAIL;
|
||||
|
||||
@@ -32,7 +29,7 @@ public class IotHttpAuthHandler extends IotHttpAbstractHandler {
|
||||
|
||||
public static final String PATH = "/auth";
|
||||
|
||||
private final IotHttpUpstreamProtocol protocol;
|
||||
private final String serverId;
|
||||
|
||||
private final IotDeviceTokenService deviceTokenService;
|
||||
|
||||
@@ -40,42 +37,31 @@ public class IotHttpAuthHandler extends IotHttpAbstractHandler {
|
||||
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
public IotHttpAuthHandler(IotHttpUpstreamProtocol protocol) {
|
||||
this.protocol = protocol;
|
||||
public IotHttpAuthHandler(IotHttpProtocol protocol) {
|
||||
this.serverId = protocol.getServerId();
|
||||
this.deviceTokenService = SpringUtil.getBean(IotDeviceTokenService.class);
|
||||
this.deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("DuplicatedCode")
|
||||
public CommonResult<Object> handle0(RoutingContext context) {
|
||||
// 1. 解析参数
|
||||
JsonObject body = context.body().asJsonObject();
|
||||
if (body == null) {
|
||||
throw invalidParamException("请求体不能为空");
|
||||
}
|
||||
String clientId = body.getString("clientId");
|
||||
if (StrUtil.isEmpty(clientId)) {
|
||||
throw invalidParamException("clientId 不能为空");
|
||||
}
|
||||
String username = body.getString("username");
|
||||
if (StrUtil.isEmpty(username)) {
|
||||
throw invalidParamException("username 不能为空");
|
||||
}
|
||||
String password = body.getString("password");
|
||||
if (StrUtil.isEmpty(password)) {
|
||||
throw invalidParamException("password 不能为空");
|
||||
}
|
||||
IotDeviceAuthReqDTO request = deserializeRequest(context, IotDeviceAuthReqDTO.class);
|
||||
Assert.notNull(request, "请求参数不能为空");
|
||||
Assert.notBlank(request.getClientId(), "clientId 不能为空");
|
||||
Assert.notBlank(request.getUsername(), "username 不能为空");
|
||||
Assert.notBlank(request.getPassword(), "password 不能为空");
|
||||
|
||||
// 2.1 执行认证
|
||||
CommonResult<Boolean> result = deviceApi.authDevice(new IotDeviceAuthReqDTO()
|
||||
.setClientId(clientId).setUsername(username).setPassword(password));
|
||||
CommonResult<Boolean> result = deviceApi.authDevice(request);
|
||||
result.checkError();
|
||||
if (!BooleanUtil.isTrue(result.getData())) {
|
||||
if (BooleanUtil.isFalse(result.getData())) {
|
||||
throw exception(DEVICE_AUTH_FAIL);
|
||||
}
|
||||
// 2.2 生成 Token
|
||||
IotDeviceIdentity deviceInfo = deviceTokenService.parseUsername(username);
|
||||
IotDeviceIdentity deviceInfo = deviceTokenService.parseUsername(request.getUsername());
|
||||
Assert.notNull(deviceInfo, "设备信息不能为空");
|
||||
String token = deviceTokenService.createToken(deviceInfo.getProductKey(), deviceInfo.getDeviceName());
|
||||
Assert.notBlank(token, "生成 token 不能为空位");
|
||||
@@ -83,7 +69,7 @@ public class IotHttpAuthHandler extends IotHttpAbstractHandler {
|
||||
// 3. 执行上线
|
||||
IotDeviceMessage message = IotDeviceMessage.buildStateUpdateOnline();
|
||||
deviceMessageService.sendDeviceMessage(message,
|
||||
deviceInfo.getProductKey(), deviceInfo.getDeviceName(), protocol.getServerId());
|
||||
deviceInfo.getProductKey(), deviceInfo.getDeviceName(), serverId);
|
||||
|
||||
// 构建响应数据
|
||||
return success(MapUtil.of("token", token));
|
||||
@@ -1,15 +1,13 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.router;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotDeviceRegisterRespDTO;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
@@ -33,27 +31,14 @@ public class IotHttpRegisterHandler extends IotHttpAbstractHandler {
|
||||
@Override
|
||||
public CommonResult<Object> handle0(RoutingContext context) {
|
||||
// 1. 解析参数
|
||||
JsonObject body = context.body().asJsonObject();
|
||||
if (body == null) {
|
||||
throw invalidParamException("请求体不能为空");
|
||||
}
|
||||
String productKey = body.getString("productKey");
|
||||
if (StrUtil.isEmpty(productKey)) {
|
||||
throw invalidParamException("productKey 不能为空");
|
||||
}
|
||||
String deviceName = body.getString("deviceName");
|
||||
if (StrUtil.isEmpty(deviceName)) {
|
||||
throw invalidParamException("deviceName 不能为空");
|
||||
}
|
||||
String productSecret = body.getString("productSecret");
|
||||
if (StrUtil.isEmpty(productSecret)) {
|
||||
throw invalidParamException("productSecret 不能为空");
|
||||
}
|
||||
IotDeviceRegisterReqDTO request = deserializeRequest(context, IotDeviceRegisterReqDTO.class);
|
||||
Assert.notNull(request, "请求参数不能为空");
|
||||
Assert.notBlank(request.getProductKey(), "productKey 不能为空");
|
||||
Assert.notBlank(request.getDeviceName(), "deviceName 不能为空");
|
||||
Assert.notBlank(request.getSign(), "sign 不能为空");
|
||||
|
||||
// 2. 调用动态注册
|
||||
IotDeviceRegisterReqDTO reqDTO = new IotDeviceRegisterReqDTO()
|
||||
.setProductKey(productKey).setDeviceName(deviceName).setProductSecret(productSecret);
|
||||
CommonResult<IotDeviceRegisterRespDTO> result = deviceApi.registerDevice(reqDTO);
|
||||
CommonResult<IotDeviceRegisterRespDTO> result = deviceApi.registerDevice(request);
|
||||
result.checkError();
|
||||
|
||||
// 3. 返回结果
|
||||
@@ -1,17 +1,17 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.router;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotSubDeviceRegisterFullReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterRespDTO;
|
||||
import io.vertx.core.json.JsonObject;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
@@ -39,29 +39,31 @@ public class IotHttpRegisterSubHandler extends IotHttpAbstractHandler {
|
||||
|
||||
@Override
|
||||
public CommonResult<Object> handle0(RoutingContext context) {
|
||||
// 1. 解析通用参数
|
||||
// 1.1 解析通用参数
|
||||
String productKey = context.pathParam("productKey");
|
||||
String deviceName = context.pathParam("deviceName");
|
||||
// 1.2 解析子设备列表
|
||||
SubDeviceRegisterRequest request = deserializeRequest(context, SubDeviceRegisterRequest.class);
|
||||
Assert.notNull(request, "请求参数不能为空");
|
||||
Assert.notEmpty(request.getParams(), "params 不能为空");
|
||||
|
||||
// 2. 解析子设备列表
|
||||
JsonObject body = context.body().asJsonObject();
|
||||
if (body == null) {
|
||||
throw invalidParamException("请求体不能为空");
|
||||
}
|
||||
if (body.getJsonArray("params") == null) {
|
||||
throw invalidParamException("params 不能为空");
|
||||
}
|
||||
List<cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO> subDevices = JsonUtils.parseArray(
|
||||
body.getJsonArray("params").toString(), cn.iocoder.yudao.module.iot.core.topic.auth.IotSubDeviceRegisterReqDTO.class);
|
||||
|
||||
// 3. 调用子设备动态注册
|
||||
// 2. 调用子设备动态注册
|
||||
IotSubDeviceRegisterFullReqDTO reqDTO = new IotSubDeviceRegisterFullReqDTO()
|
||||
.setGatewayProductKey(productKey).setGatewayDeviceName(deviceName).setSubDevices(subDevices);
|
||||
.setGatewayProductKey(productKey)
|
||||
.setGatewayDeviceName(deviceName)
|
||||
.setSubDevices(request.getParams());
|
||||
CommonResult<List<IotSubDeviceRegisterRespDTO>> result = deviceApi.registerSubDevices(reqDTO);
|
||||
result.checkError();
|
||||
|
||||
// 4. 返回结果
|
||||
// 3. 返回结果
|
||||
return success(result.getData());
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SubDeviceRegisterRequest {
|
||||
|
||||
private List<IotSubDeviceRegisterReqDTO> params;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.router;
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.http.handler.upstream;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
@@ -6,55 +6,47 @@ import cn.hutool.core.text.StrPool;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpUpstreamProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.http.IotHttpProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.ext.web.RoutingContext;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
|
||||
|
||||
/**
|
||||
* IoT 网关 HTTP 协议的【上行】处理器
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IotHttpUpstreamHandler extends IotHttpAbstractHandler {
|
||||
|
||||
public static final String PATH = "/topic/sys/:productKey/:deviceName/*";
|
||||
|
||||
private final IotHttpUpstreamProtocol protocol;
|
||||
private final String serverId;
|
||||
|
||||
private final IotDeviceMessageService deviceMessageService;
|
||||
|
||||
public IotHttpUpstreamHandler(IotHttpUpstreamProtocol protocol) {
|
||||
this.protocol = protocol;
|
||||
public IotHttpUpstreamHandler(IotHttpProtocol protocol) {
|
||||
this.serverId = protocol.getServerId();
|
||||
this.deviceMessageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected CommonResult<Object> handle0(RoutingContext context) {
|
||||
// 1. 解析通用参数
|
||||
// 1.1 解析通用参数
|
||||
String productKey = context.pathParam("productKey");
|
||||
String deviceName = context.pathParam("deviceName");
|
||||
String method = context.pathParam("*").replaceAll(StrPool.SLASH, StrPool.DOT);
|
||||
|
||||
// 2.1 解析消息
|
||||
if (context.body().buffer() == null) {
|
||||
throw invalidParamException("请求体不能为空");
|
||||
}
|
||||
byte[] bytes = context.body().buffer().getBytes();
|
||||
IotDeviceMessage message = deviceMessageService.decodeDeviceMessage(bytes,
|
||||
productKey, deviceName);
|
||||
// 1.2 根据 Content-Type 反序列化消息
|
||||
IotDeviceMessage message = deserializeRequest(context, IotDeviceMessage.class);
|
||||
Assert.notNull(message, "请求参数不能为空");
|
||||
Assert.equals(method, message.getMethod(), "method 不匹配");
|
||||
// 2.2 发送消息
|
||||
|
||||
// 2. 发送消息
|
||||
deviceMessageService.sendDeviceMessage(message,
|
||||
productKey, deviceName, protocol.getServerId());
|
||||
productKey, deviceName, serverId);
|
||||
|
||||
// 3. 返回结果
|
||||
return CommonResult.success(MapUtil.of("messageId", message.getId()));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.manager;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import io.vertx.core.Vertx;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
|
||||
|
||||
/**
|
||||
* Modbus 轮询调度器基类
|
||||
* <p>
|
||||
* 封装通用的定时器管理、per-device 请求队列限速逻辑。
|
||||
* 子类只需实现 {@link #pollPoint(Long, Long)} 定义具体的轮询动作。
|
||||
* <p>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public abstract class AbstractIotModbusPollScheduler {
|
||||
|
||||
protected final Vertx vertx;
|
||||
|
||||
/**
|
||||
* 同设备最小请求间隔(毫秒),防止 Modbus 设备性能不足时请求堆积
|
||||
*/
|
||||
private static final long MIN_REQUEST_INTERVAL = 1000;
|
||||
/**
|
||||
* 每个设备请求队列的最大长度,超出时丢弃最旧请求
|
||||
*/
|
||||
private static final int MAX_QUEUE_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* 设备点位的定时器映射:deviceId -> (pointId -> PointTimerInfo)
|
||||
*/
|
||||
private final Map<Long, Map<Long, PointTimerInfo>> devicePointTimers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* per-device 请求队列:deviceId -> 待执行请求队列
|
||||
*/
|
||||
private final Map<Long, Queue<Runnable>> deviceRequestQueues = new ConcurrentHashMap<>();
|
||||
/**
|
||||
* per-device 上次请求时间戳:deviceId -> lastRequestTimeMs
|
||||
*/
|
||||
private final Map<Long, Long> deviceLastRequestTime = new ConcurrentHashMap<>();
|
||||
/**
|
||||
* per-device 延迟 timer 标记:deviceId -> 是否有延迟 timer 在等待
|
||||
*/
|
||||
private final Map<Long, Boolean> deviceDelayTimerActive = new ConcurrentHashMap<>();
|
||||
|
||||
protected AbstractIotModbusPollScheduler(Vertx vertx) {
|
||||
this.vertx = vertx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 点位定时器信息
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
private static class PointTimerInfo {
|
||||
|
||||
/**
|
||||
* Vert.x 定时器 ID
|
||||
*/
|
||||
private Long timerId;
|
||||
/**
|
||||
* 轮询间隔(用于判断是否需要更新定时器)
|
||||
*/
|
||||
private Integer pollInterval;
|
||||
|
||||
}
|
||||
|
||||
// ========== 轮询管理 ==========
|
||||
|
||||
/**
|
||||
* 更新轮询任务(增量更新)
|
||||
*
|
||||
* 1. 【删除】点位:停止对应的轮询定时器
|
||||
* 2. 【新增】点位:创建对应的轮询定时器
|
||||
* 3. 【修改】点位:pollInterval 变化,重建对应的轮询定时器
|
||||
* 【修改】其他属性变化:不需要重建定时器(pollPoint 运行时从 configCache 取最新 point)
|
||||
*/
|
||||
public void updatePolling(IotModbusDeviceConfigRespDTO config) {
|
||||
Long deviceId = config.getDeviceId();
|
||||
List<IotModbusPointRespDTO> newPoints = config.getPoints();
|
||||
Map<Long, PointTimerInfo> currentTimers = devicePointTimers
|
||||
.computeIfAbsent(deviceId, k -> new ConcurrentHashMap<>());
|
||||
// 1.1 计算新配置中的点位 ID 集合
|
||||
Set<Long> newPointIds = convertSet(newPoints, IotModbusPointRespDTO::getId);
|
||||
// 1.2 计算删除的点位 ID 集合
|
||||
Set<Long> removedPointIds = new HashSet<>(currentTimers.keySet());
|
||||
removedPointIds.removeAll(newPointIds);
|
||||
|
||||
// 2. 处理删除的点位:停止不再存在的定时器
|
||||
for (Long pointId : removedPointIds) {
|
||||
PointTimerInfo timerInfo = currentTimers.remove(pointId);
|
||||
if (timerInfo != null) {
|
||||
vertx.cancelTimer(timerInfo.getTimerId());
|
||||
log.debug("[updatePolling][设备 {} 点位 {} 定时器已删除]", deviceId, pointId);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 处理新增和修改的点位
|
||||
if (CollUtil.isEmpty(newPoints)) {
|
||||
return;
|
||||
}
|
||||
for (IotModbusPointRespDTO point : newPoints) {
|
||||
Long pointId = point.getId();
|
||||
Integer newPollInterval = point.getPollInterval();
|
||||
PointTimerInfo existingTimer = currentTimers.get(pointId);
|
||||
// 3.1 新增点位:创建定时器
|
||||
if (existingTimer == null) {
|
||||
Long timerId = createPollTimer(deviceId, pointId, newPollInterval);
|
||||
if (timerId != null) {
|
||||
currentTimers.put(pointId, new PointTimerInfo(timerId, newPollInterval));
|
||||
log.debug("[updatePolling][设备 {} 点位 {} 定时器已创建, interval={}ms]",
|
||||
deviceId, pointId, newPollInterval);
|
||||
}
|
||||
} else if (!Objects.equals(existingTimer.getPollInterval(), newPollInterval)) {
|
||||
// 3.2 pollInterval 变化:重建定时器
|
||||
vertx.cancelTimer(existingTimer.getTimerId());
|
||||
Long timerId = createPollTimer(deviceId, pointId, newPollInterval);
|
||||
if (timerId != null) {
|
||||
currentTimers.put(pointId, new PointTimerInfo(timerId, newPollInterval));
|
||||
log.debug("[updatePolling][设备 {} 点位 {} 定时器已更新, interval={}ms -> {}ms]",
|
||||
deviceId, pointId, existingTimer.getPollInterval(), newPollInterval);
|
||||
} else {
|
||||
currentTimers.remove(pointId);
|
||||
}
|
||||
}
|
||||
// 3.3 其他属性变化:无需重建定时器,因为 pollPoint() 运行时从 configCache 获取最新 point,自动使用新配置
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建轮询定时器
|
||||
*/
|
||||
private Long createPollTimer(Long deviceId, Long pointId, Integer pollInterval) {
|
||||
if (pollInterval == null || pollInterval <= 0) {
|
||||
return null;
|
||||
}
|
||||
return vertx.setPeriodic(pollInterval, timerId -> {
|
||||
try {
|
||||
submitPollRequest(deviceId, pointId);
|
||||
} catch (Exception e) {
|
||||
log.error("[createPollTimer][轮询点位失败, deviceId={}, pointId={}]", deviceId, pointId, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 请求队列(per-device 限速) ==========
|
||||
|
||||
/**
|
||||
* 提交轮询请求到设备请求队列(保证同设备请求间隔)
|
||||
*/
|
||||
private void submitPollRequest(Long deviceId, Long pointId) {
|
||||
// 1. 【重要】将请求添加到设备的请求队列
|
||||
Queue<Runnable> queue = deviceRequestQueues.computeIfAbsent(deviceId, k -> new ConcurrentLinkedQueue<>());
|
||||
while (queue.size() >= MAX_QUEUE_SIZE) {
|
||||
// 超出上限时,丢弃最旧的请求
|
||||
queue.poll();
|
||||
log.warn("[submitPollRequest][设备 {} 请求队列已满({}), 丢弃最旧请求]", deviceId, MAX_QUEUE_SIZE);
|
||||
}
|
||||
queue.offer(() -> pollPoint(deviceId, pointId));
|
||||
|
||||
// 2. 处理设备请求队列(如果没有延迟 timer 在等待)
|
||||
processDeviceQueue(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理设备请求队列
|
||||
*/
|
||||
private void processDeviceQueue(Long deviceId) {
|
||||
Queue<Runnable> queue = deviceRequestQueues.get(deviceId);
|
||||
if (CollUtil.isEmpty(queue)) {
|
||||
return;
|
||||
}
|
||||
// 检查是否已有延迟 timer 在等待
|
||||
if (Boolean.TRUE.equals(deviceDelayTimerActive.get(deviceId))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 不满足间隔要求,延迟执行
|
||||
long now = System.currentTimeMillis();
|
||||
long lastTime = deviceLastRequestTime.getOrDefault(deviceId, 0L);
|
||||
long elapsed = now - lastTime;
|
||||
if (elapsed < MIN_REQUEST_INTERVAL) {
|
||||
scheduleNextRequest(deviceId, MIN_REQUEST_INTERVAL - elapsed);
|
||||
return;
|
||||
}
|
||||
|
||||
// 满足间隔要求,立即执行
|
||||
Runnable task = queue.poll();
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
deviceLastRequestTime.put(deviceId, now);
|
||||
task.run();
|
||||
// 继续处理队列中的下一个(如果有的话,需要延迟)
|
||||
if (CollUtil.isNotEmpty(queue)) {
|
||||
scheduleNextRequest(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleNextRequest(Long deviceId) {
|
||||
scheduleNextRequest(deviceId, MIN_REQUEST_INTERVAL);
|
||||
}
|
||||
|
||||
private void scheduleNextRequest(Long deviceId, long delayMs) {
|
||||
deviceDelayTimerActive.put(deviceId, true);
|
||||
vertx.setTimer(delayMs, id -> {
|
||||
deviceDelayTimerActive.put(deviceId, false);
|
||||
Queue<Runnable> queue = deviceRequestQueues.get(deviceId);
|
||||
if (CollUtil.isEmpty(queue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 满足间隔要求,立即执行
|
||||
Runnable task = queue.poll();
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
deviceLastRequestTime.put(deviceId, System.currentTimeMillis());
|
||||
task.run();
|
||||
// 继续处理队列中的下一个(如果有的话,需要延迟)
|
||||
if (CollUtil.isNotEmpty(queue)) {
|
||||
scheduleNextRequest(deviceId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 轮询执行 ==========
|
||||
|
||||
/**
|
||||
* 轮询单个点位(子类实现具体的读取逻辑)
|
||||
*
|
||||
* @param deviceId 设备 ID
|
||||
* @param pointId 点位 ID
|
||||
*/
|
||||
protected abstract void pollPoint(Long deviceId, Long pointId);
|
||||
|
||||
// ========== 停止 ==========
|
||||
|
||||
/**
|
||||
* 停止设备的轮询
|
||||
*/
|
||||
public void stopPolling(Long deviceId) {
|
||||
Map<Long, PointTimerInfo> timers = devicePointTimers.remove(deviceId);
|
||||
if (CollUtil.isEmpty(timers)) {
|
||||
return;
|
||||
}
|
||||
for (PointTimerInfo timerInfo : timers.values()) {
|
||||
vertx.cancelTimer(timerInfo.getTimerId());
|
||||
}
|
||||
// 清理请求队列
|
||||
deviceRequestQueues.remove(deviceId);
|
||||
deviceLastRequestTime.remove(deviceId);
|
||||
deviceDelayTimerActive.remove(deviceId);
|
||||
log.debug("[stopPolling][设备 {} 停止了 {} 个轮询定时器]", deviceId, timers.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止所有轮询
|
||||
*/
|
||||
public void stopAll() {
|
||||
for (Long deviceId : new ArrayList<>(devicePointTimers.keySet())) {
|
||||
stopPolling(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusByteOrderEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusRawDataTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpserver.codec.IotModbusFrame;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
|
||||
/**
|
||||
* IoT Modbus 协议工具类
|
||||
* <p>
|
||||
* 提供 Modbus 协议全链路能力:
|
||||
* <ul>
|
||||
* <li>协议常量:功能码(FC01~FC16)、异常掩码等</li>
|
||||
* <li>功能码判断:读/写/异常分类、可写判断、写功能码映射</li>
|
||||
* <li>CRC-16/MODBUS 计算和校验</li>
|
||||
* <li>数据转换:原始值 ↔ 物模型属性值({@link #convertToPropertyValue} / {@link #convertToRawValues})</li>
|
||||
* <li>帧值提取:从 Modbus 帧提取寄存器/线圈值({@link #extractValues})</li>
|
||||
* <li>点位查找({@link #findPoint})</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@UtilityClass
|
||||
@Slf4j
|
||||
public class IotModbusCommonUtils {
|
||||
|
||||
/** FC01: 读线圈 */
|
||||
public static final int FC_READ_COILS = 1;
|
||||
/** FC02: 读离散输入 */
|
||||
public static final int FC_READ_DISCRETE_INPUTS = 2;
|
||||
/** FC03: 读保持寄存器 */
|
||||
public static final int FC_READ_HOLDING_REGISTERS = 3;
|
||||
/** FC04: 读输入寄存器 */
|
||||
public static final int FC_READ_INPUT_REGISTERS = 4;
|
||||
|
||||
/** FC05: 写单个线圈 */
|
||||
public static final int FC_WRITE_SINGLE_COIL = 5;
|
||||
/** FC06: 写单个寄存器 */
|
||||
public static final int FC_WRITE_SINGLE_REGISTER = 6;
|
||||
/** FC15: 写多个线圈 */
|
||||
public static final int FC_WRITE_MULTIPLE_COILS = 15;
|
||||
/** FC16: 写多个寄存器 */
|
||||
public static final int FC_WRITE_MULTIPLE_REGISTERS = 16;
|
||||
|
||||
/**
|
||||
* 异常响应掩码:响应帧的功能码最高位为 1 时,表示异常响应
|
||||
* 例如:请求 FC=0x03,异常响应 FC=0x83(0x03 | 0x80)
|
||||
*/
|
||||
public static final int FC_EXCEPTION_MASK = 0x80;
|
||||
|
||||
/**
|
||||
* 功能码掩码:用于从异常响应中提取原始功能码
|
||||
* 例如:异常 FC=0x83,原始 FC = 0x83 & 0x7F = 0x03
|
||||
*/
|
||||
public static final int FC_MASK = 0x7F;
|
||||
|
||||
// ==================== 功能码分类判断 ====================
|
||||
|
||||
/**
|
||||
* 判断是否为读响应(FC01-04)
|
||||
*/
|
||||
public static boolean isReadResponse(int functionCode) {
|
||||
return functionCode >= FC_READ_COILS && functionCode <= FC_READ_INPUT_REGISTERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为写响应(FC05/06/15/16)
|
||||
*/
|
||||
public static boolean isWriteResponse(int functionCode) {
|
||||
return functionCode == FC_WRITE_SINGLE_COIL || functionCode == FC_WRITE_SINGLE_REGISTER
|
||||
|| functionCode == FC_WRITE_MULTIPLE_COILS || functionCode == FC_WRITE_MULTIPLE_REGISTERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为异常响应
|
||||
*/
|
||||
public static boolean isExceptionResponse(int functionCode) {
|
||||
return (functionCode & FC_EXCEPTION_MASK) != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从异常响应中提取原始功能码
|
||||
*/
|
||||
public static int extractOriginalFunctionCode(int exceptionFunctionCode) {
|
||||
return exceptionFunctionCode & FC_MASK;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断读功能码是否支持写操作
|
||||
* <p>
|
||||
* FC01(读线圈)和 FC03(读保持寄存器)支持写操作;
|
||||
* FC02(读离散输入)和 FC04(读输入寄存器)为只读。
|
||||
*
|
||||
* @param readFunctionCode 读功能码(FC01-04)
|
||||
* @return 是否支持写操作
|
||||
*/
|
||||
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
|
||||
public static boolean isWritable(int readFunctionCode) {
|
||||
return readFunctionCode == FC_READ_COILS || readFunctionCode == FC_READ_HOLDING_REGISTERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单写功能码
|
||||
* <p>
|
||||
* FC01(读线圈)→ FC05(写单个线圈);
|
||||
* FC03(读保持寄存器)→ FC06(写单个寄存器);
|
||||
* 其他返回 null(不支持写)。
|
||||
*
|
||||
* @param readFunctionCode 读功能码
|
||||
* @return 单写功能码,不支持写时返回 null
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
public static Integer getWriteSingleFunctionCode(int readFunctionCode) {
|
||||
switch (readFunctionCode) {
|
||||
case FC_READ_COILS:
|
||||
return FC_WRITE_SINGLE_COIL;
|
||||
case FC_READ_HOLDING_REGISTERS:
|
||||
return FC_WRITE_SINGLE_REGISTER;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多写功能码
|
||||
* <p>
|
||||
* FC01(读线圈)→ FC15(写多个线圈);
|
||||
* FC03(读保持寄存器)→ FC16(写多个寄存器);
|
||||
* 其他返回 null(不支持写)。
|
||||
*
|
||||
* @param readFunctionCode 读功能码
|
||||
* @return 多写功能码,不支持写时返回 null
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
public static Integer getWriteMultipleFunctionCode(int readFunctionCode) {
|
||||
switch (readFunctionCode) {
|
||||
case FC_READ_COILS:
|
||||
return FC_WRITE_MULTIPLE_COILS;
|
||||
case FC_READ_HOLDING_REGISTERS:
|
||||
return FC_WRITE_MULTIPLE_REGISTERS;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== CRC16 工具 ====================
|
||||
|
||||
/**
|
||||
* 计算 CRC-16/MODBUS
|
||||
*
|
||||
* @param data 数据
|
||||
* @param length 计算长度
|
||||
* @return CRC16 值
|
||||
*/
|
||||
public static int calculateCrc16(byte[] data, int length) {
|
||||
int crc = 0xFFFF;
|
||||
for (int i = 0; i < length; i++) {
|
||||
crc ^= (data[i] & 0xFF);
|
||||
for (int j = 0; j < 8; j++) {
|
||||
if ((crc & 0x0001) != 0) {
|
||||
crc >>= 1;
|
||||
crc ^= 0xA001;
|
||||
} else {
|
||||
crc >>= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 CRC16
|
||||
*
|
||||
* @param data 包含 CRC 的完整数据
|
||||
* @return 校验是否通过
|
||||
*/
|
||||
public static boolean verifyCrc16(byte[] data) {
|
||||
if (data.length < 3) {
|
||||
return false;
|
||||
}
|
||||
int computed = calculateCrc16(data, data.length - 2);
|
||||
int received = (data[data.length - 2] & 0xFF) | ((data[data.length - 1] & 0xFF) << 8);
|
||||
return computed == received;
|
||||
}
|
||||
|
||||
// ==================== 数据转换 ====================
|
||||
|
||||
/**
|
||||
* 将原始值转换为物模型属性值
|
||||
*
|
||||
* @param rawValues 原始值数组(寄存器值或线圈值)
|
||||
* @param point 点位配置
|
||||
* @return 转换后的属性值
|
||||
*/
|
||||
public static Object convertToPropertyValue(int[] rawValues, IotModbusPointRespDTO point) {
|
||||
if (ArrayUtil.isEmpty(rawValues)) {
|
||||
return null;
|
||||
}
|
||||
String rawDataType = point.getRawDataType();
|
||||
String byteOrder = point.getByteOrder();
|
||||
BigDecimal scale = ObjectUtil.defaultIfNull(point.getScale(), BigDecimal.ONE);
|
||||
|
||||
// 1. 根据原始数据类型解析原始数值
|
||||
Number rawNumber = parseRawValue(rawValues, rawDataType, byteOrder);
|
||||
if (rawNumber == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 应用缩放因子:实际值 = 原始值 × scale
|
||||
BigDecimal actualValue = new BigDecimal(rawNumber.toString()).multiply(scale);
|
||||
|
||||
// 3. 根据数据类型返回合适的 Java 类型
|
||||
return formatValue(actualValue, rawDataType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将物模型属性值转换为原始寄存器值
|
||||
*
|
||||
* @param propertyValue 属性值
|
||||
* @param point 点位配置
|
||||
* @return 原始值数组
|
||||
*/
|
||||
public static int[] convertToRawValues(Object propertyValue, IotModbusPointRespDTO point) {
|
||||
if (propertyValue == null) {
|
||||
return new int[0];
|
||||
}
|
||||
String rawDataType = point.getRawDataType();
|
||||
String byteOrder = point.getByteOrder();
|
||||
BigDecimal scale = ObjectUtil.defaultIfNull(point.getScale(), BigDecimal.ONE);
|
||||
int registerCount = ObjectUtil.defaultIfNull(point.getRegisterCount(), 1);
|
||||
|
||||
// 1. 转换为 BigDecimal
|
||||
BigDecimal actualValue = new BigDecimal(propertyValue.toString());
|
||||
|
||||
// 2. 应用缩放因子:原始值 = 实际值 ÷ scale
|
||||
BigDecimal rawValue = actualValue.divide(scale, 0, RoundingMode.HALF_UP);
|
||||
|
||||
// 3. 根据原始数据类型编码为寄存器值
|
||||
return encodeToRegisters(rawValue, rawDataType, byteOrder, registerCount);
|
||||
}
|
||||
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
private static Number parseRawValue(int[] rawValues, String rawDataType, String byteOrder) {
|
||||
IotModbusRawDataTypeEnum dataTypeEnum = IotModbusRawDataTypeEnum.getByType(rawDataType);
|
||||
if (dataTypeEnum == null) {
|
||||
log.warn("[parseRawValue][不支持的数据类型: {}]", rawDataType);
|
||||
return rawValues[0];
|
||||
}
|
||||
switch (dataTypeEnum) {
|
||||
case BOOLEAN:
|
||||
return rawValues[0] != 0 ? 1 : 0;
|
||||
case INT16:
|
||||
return (short) rawValues[0];
|
||||
case UINT16:
|
||||
return rawValues[0] & 0xFFFF;
|
||||
case INT32:
|
||||
return parseInt32(rawValues, byteOrder);
|
||||
case UINT32:
|
||||
return parseUint32(rawValues, byteOrder);
|
||||
case FLOAT:
|
||||
return parseFloat(rawValues, byteOrder);
|
||||
case DOUBLE:
|
||||
return parseDouble(rawValues, byteOrder);
|
||||
default:
|
||||
log.warn("[parseRawValue][不支持的数据类型: {}]", rawDataType);
|
||||
return rawValues[0];
|
||||
}
|
||||
}
|
||||
|
||||
private static int parseInt32(int[] rawValues, String byteOrder) {
|
||||
if (rawValues.length < 2) {
|
||||
return rawValues[0];
|
||||
}
|
||||
byte[] bytes = reorderBytes(registersToBytes(rawValues, 2), byteOrder);
|
||||
return ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getInt();
|
||||
}
|
||||
|
||||
private static long parseUint32(int[] rawValues, String byteOrder) {
|
||||
if (rawValues.length < 2) {
|
||||
return rawValues[0] & 0xFFFFFFFFL;
|
||||
}
|
||||
byte[] bytes = reorderBytes(registersToBytes(rawValues, 2), byteOrder);
|
||||
return ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getInt() & 0xFFFFFFFFL;
|
||||
}
|
||||
|
||||
private static float parseFloat(int[] rawValues, String byteOrder) {
|
||||
if (rawValues.length < 2) {
|
||||
return (float) rawValues[0];
|
||||
}
|
||||
byte[] bytes = reorderBytes(registersToBytes(rawValues, 2), byteOrder);
|
||||
return ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getFloat();
|
||||
}
|
||||
|
||||
private static double parseDouble(int[] rawValues, String byteOrder) {
|
||||
if (rawValues.length < 4) {
|
||||
return rawValues[0];
|
||||
}
|
||||
byte[] bytes = reorderBytes(registersToBytes(rawValues, 4), byteOrder);
|
||||
return ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).getDouble();
|
||||
}
|
||||
|
||||
private static byte[] registersToBytes(int[] registers, int count) {
|
||||
byte[] bytes = new byte[count * 2];
|
||||
for (int i = 0; i < Math.min(registers.length, count); i++) {
|
||||
bytes[i * 2] = (byte) ((registers[i] >> 8) & 0xFF);
|
||||
bytes[i * 2 + 1] = (byte) (registers[i] & 0xFF);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
private static byte[] reorderBytes(byte[] bytes, String byteOrder) {
|
||||
IotModbusByteOrderEnum byteOrderEnum = IotModbusByteOrderEnum.getByOrder(byteOrder);
|
||||
// null 或者大端序,不需要调整
|
||||
if (ObjectUtils.equalsAny(byteOrderEnum, null, IotModbusByteOrderEnum.ABCD, IotModbusByteOrderEnum.AB)) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// 其他字节序调整
|
||||
byte[] result = new byte[bytes.length];
|
||||
switch (byteOrderEnum) {
|
||||
case BA: // 小端序:按每 2 字节一组交换(16 位场景 [1,0],32 位场景 [1,0,3,2])
|
||||
for (int i = 0; i + 1 < bytes.length; i += 2) {
|
||||
result[i] = bytes[i + 1];
|
||||
result[i + 1] = bytes[i];
|
||||
}
|
||||
break;
|
||||
case CDAB: // 大端字交换(32 位)
|
||||
if (bytes.length >= 4) {
|
||||
result[0] = bytes[2];
|
||||
result[1] = bytes[3];
|
||||
result[2] = bytes[0];
|
||||
result[3] = bytes[1];
|
||||
}
|
||||
break;
|
||||
case DCBA: // 小端序(32 位)
|
||||
if (bytes.length >= 4) {
|
||||
result[0] = bytes[3];
|
||||
result[1] = bytes[2];
|
||||
result[2] = bytes[1];
|
||||
result[3] = bytes[0];
|
||||
}
|
||||
break;
|
||||
case BADC: // 小端字交换(32 位)
|
||||
if (bytes.length >= 4) {
|
||||
result[0] = bytes[1];
|
||||
result[1] = bytes[0];
|
||||
result[2] = bytes[3];
|
||||
result[3] = bytes[2];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return bytes;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
private static int[] encodeToRegisters(BigDecimal rawValue, String rawDataType, String byteOrder, int registerCount) {
|
||||
IotModbusRawDataTypeEnum dataTypeEnum = IotModbusRawDataTypeEnum.getByType(rawDataType);
|
||||
if (dataTypeEnum == null) {
|
||||
return new int[]{rawValue.intValue()};
|
||||
}
|
||||
switch (dataTypeEnum) {
|
||||
case BOOLEAN:
|
||||
return new int[]{rawValue.intValue() != 0 ? 1 : 0};
|
||||
case INT16:
|
||||
case UINT16:
|
||||
return new int[]{rawValue.intValue() & 0xFFFF};
|
||||
case INT32:
|
||||
return encodeInt32(rawValue.intValue(), byteOrder);
|
||||
case UINT32:
|
||||
// 使用 longValue() 避免超过 Integer.MAX_VALUE 时溢出,
|
||||
// 强转 int 保留低 32 位 bit pattern,写入寄存器的字节是正确的无符号值
|
||||
return encodeInt32((int) rawValue.longValue(), byteOrder);
|
||||
case FLOAT:
|
||||
return encodeFloat(rawValue.floatValue(), byteOrder);
|
||||
case DOUBLE:
|
||||
return encodeDouble(rawValue.doubleValue(), byteOrder);
|
||||
default:
|
||||
return new int[]{rawValue.intValue()};
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] encodeInt32(int value, String byteOrder) {
|
||||
byte[] bytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(value).array();
|
||||
bytes = reorderBytes(bytes, byteOrder);
|
||||
return bytesToRegisters(bytes);
|
||||
}
|
||||
|
||||
private static int[] encodeFloat(float value, String byteOrder) {
|
||||
byte[] bytes = ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putFloat(value).array();
|
||||
bytes = reorderBytes(bytes, byteOrder);
|
||||
return bytesToRegisters(bytes);
|
||||
}
|
||||
|
||||
private static int[] encodeDouble(double value, String byteOrder) {
|
||||
byte[] bytes = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).putDouble(value).array();
|
||||
bytes = reorderBytes(bytes, byteOrder);
|
||||
return bytesToRegisters(bytes);
|
||||
}
|
||||
|
||||
private static int[] bytesToRegisters(byte[] bytes) {
|
||||
int[] registers = new int[bytes.length / 2];
|
||||
for (int i = 0; i < registers.length; i++) {
|
||||
registers[i] = ((bytes[i * 2] & 0xFF) << 8) | (bytes[i * 2 + 1] & 0xFF);
|
||||
}
|
||||
return registers;
|
||||
}
|
||||
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
private static Object formatValue(BigDecimal value, String rawDataType) {
|
||||
IotModbusRawDataTypeEnum dataTypeEnum = IotModbusRawDataTypeEnum.getByType(rawDataType);
|
||||
if (dataTypeEnum == null) {
|
||||
return value;
|
||||
}
|
||||
switch (dataTypeEnum) {
|
||||
case BOOLEAN:
|
||||
return value.intValue() != 0;
|
||||
case INT16:
|
||||
case INT32:
|
||||
return value.intValue();
|
||||
case UINT16:
|
||||
case UINT32:
|
||||
return value.longValue();
|
||||
case FLOAT:
|
||||
return value.floatValue();
|
||||
case DOUBLE:
|
||||
return value.doubleValue();
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 帧值提取 ====================
|
||||
|
||||
/**
|
||||
* 从帧中提取寄存器值(FC01-04 读响应)
|
||||
*
|
||||
* @param frame 解码后的 Modbus 帧
|
||||
* @return 寄存器值数组(int[]),失败返回 null
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
public static int[] extractValues(IotModbusFrame frame) {
|
||||
if (frame == null || frame.isException()) {
|
||||
return null;
|
||||
}
|
||||
byte[] pdu = frame.getPdu();
|
||||
if (pdu == null || pdu.length < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
int functionCode = frame.getFunctionCode();
|
||||
switch (functionCode) {
|
||||
case FC_READ_COILS:
|
||||
case FC_READ_DISCRETE_INPUTS:
|
||||
return extractCoilValues(pdu);
|
||||
case FC_READ_HOLDING_REGISTERS:
|
||||
case FC_READ_INPUT_REGISTERS:
|
||||
return extractRegisterValues(pdu);
|
||||
default:
|
||||
log.warn("[extractValues][不支持的功能码: {}]", functionCode);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int[] extractCoilValues(byte[] pdu) {
|
||||
if (pdu.length < 2) {
|
||||
return null;
|
||||
}
|
||||
int byteCount = pdu[0] & 0xFF;
|
||||
int bitCount = byteCount * 8;
|
||||
int[] values = new int[bitCount];
|
||||
for (int i = 0; i < bitCount && (1 + i / 8) < pdu.length; i++) {
|
||||
values[i] = ((pdu[1 + i / 8] >> (i % 8)) & 0x01);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private static int[] extractRegisterValues(byte[] pdu) {
|
||||
if (pdu.length < 2) {
|
||||
return null;
|
||||
}
|
||||
int byteCount = pdu[0] & 0xFF;
|
||||
int registerCount = byteCount / 2;
|
||||
int[] values = new int[registerCount];
|
||||
for (int i = 0; i < registerCount && (1 + i * 2 + 1) < pdu.length; i++) {
|
||||
values[i] = ((pdu[1 + i * 2] & 0xFF) << 8) | (pdu[1 + i * 2 + 1] & 0xFF);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从响应帧中提取 registerCount(通过 PDU 的 byteCount 推断)
|
||||
*
|
||||
* @param frame 解码后的 Modbus 响应帧
|
||||
* @return registerCount,无法提取时返回 -1(匹配时跳过校验)
|
||||
*/
|
||||
public static int extractRegisterCountFromResponse(IotModbusFrame frame) {
|
||||
byte[] pdu = frame.getPdu();
|
||||
if (pdu == null || pdu.length < 1) {
|
||||
return -1;
|
||||
}
|
||||
int byteCount = pdu[0] & 0xFF;
|
||||
int fc = frame.getFunctionCode();
|
||||
// FC03/04 寄存器读响应:registerCount = byteCount / 2
|
||||
if (fc == FC_READ_HOLDING_REGISTERS || fc == FC_READ_INPUT_REGISTERS) {
|
||||
return byteCount / 2;
|
||||
}
|
||||
// FC01/02 线圈/离散输入读响应:按 bit 打包有余位,无法精确反推,返回 -1 跳过校验
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ==================== 点位查找 ====================
|
||||
|
||||
/**
|
||||
* 查找点位配置
|
||||
*
|
||||
* @param config 设备 Modbus 配置
|
||||
* @param identifier 点位标识符
|
||||
* @return 匹配的点位配置,未找到返回 null
|
||||
*/
|
||||
public static IotModbusPointRespDTO findPoint(IotModbusDeviceConfigRespDTO config, String identifier) {
|
||||
if (config == null || StrUtil.isBlank(identifier)) {
|
||||
return null;
|
||||
}
|
||||
return CollUtil.findOne(config.getPoints(), p -> identifier.equals(p.getIdentifier()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据点位 ID 查找点位配置
|
||||
*
|
||||
* @param config 设备 Modbus 配置
|
||||
* @param pointId 点位 ID
|
||||
* @return 匹配的点位配置,未找到返回 null
|
||||
*/
|
||||
public static IotModbusPointRespDTO findPointById(IotModbusDeviceConfigRespDTO config, Long pointId) {
|
||||
if (config == null || pointId == null) {
|
||||
return null;
|
||||
}
|
||||
return CollUtil.findOne(config.getPoints(), p -> p.getId().equals(pointId));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager;
|
||||
import com.ghgande.j2mod.modbus.io.ModbusTCPTransaction;
|
||||
import com.ghgande.j2mod.modbus.msg.*;
|
||||
import com.ghgande.j2mod.modbus.procimg.InputRegister;
|
||||
import com.ghgande.j2mod.modbus.procimg.Register;
|
||||
import com.ghgande.j2mod.modbus.procimg.SimpleRegister;
|
||||
import com.ghgande.j2mod.modbus.util.BitVector;
|
||||
import io.vertx.core.Future;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.*;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP 客户端工具类
|
||||
* <p>
|
||||
* 封装基于 j2mod 的 Modbus TCP 读写操作:
|
||||
* 1. 根据功能码创建对应的 Modbus 读/写请求
|
||||
* 2. 通过 {@link IotModbusTcpClientConnectionManager.ModbusConnection} 执行事务
|
||||
* 3. 从响应中提取原始值
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@UtilityClass
|
||||
@Slf4j
|
||||
public class IotModbusTcpClientUtils {
|
||||
|
||||
/**
|
||||
* 读取 Modbus 数据
|
||||
*
|
||||
* @param connection Modbus 连接
|
||||
* @param slaveId 从站地址
|
||||
* @param point 点位配置
|
||||
* @return 原始值(int 数组)
|
||||
*/
|
||||
public static Future<int[]> read(IotModbusTcpClientConnectionManager.ModbusConnection connection,
|
||||
Integer slaveId,
|
||||
IotModbusPointRespDTO point) {
|
||||
return connection.executeBlocking(tcpConnection -> {
|
||||
try {
|
||||
// 1. 创建请求
|
||||
ModbusRequest request = createReadRequest(point.getFunctionCode(),
|
||||
point.getRegisterAddress(), point.getRegisterCount());
|
||||
request.setUnitID(slaveId);
|
||||
|
||||
// 2. 执行事务(请求)
|
||||
ModbusTCPTransaction transaction = new ModbusTCPTransaction(tcpConnection);
|
||||
transaction.setRequest(request);
|
||||
transaction.execute();
|
||||
|
||||
// 3. 解析响应
|
||||
ModbusResponse response = transaction.getResponse();
|
||||
return extractValues(response, point.getFunctionCode());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(String.format("Modbus 读取失败 [slaveId=%d, identifier=%s, address=%d]",
|
||||
slaveId, point.getIdentifier(), point.getRegisterAddress()), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 Modbus 数据
|
||||
*
|
||||
* @param connection Modbus 连接
|
||||
* @param slaveId 从站地址
|
||||
* @param point 点位配置
|
||||
* @param values 要写入的值
|
||||
* @return 是否成功
|
||||
*/
|
||||
public static Future<Boolean> write(IotModbusTcpClientConnectionManager.ModbusConnection connection,
|
||||
Integer slaveId,
|
||||
IotModbusPointRespDTO point,
|
||||
int[] values) {
|
||||
return connection.executeBlocking(tcpConnection -> {
|
||||
try {
|
||||
// 1. 创建请求
|
||||
ModbusRequest request = createWriteRequest(point.getFunctionCode(),
|
||||
point.getRegisterAddress(), point.getRegisterCount(), values);
|
||||
if (request == null) {
|
||||
throw new RuntimeException("功能码 " + point.getFunctionCode() + " 不支持写操作");
|
||||
}
|
||||
request.setUnitID(slaveId);
|
||||
|
||||
// 2. 执行事务(请求)
|
||||
ModbusTCPTransaction transaction = new ModbusTCPTransaction(tcpConnection);
|
||||
transaction.setRequest(request);
|
||||
transaction.execute();
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(String.format("Modbus 写入失败 [slaveId=%d, identifier=%s, address=%d]",
|
||||
slaveId, point.getIdentifier(), point.getRegisterAddress()), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建读取请求
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
private static ModbusRequest createReadRequest(Integer functionCode, Integer address, Integer count) {
|
||||
switch (functionCode) {
|
||||
case FC_READ_COILS:
|
||||
return new ReadCoilsRequest(address, count);
|
||||
case FC_READ_DISCRETE_INPUTS:
|
||||
return new ReadInputDiscretesRequest(address, count);
|
||||
case FC_READ_HOLDING_REGISTERS:
|
||||
return new ReadMultipleRegistersRequest(address, count);
|
||||
case FC_READ_INPUT_REGISTERS:
|
||||
return new ReadInputRegistersRequest(address, count);
|
||||
default:
|
||||
throw new IllegalArgumentException("不支持的功能码: " + functionCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建写入请求
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
private static ModbusRequest createWriteRequest(Integer functionCode, Integer address, Integer count, int[] values) {
|
||||
switch (functionCode) {
|
||||
case FC_READ_COILS: // 写线圈(使用功能码 5 或 15)
|
||||
if (count == 1) {
|
||||
return new WriteCoilRequest(address, values[0] != 0);
|
||||
} else {
|
||||
BitVector bv = new BitVector(count);
|
||||
for (int i = 0; i < Math.min(values.length, count); i++) {
|
||||
bv.setBit(i, values[i] != 0);
|
||||
}
|
||||
return new WriteMultipleCoilsRequest(address, bv);
|
||||
}
|
||||
case FC_READ_HOLDING_REGISTERS: // 写保持寄存器(使用功能码 6 或 16)
|
||||
if (count == 1) {
|
||||
return new WriteSingleRegisterRequest(address, new SimpleRegister(values[0]));
|
||||
} else {
|
||||
Register[] registers = new SimpleRegister[count];
|
||||
for (int i = 0; i < count; i++) {
|
||||
registers[i] = new SimpleRegister(i < values.length ? values[i] : 0);
|
||||
}
|
||||
return new WriteMultipleRegistersRequest(address, registers);
|
||||
}
|
||||
case FC_READ_DISCRETE_INPUTS: // 只读
|
||||
case FC_READ_INPUT_REGISTERS: // 只读
|
||||
return null;
|
||||
default:
|
||||
throw new IllegalArgumentException("不支持的功能码: " + functionCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从响应中提取值
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
private static int[] extractValues(ModbusResponse response, Integer functionCode) {
|
||||
switch (functionCode) {
|
||||
case FC_READ_COILS:
|
||||
ReadCoilsResponse coilsResponse = (ReadCoilsResponse) response;
|
||||
int bitCount = coilsResponse.getBitCount();
|
||||
int[] coilValues = new int[bitCount];
|
||||
for (int i = 0; i < bitCount; i++) {
|
||||
coilValues[i] = coilsResponse.getCoilStatus(i) ? 1 : 0;
|
||||
}
|
||||
return coilValues;
|
||||
case FC_READ_DISCRETE_INPUTS:
|
||||
ReadInputDiscretesResponse discretesResponse = (ReadInputDiscretesResponse) response;
|
||||
int discreteCount = discretesResponse.getBitCount();
|
||||
int[] discreteValues = new int[discreteCount];
|
||||
for (int i = 0; i < discreteCount; i++) {
|
||||
discreteValues[i] = discretesResponse.getDiscreteStatus(i) ? 1 : 0;
|
||||
}
|
||||
return discreteValues;
|
||||
case FC_READ_HOLDING_REGISTERS:
|
||||
ReadMultipleRegistersResponse holdingResponse = (ReadMultipleRegistersResponse) response;
|
||||
InputRegister[] holdingRegisters = holdingResponse.getRegisters();
|
||||
int[] holdingValues = new int[holdingRegisters.length];
|
||||
for (int i = 0; i < holdingRegisters.length; i++) {
|
||||
holdingValues[i] = holdingRegisters[i].getValue();
|
||||
}
|
||||
return holdingValues;
|
||||
case FC_READ_INPUT_REGISTERS:
|
||||
ReadInputRegistersResponse inputResponse = (ReadInputRegistersResponse) response;
|
||||
InputRegister[] inputRegisters = inputResponse.getRegisters();
|
||||
int[] inputValues = new int[inputRegisters.length];
|
||||
for (int i = 0; i < inputRegisters.length; i++) {
|
||||
inputValues[i] = inputRegisters[i].getValue();
|
||||
}
|
||||
return inputValues;
|
||||
default:
|
||||
throw new IllegalArgumentException("不支持的功能码: " + functionCode);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP Client 协议配置
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
public class IotModbusTcpClientConfig {
|
||||
|
||||
/**
|
||||
* 配置刷新间隔(秒)
|
||||
*/
|
||||
@NotNull(message = "配置刷新间隔不能为空")
|
||||
@Min(value = 1, message = "配置刷新间隔不能小于 1 秒")
|
||||
private Integer configRefreshInterval = 30;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient;
|
||||
|
||||
import cn.hutool.core.lang.Assert;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.config.IotGatewayProperties.ProtocolProperties;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.IotProtocol;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream.IotModbusTcpClientDownstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream.IotModbusTcpClientDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.upstream.IotModbusTcpClientUpstreamHandler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConfigCacheService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientPollScheduler;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import io.vertx.core.Vertx;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.redisson.api.RedissonClient;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* IoT 网关 Modbus TCP Client 协议:主动轮询 Modbus 从站设备数据
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusTcpClientProtocol implements IotProtocol {
|
||||
|
||||
/**
|
||||
* 协议配置
|
||||
*/
|
||||
private final ProtocolProperties properties;
|
||||
/**
|
||||
* 服务器 ID(用于消息追踪,全局唯一)
|
||||
*/
|
||||
@Getter
|
||||
private final String serverId;
|
||||
|
||||
/**
|
||||
* 运行状态
|
||||
*/
|
||||
@Getter
|
||||
private volatile boolean running = false;
|
||||
|
||||
/**
|
||||
* Vert.x 实例
|
||||
*/
|
||||
private final Vertx vertx;
|
||||
/**
|
||||
* 配置刷新定时器 ID
|
||||
*/
|
||||
private Long configRefreshTimerId;
|
||||
|
||||
/**
|
||||
* 连接管理器
|
||||
*/
|
||||
private final IotModbusTcpClientConnectionManager connectionManager;
|
||||
/**
|
||||
* 下行消息订阅者
|
||||
*/
|
||||
private IotModbusTcpClientDownstreamSubscriber downstreamSubscriber;
|
||||
|
||||
private final IotModbusTcpClientConfigCacheService configCacheService;
|
||||
private final IotModbusTcpClientPollScheduler pollScheduler;
|
||||
|
||||
public IotModbusTcpClientProtocol(ProtocolProperties properties) {
|
||||
IotModbusTcpClientConfig modbusTcpClientConfig = properties.getModbusTcpClient();
|
||||
Assert.notNull(modbusTcpClientConfig, "Modbus TCP Client 协议配置(modbusTcpClient)不能为空");
|
||||
this.properties = properties;
|
||||
this.serverId = IotDeviceMessageUtils.generateServerId(properties.getPort());
|
||||
|
||||
// 初始化 Vertx
|
||||
this.vertx = Vertx.vertx();
|
||||
|
||||
// 初始化 Manager
|
||||
RedissonClient redissonClient = SpringUtil.getBean(RedissonClient.class);
|
||||
IotDeviceCommonApi deviceApi = SpringUtil.getBean(IotDeviceCommonApi.class);
|
||||
IotDeviceMessageService messageService = SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
this.configCacheService = new IotModbusTcpClientConfigCacheService(deviceApi);
|
||||
this.connectionManager = new IotModbusTcpClientConnectionManager(redissonClient, vertx,
|
||||
messageService, configCacheService, serverId);
|
||||
|
||||
// 初始化 Handler
|
||||
IotModbusTcpClientUpstreamHandler upstreamHandler = new IotModbusTcpClientUpstreamHandler(messageService, serverId);
|
||||
|
||||
// 初始化轮询调度器
|
||||
this.pollScheduler = new IotModbusTcpClientPollScheduler(vertx, connectionManager, upstreamHandler, configCacheService);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return properties.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public IotProtocolTypeEnum getType() {
|
||||
return IotProtocolTypeEnum.MODBUS_TCP_CLIENT;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
if (running) {
|
||||
log.warn("[start][IoT Modbus TCP Client 协议 {} 已经在运行中]", getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1.1 首次加载配置
|
||||
refreshConfig();
|
||||
// 1.2 启动配置刷新定时器
|
||||
int refreshInterval = properties.getModbusTcpClient().getConfigRefreshInterval();
|
||||
configRefreshTimerId = vertx.setPeriodic(
|
||||
TimeUnit.SECONDS.toMillis(refreshInterval),
|
||||
id -> refreshConfig()
|
||||
);
|
||||
running = true;
|
||||
log.info("[start][IoT Modbus TCP Client 协议 {} 启动成功,serverId={}]", getId(), serverId);
|
||||
|
||||
// 2. 启动下行消息订阅者
|
||||
IotMessageBus messageBus = SpringUtil.getBean(IotMessageBus.class);
|
||||
IotModbusTcpClientDownstreamHandler downstreamHandler = new IotModbusTcpClientDownstreamHandler(connectionManager,
|
||||
configCacheService);
|
||||
this.downstreamSubscriber = new IotModbusTcpClientDownstreamSubscriber(this, downstreamHandler, messageBus);
|
||||
this.downstreamSubscriber.start();
|
||||
} catch (Exception e) {
|
||||
log.error("[start][IoT Modbus TCP Client 协议 {} 启动失败]", getId(), e);
|
||||
stop0();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
stop0();
|
||||
}
|
||||
|
||||
private void stop0() {
|
||||
// 1. 停止下行消息订阅者
|
||||
if (downstreamSubscriber != null) {
|
||||
try {
|
||||
downstreamSubscriber.stop();
|
||||
log.info("[stop][IoT Modbus TCP Client 协议 {} 下行消息订阅者已停止]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT Modbus TCP Client 协议 {} 下行消息订阅者停止失败]", getId(), e);
|
||||
}
|
||||
downstreamSubscriber = null;
|
||||
}
|
||||
|
||||
// 2.1 取消配置刷新定时器
|
||||
if (configRefreshTimerId != null) {
|
||||
vertx.cancelTimer(configRefreshTimerId);
|
||||
configRefreshTimerId = null;
|
||||
}
|
||||
// 2.2 停止轮询调度器
|
||||
pollScheduler.stopAll();
|
||||
// 2.3 关闭所有连接
|
||||
connectionManager.closeAll();
|
||||
|
||||
// 3. 关闭 Vert.x 实例
|
||||
if (vertx != null) {
|
||||
try {
|
||||
vertx.close().result();
|
||||
log.info("[stop][IoT Modbus TCP Client 协议 {} Vertx 已关闭]", getId());
|
||||
} catch (Exception e) {
|
||||
log.error("[stop][IoT Modbus TCP Client 协议 {} Vertx 关闭失败]", getId(), e);
|
||||
}
|
||||
}
|
||||
running = false;
|
||||
log.info("[stop][IoT Modbus TCP Client 协议 {} 已停止]", getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新配置
|
||||
*/
|
||||
private synchronized void refreshConfig() {
|
||||
try {
|
||||
// 1. 从 biz 拉取最新配置(API 失败时返回 null)
|
||||
List<IotModbusDeviceConfigRespDTO> configs = configCacheService.refreshConfig();
|
||||
if (configs == null) {
|
||||
log.warn("[refreshConfig][API 失败,跳过本轮刷新]");
|
||||
return;
|
||||
}
|
||||
log.debug("[refreshConfig][获取到 {} 个 Modbus 设备配置]", configs.size());
|
||||
|
||||
// 2. 更新连接和轮询任务
|
||||
for (IotModbusDeviceConfigRespDTO config : configs) {
|
||||
try {
|
||||
// 2.1 确保连接存在
|
||||
connectionManager.ensureConnection(config);
|
||||
// 2.2 更新轮询任务
|
||||
pollScheduler.updatePolling(config);
|
||||
} catch (Exception e) {
|
||||
log.error("[refreshConfig][处理设备配置失败, deviceId={}]", config.getDeviceId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 清理已删除设备的资源
|
||||
Set<Long> removedDeviceIds = configCacheService.cleanupRemovedDevices(configs);
|
||||
for (Long deviceId : removedDeviceIds) {
|
||||
pollScheduler.stopPolling(deviceId);
|
||||
connectionManager.removeDevice(deviceId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[refreshConfig][刷新配置失败]", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream;
|
||||
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusTcpClientUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConfigCacheService;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientConnectionManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP Client 下行消息处理器
|
||||
* <p>
|
||||
* 负责:
|
||||
* 1. 处理下行消息(如属性设置 thing.service.property.set)
|
||||
* 2. 将属性值转换为 Modbus 写指令,通过 TCP 连接发送给设备
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IotModbusTcpClientDownstreamHandler {
|
||||
|
||||
private final IotModbusTcpClientConnectionManager connectionManager;
|
||||
private final IotModbusTcpClientConfigCacheService configCacheService;
|
||||
|
||||
/**
|
||||
* 处理下行消息
|
||||
*/
|
||||
@SuppressWarnings({"unchecked", "DuplicatedCode"})
|
||||
public void handle(IotDeviceMessage message) {
|
||||
// 1.1 检查是否是属性设置消息
|
||||
if (ObjUtil.equals(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), message.getMethod())) {
|
||||
return;
|
||||
}
|
||||
if (ObjUtil.notEqual(IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod(), message.getMethod())) {
|
||||
log.warn("[handle][忽略非属性设置消息: {}]", message.getMethod());
|
||||
return;
|
||||
}
|
||||
// 1.2 获取设备配置
|
||||
IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(message.getDeviceId());
|
||||
if (config == null) {
|
||||
log.warn("[handle][设备 {} 没有 Modbus 配置]", message.getDeviceId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 解析属性值并写入
|
||||
Object params = message.getParams();
|
||||
if (!(params instanceof Map)) {
|
||||
log.warn("[handle][params 不是 Map 类型: {}]", params);
|
||||
return;
|
||||
}
|
||||
Map<String, Object> propertyMap = (Map<String, Object>) params;
|
||||
for (Map.Entry<String, Object> entry : propertyMap.entrySet()) {
|
||||
String identifier = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
// 2.1 查找对应的点位配置
|
||||
IotModbusPointRespDTO point = IotModbusCommonUtils.findPoint(config, identifier);
|
||||
if (point == null) {
|
||||
log.warn("[handle][设备 {} 没有点位配置: {}]", message.getDeviceId(), identifier);
|
||||
continue;
|
||||
}
|
||||
// 2.2 检查是否支持写操作
|
||||
if (!IotModbusCommonUtils.isWritable(point.getFunctionCode())) {
|
||||
log.warn("[handle][点位 {} 不支持写操作, 功能码={}]", identifier, point.getFunctionCode());
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2.3 执行写入
|
||||
writeProperty(config, point, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入属性值
|
||||
*/
|
||||
private void writeProperty(IotModbusDeviceConfigRespDTO config, IotModbusPointRespDTO point, Object value) {
|
||||
// 1.1 获取连接
|
||||
IotModbusTcpClientConnectionManager.ModbusConnection connection = connectionManager.getConnection(config.getDeviceId());
|
||||
if (connection == null) {
|
||||
log.warn("[writeProperty][设备 {} 没有连接]", config.getDeviceId());
|
||||
return;
|
||||
}
|
||||
// 1.2 获取 slave ID
|
||||
Integer slaveId = connectionManager.getSlaveId(config.getDeviceId());
|
||||
if (slaveId == null) {
|
||||
log.warn("[writeProperty][设备 {} 没有 slaveId]", config.getDeviceId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.1 转换属性值为原始值
|
||||
int[] rawValues = IotModbusCommonUtils.convertToRawValues(value, point);
|
||||
// 2.2 执行 Modbus 写入
|
||||
IotModbusTcpClientUtils.write(connection, slaveId, point, rawValues)
|
||||
.onSuccess(success -> log.info("[writeProperty][写入成功, deviceId={}, identifier={}, value={}]",
|
||||
config.getDeviceId(), point.getIdentifier(), value))
|
||||
.onFailure(e -> log.error("[writeProperty][写入失败, deviceId={}, identifier={}]",
|
||||
config.getDeviceId(), point.getIdentifier(), e));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.downstream;
|
||||
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.AbstractIotProtocolDownstreamSubscriber;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.IotModbusTcpClientProtocol;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP 下行消息订阅器:订阅消息总线的下行消息并转发给处理器
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusTcpClientDownstreamSubscriber extends AbstractIotProtocolDownstreamSubscriber {
|
||||
|
||||
private final IotModbusTcpClientDownstreamHandler downstreamHandler;
|
||||
|
||||
public IotModbusTcpClientDownstreamSubscriber(IotModbusTcpClientProtocol protocol,
|
||||
IotModbusTcpClientDownstreamHandler downstreamHandler,
|
||||
IotMessageBus messageBus) {
|
||||
super(protocol, messageBus);
|
||||
this.downstreamHandler = downstreamHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleMessage(IotDeviceMessage message) {
|
||||
downstreamHandler.handle(message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.upstream;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP 上行数据处理器:将原始值转换为物模型属性值并上报
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusTcpClientUpstreamHandler {
|
||||
|
||||
private final IotDeviceMessageService messageService;
|
||||
|
||||
private final String serverId;
|
||||
|
||||
public IotModbusTcpClientUpstreamHandler(IotDeviceMessageService messageService,
|
||||
String serverId) {
|
||||
this.messageService = messageService;
|
||||
this.serverId = serverId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 Modbus 读取结果
|
||||
*
|
||||
* @param config 设备配置
|
||||
* @param point 点位配置
|
||||
* @param rawValue 原始值(int 数组)
|
||||
*/
|
||||
public void handleReadResult(IotModbusDeviceConfigRespDTO config,
|
||||
IotModbusPointRespDTO point,
|
||||
int[] rawValue) {
|
||||
try {
|
||||
// 1.1 转换原始值为物模型属性值(点位翻译)
|
||||
Object convertedValue = IotModbusCommonUtils.convertToPropertyValue(rawValue, point);
|
||||
log.debug("[handleReadResult][设备={}, 属性={}, 原始值={}, 转换值={}]",
|
||||
config.getDeviceId(), point.getIdentifier(), rawValue, convertedValue);
|
||||
// 1.2 构造属性上报消息
|
||||
Map<String, Object> params = MapUtil.of(point.getIdentifier(), convertedValue);
|
||||
IotDeviceMessage message = IotDeviceMessage.requestOf(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod(), params);
|
||||
|
||||
// 2. 发送到消息总线
|
||||
messageService.sendDeviceMessage(message, config.getProductKey(),
|
||||
config.getDeviceName(), serverId);
|
||||
} catch (Exception e) {
|
||||
log.error("[handleReadResult][处理读取结果失败, deviceId={}, identifier={}]",
|
||||
config.getDeviceId(), point.getIdentifier(), e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusModeEnum;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP Client 配置缓存服务
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IotModbusTcpClientConfigCacheService {
|
||||
|
||||
private final IotDeviceCommonApi deviceApi;
|
||||
|
||||
/**
|
||||
* 配置缓存:deviceId -> 配置
|
||||
*/
|
||||
private final Map<Long, IotModbusDeviceConfigRespDTO> configCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 已知的设备 ID 集合(作用:用于检测已删除的设备)
|
||||
*
|
||||
* @see #cleanupRemovedDevices(List)
|
||||
*/
|
||||
private final Set<Long> knownDeviceIds = ConcurrentHashMap.newKeySet();
|
||||
|
||||
/**
|
||||
* 刷新配置
|
||||
*
|
||||
* @return 最新的配置列表;API 失败时返回 null(调用方应跳过 cleanup)
|
||||
*/
|
||||
public List<IotModbusDeviceConfigRespDTO> refreshConfig() {
|
||||
try {
|
||||
// 1. 从远程获取配置
|
||||
CommonResult<List<IotModbusDeviceConfigRespDTO>> result = deviceApi.getModbusDeviceConfigList(
|
||||
new IotModbusDeviceConfigListReqDTO().setStatus(CommonStatusEnum.ENABLE.getStatus())
|
||||
.setMode(IotModbusModeEnum.POLLING.getMode()).setProtocolType(IotProtocolTypeEnum.MODBUS_TCP_CLIENT.getType()));
|
||||
result.checkError();
|
||||
List<IotModbusDeviceConfigRespDTO> configs = result.getData();
|
||||
|
||||
// 2. 更新缓存(注意:不在这里更新 knownDeviceIds,由 cleanupRemovedDevices 统一管理)
|
||||
for (IotModbusDeviceConfigRespDTO config : configs) {
|
||||
configCache.put(config.getDeviceId(), config);
|
||||
}
|
||||
return configs;
|
||||
} catch (Exception e) {
|
||||
log.error("[refreshConfig][刷新配置失败]", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备配置
|
||||
*
|
||||
* @param deviceId 设备 ID
|
||||
* @return 配置
|
||||
*/
|
||||
public IotModbusDeviceConfigRespDTO getConfig(Long deviceId) {
|
||||
return configCache.get(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算已删除设备的 ID 集合,清理缓存,并更新已知设备 ID 集合
|
||||
*
|
||||
* @param currentConfigs 当前有效的配置列表
|
||||
* @return 已删除的设备 ID 集合
|
||||
*/
|
||||
public Set<Long> cleanupRemovedDevices(List<IotModbusDeviceConfigRespDTO> currentConfigs) {
|
||||
// 1.1 获取当前有效的设备 ID
|
||||
Set<Long> currentDeviceIds = convertSet(currentConfigs, IotModbusDeviceConfigRespDTO::getDeviceId);
|
||||
// 1.2 找出已删除的设备(基于旧的 knownDeviceIds)
|
||||
Set<Long> removedDeviceIds = new HashSet<>(knownDeviceIds);
|
||||
removedDeviceIds.removeAll(currentDeviceIds);
|
||||
|
||||
// 2. 清理已删除设备的缓存
|
||||
for (Long deviceId : removedDeviceIds) {
|
||||
log.info("[cleanupRemovedDevices][清理已删除设备: {}]", deviceId);
|
||||
configCache.remove(deviceId);
|
||||
}
|
||||
|
||||
// 3. 更新已知设备 ID 集合为当前有效的设备 ID
|
||||
knownDeviceIds.clear();
|
||||
knownDeviceIds.addAll(currentDeviceIds);
|
||||
return removedDeviceIds;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager;
|
||||
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.gateway.service.device.message.IotDeviceMessageService;
|
||||
import com.ghgande.j2mod.modbus.net.TCPMasterConnection;
|
||||
import io.vertx.core.Context;
|
||||
import io.vertx.core.Future;
|
||||
import io.vertx.core.Vertx;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.redisson.api.RLock;
|
||||
import org.redisson.api.RedissonClient;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP 连接管理器
|
||||
* <p>
|
||||
* 统一管理 Modbus TCP 连接:
|
||||
* 1. 管理 TCP 连接(相同 ip:port 共用连接)
|
||||
* 2. 分布式锁管理(连接级别),避免多节点重复创建连接
|
||||
* 3. 连接重试和故障恢复
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
public class IotModbusTcpClientConnectionManager {
|
||||
|
||||
private static final String LOCK_KEY_PREFIX = "iot:modbus-tcp:connection:";
|
||||
|
||||
private final RedissonClient redissonClient;
|
||||
private final Vertx vertx;
|
||||
private final IotDeviceMessageService messageService;
|
||||
private final IotModbusTcpClientConfigCacheService configCacheService;
|
||||
private final String serverId;
|
||||
|
||||
/**
|
||||
* 连接池:key = ip:port
|
||||
*/
|
||||
private final Map<String, ModbusConnection> connectionPool = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 设备 ID 到连接 key 的映射
|
||||
*/
|
||||
private final Map<Long, String> deviceConnectionMap = new ConcurrentHashMap<>();
|
||||
|
||||
public IotModbusTcpClientConnectionManager(RedissonClient redissonClient, Vertx vertx,
|
||||
IotDeviceMessageService messageService,
|
||||
IotModbusTcpClientConfigCacheService configCacheService,
|
||||
String serverId) {
|
||||
this.redissonClient = redissonClient;
|
||||
this.vertx = vertx;
|
||||
this.messageService = messageService;
|
||||
this.configCacheService = configCacheService;
|
||||
this.serverId = serverId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保连接存在
|
||||
* <p>
|
||||
* 首次建连成功时,直接发送设备上线消息
|
||||
*
|
||||
* @param config 设备配置
|
||||
*/
|
||||
public void ensureConnection(IotModbusDeviceConfigRespDTO config) {
|
||||
// 1.1 检查设备是否切换了 IP/端口,若是则先清理旧连接
|
||||
String connectionKey = buildConnectionKey(config.getIp(), config.getPort());
|
||||
String oldConnectionKey = deviceConnectionMap.get(config.getDeviceId());
|
||||
if (oldConnectionKey != null && ObjUtil.notEqual(oldConnectionKey, connectionKey)) {
|
||||
log.info("[ensureConnection][设备 {} IP/端口变更: {} -> {}, 清理旧连接]",
|
||||
config.getDeviceId(), oldConnectionKey, connectionKey);
|
||||
removeDevice(config.getDeviceId());
|
||||
}
|
||||
// 1.2 记录设备与连接的映射
|
||||
deviceConnectionMap.put(config.getDeviceId(), connectionKey);
|
||||
|
||||
// 2. 情况一:连接已存在,注册设备并发送上线消息
|
||||
ModbusConnection connection = connectionPool.get(connectionKey);
|
||||
if (connection != null) {
|
||||
addDeviceAndOnline(connection, config);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 情况二:连接不存在,加分布式锁创建新连接
|
||||
RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + connectionKey);
|
||||
if (!lock.tryLock()) {
|
||||
log.debug("[ensureConnection][获取锁失败, 由其他节点负责: {}]", connectionKey);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 3.1 double-check:拿到锁后再次检查,避免并发创建重复连接
|
||||
connection = connectionPool.get(connectionKey);
|
||||
if (connection != null) {
|
||||
addDeviceAndOnline(connection, config);
|
||||
lock.unlock();
|
||||
return;
|
||||
}
|
||||
// 3.2 创建新连接
|
||||
connection = createConnection(config);
|
||||
connection.setLock(lock);
|
||||
connectionPool.put(connectionKey, connection);
|
||||
log.info("[ensureConnection][创建 Modbus 连接成功: {}]", connectionKey);
|
||||
// 3.3 注册设备并发送上线消息
|
||||
addDeviceAndOnline(connection, config);
|
||||
} catch (Exception e) {
|
||||
log.error("[ensureConnection][创建 Modbus 连接失败: {}]", connectionKey, e);
|
||||
// 建连失败,释放锁让其他节点可重试
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Modbus TCP 连接
|
||||
*/
|
||||
private ModbusConnection createConnection(IotModbusDeviceConfigRespDTO config) throws Exception {
|
||||
// 1. 创建 TCP 连接
|
||||
TCPMasterConnection tcpConnection = new TCPMasterConnection(InetAddress.getByName(config.getIp()));
|
||||
tcpConnection.setPort(config.getPort());
|
||||
tcpConnection.setTimeout(config.getTimeout());
|
||||
tcpConnection.connect();
|
||||
|
||||
// 2. 创建 Modbus 连接对象
|
||||
return new ModbusConnection()
|
||||
.setConnectionKey(buildConnectionKey(config.getIp(), config.getPort()))
|
||||
.setTcpConnection(tcpConnection).setContext(vertx.getOrCreateContext())
|
||||
.setTimeout(config.getTimeout()).setRetryInterval(config.getRetryInterval());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接
|
||||
*/
|
||||
public ModbusConnection getConnection(Long deviceId) {
|
||||
String connectionKey = deviceConnectionMap.get(deviceId);
|
||||
if (connectionKey == null) {
|
||||
return null;
|
||||
}
|
||||
return connectionPool.get(connectionKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备的 slave ID
|
||||
*/
|
||||
public Integer getSlaveId(Long deviceId) {
|
||||
ModbusConnection connection = getConnection(deviceId);
|
||||
if (connection == null) {
|
||||
return null;
|
||||
}
|
||||
return connection.getSlaveId(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除设备
|
||||
* <p>
|
||||
* 移除时直接发送设备下线消息
|
||||
*/
|
||||
public void removeDevice(Long deviceId) {
|
||||
// 1.1 移除设备时,发送下线消息
|
||||
sendOfflineMessage(deviceId);
|
||||
// 1.2 移除设备引用
|
||||
String connectionKey = deviceConnectionMap.remove(deviceId);
|
||||
if (connectionKey == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.1 移除连接中的设备引用
|
||||
ModbusConnection connection = connectionPool.get(connectionKey);
|
||||
if (connection == null) {
|
||||
return;
|
||||
}
|
||||
connection.removeDevice(deviceId);
|
||||
// 2.2 如果没有设备引用了,关闭连接
|
||||
if (connection.getDeviceCount() == 0) {
|
||||
closeConnection(connectionKey);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 设备连接 & 上下线消息 ====================
|
||||
|
||||
/**
|
||||
* 注册设备到连接,并发送上线消息
|
||||
*/
|
||||
private void addDeviceAndOnline(ModbusConnection connection,
|
||||
IotModbusDeviceConfigRespDTO config) {
|
||||
Integer previous = connection.addDevice(config.getDeviceId(), config.getSlaveId());
|
||||
// 首次注册,发送上线消息
|
||||
if (previous == null) {
|
||||
sendOnlineMessage(config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送设备上线消息
|
||||
*/
|
||||
private void sendOnlineMessage(IotModbusDeviceConfigRespDTO config) {
|
||||
try {
|
||||
IotDeviceMessage onlineMessage = IotDeviceMessage.buildStateUpdateOnline();
|
||||
messageService.sendDeviceMessage(onlineMessage,
|
||||
config.getProductKey(), config.getDeviceName(), serverId);
|
||||
} catch (Exception ex) {
|
||||
log.error("[sendOnlineMessage][发送设备上线消息失败, deviceId={}]", config.getDeviceId(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送设备下线消息
|
||||
*/
|
||||
private void sendOfflineMessage(Long deviceId) {
|
||||
IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(deviceId);
|
||||
if (config == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
IotDeviceMessage offlineMessage = IotDeviceMessage.buildStateOffline();
|
||||
messageService.sendDeviceMessage(offlineMessage,
|
||||
config.getProductKey(), config.getDeviceName(), serverId);
|
||||
} catch (Exception ex) {
|
||||
log.error("[sendOfflineMessage][发送设备下线消息失败, deviceId={}]", deviceId, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭指定连接
|
||||
*/
|
||||
private void closeConnection(String connectionKey) {
|
||||
ModbusConnection connection = connectionPool.remove(connectionKey);
|
||||
if (connection == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (connection.getTcpConnection() != null) {
|
||||
connection.getTcpConnection().close();
|
||||
}
|
||||
// 释放分布式锁,让其他节点可接管
|
||||
RLock lock = connection.getLock();
|
||||
if (lock != null && lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
log.info("[closeConnection][关闭 Modbus 连接: {}]", connectionKey);
|
||||
} catch (Exception e) {
|
||||
log.error("[closeConnection][关闭连接失败: {}]", connectionKey, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有连接
|
||||
*/
|
||||
public void closeAll() {
|
||||
// 先复制再遍历,避免 closeConnection 中 remove 导致并发修改
|
||||
List<String> connectionKeys = new ArrayList<>(connectionPool.keySet());
|
||||
for (String connectionKey : connectionKeys) {
|
||||
closeConnection(connectionKey);
|
||||
}
|
||||
deviceConnectionMap.clear();
|
||||
}
|
||||
|
||||
private String buildConnectionKey(String ip, Integer port) {
|
||||
return ip + ":" + port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modbus 连接信息
|
||||
*/
|
||||
@Data
|
||||
public static class ModbusConnection {
|
||||
|
||||
private String connectionKey;
|
||||
private TCPMasterConnection tcpConnection;
|
||||
private Integer timeout;
|
||||
private Integer retryInterval;
|
||||
/**
|
||||
* 设备 ID 到 slave ID 的映射
|
||||
*/
|
||||
private final Map<Long, Integer> deviceSlaveMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 分布式锁,锁住连接的创建和销毁,避免多节点重复连接同一从站
|
||||
*/
|
||||
private RLock lock;
|
||||
|
||||
/**
|
||||
* Vert.x Context,用于 executeBlocking 执行 Modbus 操作,保证同一连接的操作串行执行
|
||||
*/
|
||||
private Context context;
|
||||
|
||||
public Integer addDevice(Long deviceId, Integer slaveId) {
|
||||
return deviceSlaveMap.putIfAbsent(deviceId, slaveId);
|
||||
}
|
||||
|
||||
public void removeDevice(Long deviceId) {
|
||||
deviceSlaveMap.remove(deviceId);
|
||||
}
|
||||
|
||||
public int getDeviceCount() {
|
||||
return deviceSlaveMap.size();
|
||||
}
|
||||
|
||||
public Integer getSlaveId(Long deviceId) {
|
||||
return deviceSlaveMap.get(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 Modbus 读取操作(阻塞方式,在 Vert.x worker 线程执行)
|
||||
*/
|
||||
public <T> Future<T> executeBlocking(java.util.function.Function<TCPMasterConnection, T> operation) {
|
||||
// ordered=true 保证同一 Context 的操作串行执行,不同连接之间可并行
|
||||
return context.executeBlocking(() -> operation.apply(tcpConnection), true);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user