From a205823773b3845e9260e60366dc0723b86a56ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 11:27:02 +0800 Subject: [PATCH 01/17] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/machine/MachineCommandManager.java | 272 +++++++++++++ .../command/CommandExecutionListener.java | 15 + .../impl/machine/command/CommandResult.java | 58 +++ .../impl/machine/command/CommandType.java | 86 +++++ .../impl/machine/command/Transaction.java | 62 +++ .../machine/command/TransactionExecutor.java | 313 +++++++++++++++ .../impl/machine/config/ExecutorConfig.java | 72 ++++ .../config/MachineFrameworkConfig.java | 29 ++ .../impl/machine/config/RedisConfig.java | 30 ++ .../instruction/AbstractInstruction.java | 57 +++ .../machine/instruction/CallbackConfig.java | 150 ++++++++ .../impl/machine/instruction/Instruction.java | 87 +++++ .../instruction/InstructionContext.java | 85 +++++ .../instruction/InstructionResult.java | 44 +++ .../machine/mqtt/MqttCallbackHandler.java | 45 +++ .../machine/mqtt/MqttCallbackRegistry.java | 361 ++++++++++++++++++ .../domain/impl/machine/mqtt/MqttClient.java | 14 + .../mqtt/store/InMemoryMqttCallbackStore.java | 85 +++++ .../machine/mqtt/store/MqttCallbackInfo.java | 72 ++++ .../machine/mqtt/store/MqttCallbackStore.java | 79 ++++ .../mqtt/store/RedisMqttCallbackStore.java | 267 +++++++++++++ .../device/domain/impl/machine/readme.txt | 218 +++++++++++ .../impl/machine/state/AirportState.java | 15 + .../domain/impl/machine/state/CoverState.java | 22 ++ .../impl/machine/state/DebugModeState.java | 21 + .../domain/impl/machine/state/DrcState.java | 23 ++ .../domain/impl/machine/state/DroneState.java | 33 ++ .../impl/machine/state/MachineStates.java | 50 +++ .../domain/impl/machine/state/StopState.java | 22 ++ .../statemachine/MachineStateManager.java | 195 ++++++++++ .../statemachine/StateChangeListener.java | 18 + .../store/InMemoryMachineStateStore.java | 50 +++ .../statemachine/store/MachineStateStore.java | 43 +++ .../store/RedisMachineStateStore.java | 93 +++++ .../impl/machine/vendor/VendorConfig.java | 49 +++ .../impl/machine/vendor/VendorRegistry.java | 119 ++++++ .../machine/vendor/dji/DjiVendorConfig.java | 121 ++++++ .../DjiCancelPointInstruction.java | 57 +++ .../DjiCheckDebugModeInstruction.java | 51 +++ .../instruction/DjiCloseCoverInstruction.java | 58 +++ .../DjiEmergencyStopInstruction.java | 61 +++ .../DjiEnableDebugModeInstruction.java | 55 +++ .../dji/instruction/DjiLandInstruction.java | 59 +++ .../instruction/DjiOpenCoverInstruction.java | 60 +++ .../instruction/DjiPointFlyInstruction.java | 63 +++ .../DjiResumeFlightInstruction.java | 61 +++ .../instruction/DjiReturnHomeInstruction.java | 58 +++ .../DjiStartMissionInstruction.java | 60 +++ .../instruction/DjiTakeOffInstruction.java | 65 ++++ .../InMemorySnVendorMappingRepository.java | 53 +++ .../MysqlSnVendorMappingRepository.java | 97 +++++ .../repository/SnVendorMappingRepository.java | 46 +++ .../store/InMemorySnVendorMappingStore.java | 47 +++ .../store/RedisSnVendorMappingStore.java | 117 ++++++ .../vendor/store/SnVendorMappingStore.java | 49 +++ 55 files changed, 4492 insertions(+) create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/MachineCommandManager.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandExecutionListener.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandResult.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandType.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/command/Transaction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/command/TransactionExecutor.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/config/ExecutorConfig.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/config/MachineFrameworkConfig.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/config/RedisConfig.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/instruction/AbstractInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/instruction/CallbackConfig.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/instruction/Instruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/instruction/InstructionContext.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/instruction/InstructionResult.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttCallbackHandler.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttCallbackRegistry.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttClient.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/InMemoryMqttCallbackStore.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/MqttCallbackInfo.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/MqttCallbackStore.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/RedisMqttCallbackStore.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/readme.txt create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/state/AirportState.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/state/CoverState.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/state/DebugModeState.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/state/DrcState.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/state/DroneState.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/state/MachineStates.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/state/StopState.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/MachineStateManager.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/StateChangeListener.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/InMemoryMachineStateStore.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/MachineStateStore.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/RedisMachineStateStore.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/VendorConfig.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/VendorRegistry.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/DjiVendorConfig.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCancelPointInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCheckDebugModeInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCloseCoverInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiEmergencyStopInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiEnableDebugModeInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiLandInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiOpenCoverInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiPointFlyInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiResumeFlightInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiReturnHomeInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiStartMissionInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiTakeOffInstruction.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/InMemorySnVendorMappingRepository.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/MysqlSnVendorMappingRepository.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/SnVendorMappingRepository.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/InMemorySnVendorMappingStore.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/RedisSnVendorMappingStore.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/SnVendorMappingStore.java diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/MachineCommandManager.java b/src/main/java/com/ruoyi/device/domain/impl/machine/MachineCommandManager.java new file mode 100644 index 0000000..a065b4b --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/MachineCommandManager.java @@ -0,0 +1,272 @@ +package com.ruoyi.device.domain.impl.machine; + + +import com.ruoyi.device.domain.impl.machine.command.*; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import com.ruoyi.device.domain.impl.machine.mqtt.MqttClient; +import com.ruoyi.device.domain.impl.machine.state.MachineStates; +import com.ruoyi.device.domain.impl.machine.statemachine.MachineStateManager; +import com.ruoyi.device.domain.impl.machine.statemachine.StateChangeListener; +import com.ruoyi.device.domain.impl.machine.vendor.VendorConfig; +import com.ruoyi.device.domain.impl.machine.vendor.VendorRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 设备命令管理器(框架使用者的主要入口) + */ +@Slf4j +@Component +public class MachineCommandManager { + + private final VendorRegistry vendorRegistry; + private final MachineStateManager stateManager; + private final TransactionExecutor transactionExecutor; + private final MqttClient mqttClient; + + /** + * SN -> 当前正在执行的命令 + */ + private final Map executingCommands = new ConcurrentHashMap<>(); + + /** + * 命令执行监听器 + */ + private final Map commandListeners = new ConcurrentHashMap<>(); + + public MachineCommandManager(VendorRegistry vendorRegistry, + MachineStateManager stateManager, + TransactionExecutor transactionExecutor, + MqttClient mqttClient) { + this.vendorRegistry = vendorRegistry; + this.stateManager = stateManager; + this.transactionExecutor = transactionExecutor; + this.mqttClient = mqttClient; + } + + /** + * 获取设备当前状态 + * + * @param sn 设备SN号 + * @return 设备状态 + */ + public MachineStates getMachineStates(String sn) { + return stateManager.getStates(sn); + } + + /** + * 更新设备状态(通常在心跳中调用) + * + * @param sn 设备SN号 + * @param newStates 新状态 + */ + public void updateMachineStates(String sn, MachineStates newStates,Boolean force) { + stateManager.updateStates(sn, newStates,force); + } + + /** + * 判断设备是否正在执行命令 + * + * @param sn 设备SN号 + * @return 是否正在执行命令 + */ + public boolean isExecutingCommand(String sn) { + CommandExecution execution = executingCommands.get(sn); + return execution != null && !execution.getFuture().isDone(); + } + + /** + * 获取设备当前正在执行的命令类型 + * + * @param sn 设备SN号 + * @return 命令类型,如果没有正在执行的命令则返回null + */ + public CommandType getExecutingCommandType(String sn) { + CommandExecution execution = executingCommands.get(sn); + if (execution != null && !execution.getFuture().isDone()) { + return execution.getCommandType(); + } + return null; + } + + /** + * 获取设备在当前状态下可以执行的命令列表 + * + * @param sn 设备SN号 + * @return 可执行的命令列表 + */ + public List getAvailableCommands(String sn) { + VendorConfig vendorConfig = vendorRegistry.getVendorConfig(sn); + if (vendorConfig == null) { + log.warn("设备未绑定厂家: sn={}", sn); + return List.of(); + } + + MachineStates currentStates = stateManager.getStates(sn); + return vendorConfig.getAvailableCommands(currentStates); + } + + /** + * 执行命令 + * + * @param sn 设备SN号 + * @param commandType 命令类型 + * @return 命令执行结果的Future + */ + public CompletableFuture executeCommand(String sn, CommandType commandType) { + return executeCommand(sn, commandType, Map.of()); + } + + /** + * 执行命令(带参数) + * + * @param sn 设备SN号 + * @param commandType 命令类型 + * @param params 命令参数 + * @return 命令执行结果的Future + */ + public CompletableFuture executeCommand(String sn, CommandType commandType, Map params) { + log.info("收到命令执行请求: sn={}, commandType={}, params={}", sn, commandType, params); + + // 1. 检查设备是否已绑定厂家 + VendorConfig vendorConfig = vendorRegistry.getVendorConfig(sn); + if (vendorConfig == null) { + String error = "设备未绑定厂家"; + log.error("{}: sn={}", error, sn); + return CompletableFuture.completedFuture(CommandResult.failure(commandType, error)); + } + + // 2. 检查是否正在执行其他命令 + if (isExecutingCommand(sn)) { + String error = "设备正在执行其他命令: " + getExecutingCommandType(sn); + log.warn("{}: sn={}", error, sn); + return CompletableFuture.completedFuture(CommandResult.failure(commandType, error)); + } + + // 3. 检查当前状态是否可以执行该命令 + MachineStates currentStates = stateManager.getStates(sn); + if (!vendorConfig.canExecuteCommand(currentStates, commandType)) { + String error = "当前状态不允许执行该命令"; + log.warn("{}: sn={}, commandType={}, currentStates={}", error, sn, commandType, currentStates); + return CompletableFuture.completedFuture(CommandResult.failure(commandType, error)); + } + + // 4. 获取事务定义 + Transaction transaction = vendorConfig.getTransaction(commandType); + if (transaction == null) { + String error = "厂家不支持该命令"; + log.error("{}: sn={}, commandType={}, vendorType={}", error, sn, commandType, vendorConfig.getVendorType()); + return CompletableFuture.completedFuture(CommandResult.failure(commandType, error)); + } + + // 5. 创建指令上下文 + InstructionContext context = new InstructionContext(sn, vendorConfig.getVendorType(), mqttClient); + params.forEach(context::putCommandParam); + + // 6. 执行事务 + CompletableFuture future = transactionExecutor.executeTransaction(transaction, context); + + // 7. 记录正在执行的命令 + executingCommands.put(sn, new CommandExecution(commandType, future, System.currentTimeMillis())); + + // 8. 添加完成回调 + future.whenComplete((result, throwable) -> { + executingCommands.remove(sn); + + if (throwable != null) { + log.error("命令执行异常: sn={}, commandType={}", sn, commandType, throwable); + notifyCommandComplete(sn, CommandResult.failure(commandType, "命令执行异常: " + throwable.getMessage())); + } else { + log.info("命令执行完成: sn={}, commandType={}, success={}", sn, commandType, result.isSuccess()); + notifyCommandComplete(sn, result); + } + }); + + return future; + } + + /** + * 注册命令执行监听器 + * + * @param listenerId 监听器ID + * @param listener 监听器 + */ + public void registerCommandListener(String listenerId, CommandExecutionListener listener) { + commandListeners.put(listenerId, listener); + log.debug("注册命令执行监听器: listenerId={}", listenerId); + } + + /** + * 取消注册命令执行监听器 + * + * @param listenerId 监听器ID + */ + public void unregisterCommandListener(String listenerId) { + commandListeners.remove(listenerId); + log.debug("取消注册命令执行监听器: listenerId={}", listenerId); + } + + /** + * 注册状态变化监听器 + * + * @param listenerId 监听器ID + * @param listener 监听器 + */ + public void registerStateChangeListener(String listenerId, StateChangeListener listener) { + stateManager.registerStateChangeListener(listenerId, listener); + } + + /** + * 取消注册状态变化监听器 + * + * @param listenerId 监听器ID + */ + public void unregisterStateChangeListener(String listenerId) { + stateManager.unregisterStateChangeListener(listenerId); + } + + /** + * 通知命令执行完成 + */ + private void notifyCommandComplete(String sn, CommandResult result) { + for (CommandExecutionListener listener : commandListeners.values()) { + try { + listener.onCommandComplete(sn, result); + } catch (Exception e) { + log.error("命令执行监听器执行失败: sn={}, commandType={}", sn, result.getCommandType(), e); + } + } + } + + /** + * 命令执行信息 + */ + private static class CommandExecution { + private final CommandType commandType; + private final CompletableFuture future; + private final long startTime; + + public CommandExecution(CommandType commandType, CompletableFuture future, long startTime) { + this.commandType = commandType; + this.future = future; + this.startTime = startTime; + } + + public CommandType getCommandType() { + return commandType; + } + + public CompletableFuture getFuture() { + return future; + } + + public long getStartTime() { + return startTime; + } + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandExecutionListener.java b/src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandExecutionListener.java new file mode 100644 index 0000000..281fbfc --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandExecutionListener.java @@ -0,0 +1,15 @@ +package com.ruoyi.device.domain.impl.machine.command; + +/** + * 命令执行监听器 + */ +@FunctionalInterface +public interface CommandExecutionListener { + /** + * 命令执行完成回调 + * + * @param sn 设备SN号 + * @param result 命令执行结果 + */ + void onCommandComplete(String sn, CommandResult result); +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandResult.java b/src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandResult.java new file mode 100644 index 0000000..4af9663 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandResult.java @@ -0,0 +1,58 @@ +package com.ruoyi.device.domain.impl.machine.command; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 命令执行结果 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CommandResult { + /** + * 命令类型 + */ + private CommandType commandType; + + /** + * 是否成功 + */ + private boolean success; + + /** + * 错误信息 + */ + private String errorMessage; + + /** + * 失败的指令名称 + */ + private String failedInstructionName; + + /** + * 结果数据 + */ + private Object data; + + public static CommandResult success(CommandType commandType) { + return new CommandResult(commandType, true, null, null, null); + } + + public static CommandResult success(CommandType commandType, Object data) { + return new CommandResult(commandType, true, null, null, data); + } + + public static CommandResult failure(CommandType commandType, String errorMessage) { + return new CommandResult(commandType, false, errorMessage, null, null); + } + + public static CommandResult failure(CommandType commandType, String errorMessage, String failedInstructionName) { + return new CommandResult(commandType, false, errorMessage, failedInstructionName, null); + } + + public static CommandResult timeout(CommandType commandType) { + return new CommandResult(commandType, false, "命令执行超时", null, null); + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandType.java b/src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandType.java new file mode 100644 index 0000000..8d67570 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/command/CommandType.java @@ -0,0 +1,86 @@ +package com.ruoyi.device.domain.impl.machine.command; + +/** + * 命令类型枚举 + */ +public enum CommandType { + /** + * 起飞 + */ + TAKE_OFF, + + /** + * 返航 + */ + RETURN_HOME, + + /** + * 急停 + */ + EMERGENCY_STOP, + + /** + * 继续飞行 + */ + RESUME_FLIGHT, + + /** + * 指点飞行 + */ + POINT_FLY, + + /** + * 取消指点 + */ + CANCEL_POINT, + + /** + * 开始航线任务 + */ + START_MISSION, + + /** + * 暂停航线任务 + */ + PAUSE_MISSION, + + /** + * 恢复航线任务 + */ + RESUME_MISSION, + + /** + * 打开舱门 + */ + OPEN_COVER, + + /** + * 关闭舱门 + */ + CLOSE_COVER, + + /** + * 进入调试模式 + */ + ENTER_DEBUG_MODE, + + /** + * 退出调试模式 + */ + EXIT_DEBUG_MODE, + + /** + * 进入DRC模式 + */ + ENTER_DRC_MODE, + + /** + * 退出DRC模式 + */ + EXIT_DRC_MODE, + + /** + * 重启机巢 + */ + REBOOT_AIRPORT +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/command/Transaction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/command/Transaction.java new file mode 100644 index 0000000..e3a4e45 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/command/Transaction.java @@ -0,0 +1,62 @@ +package com.ruoyi.device.domain.impl.machine.command; + + +import com.ruoyi.device.domain.impl.machine.instruction.Instruction; +import lombok.Data; + +/** + * 事务(由多个指令组成的树状结构,支持条件分支) + */ +@Data +public class Transaction { + /** + * 事务名称 + */ + private String name; + + /** + * 命令类型 + */ + private CommandType commandType; + + /** + * 根指令(事务的起始指令) + */ + private Instruction rootInstruction; + + /** + * 事务超时时间(毫秒) + */ + private long timeoutMs = 20000; // 默认10秒 + + public Transaction(String name, CommandType commandType) { + this.name = name; + this.commandType = commandType; + } + + /** + * 设置根指令 + * + * @param instruction 根指令 + * @return Transaction 支持链式调用 + */ + public Transaction root(Instruction instruction) { + this.rootInstruction = instruction; + return this; + } + + /** + * 获取根指令 + */ + public Instruction getRootInstruction() { + return rootInstruction; + } + + /** + * 设置超时时间 + */ + public Transaction setTimeout(long timeoutMs) { + this.timeoutMs = timeoutMs; + return this; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/command/TransactionExecutor.java b/src/main/java/com/ruoyi/device/domain/impl/machine/command/TransactionExecutor.java new file mode 100644 index 0000000..5b5112c --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/command/TransactionExecutor.java @@ -0,0 +1,313 @@ +package com.ruoyi.device.domain.impl.machine.command; + + +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.Instruction; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionResult; +import com.ruoyi.device.domain.impl.machine.mqtt.MqttCallbackRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 事务执行器(完全异步化版本) + * + * 设计说明: + * 1. 完全异步:不阻塞任何线程,所有操作都通过 CompletableFuture 链式调用 + * 2. 高并发:可以同时处理数万个命令,不会创建大量线程 + * 3. 资源高效:线程只在真正需要执行任务时才使用,不会浪费在等待上 + * + * 性能优势: + * - 传统方式:10万个命令 = 10万个阻塞线程 = 系统崩溃 + * - 异步方式:10万个命令 = 200个工作线程 + 10万个 CompletableFuture = 正常运行 + */ +@Slf4j +@Component +public class TransactionExecutor { + + private final MqttCallbackRegistry callbackRegistry; + private final Executor commandExecutor; + private final ScheduledExecutorService timeoutScheduler; + + public TransactionExecutor( + MqttCallbackRegistry callbackRegistry, + @Qualifier("commandExecutor") Executor commandExecutor) { + this.callbackRegistry = callbackRegistry; + this.commandExecutor = commandExecutor; + + // 创建一个专门用于超时检查的调度器(核心线程数较小) + this.timeoutScheduler = new ScheduledThreadPoolExecutor( + 2, + r -> { + Thread t = new Thread(r, "timeout-scheduler"); + t.setDaemon(true); + return t; + } + ); + + log.info("事务执行器初始化完成(完全异步模式)"); + } + + /** + * 执行事务(完全异步) + * + * @param transaction 事务定义 + * @param context 执行上下文 + * @return CompletableFuture,不会阻塞调用线程 + */ + public CompletableFuture executeTransaction(Transaction transaction, InstructionContext context) { + log.info("开始执行事务: transaction={}, sn={}", transaction.getName(), context.getSn()); + + long startTime = System.currentTimeMillis(); + + // 直接返回异步执行的结果,不创建新线程 + return executeInstructionTreeAsync(transaction, context, startTime, transaction.getRootInstruction()); + } + + /** + * 异步执行指令树 + * + * @param transaction 事务定义 + * @param context 执行上下文 + * @param startTime 事务开始时间 + * @param currentInstruction 当前要执行的指令 + * @return CompletableFuture + */ + private CompletableFuture executeInstructionTreeAsync( + Transaction transaction, + InstructionContext context, + long startTime, + Instruction currentInstruction) { + + // 检查根指令 + if (currentInstruction == null) { + log.error("事务没有根指令: transaction={}", transaction.getName()); + return CompletableFuture.completedFuture( + CommandResult.failure(transaction.getCommandType(), "事务没有根指令") + ); + } + + // 检查事务是否超时 + if (System.currentTimeMillis() - startTime > transaction.getTimeoutMs()) { + log.warn("事务执行超时: transaction={}, sn={}", transaction.getName(), context.getSn()); + return CompletableFuture.completedFuture( + CommandResult.timeout(transaction.getCommandType()) + ); + } + + log.debug("执行指令: instruction={}", currentInstruction.getName()); + + // 异步执行当前指令 + Instruction finalCurrentInstruction = currentInstruction; + return executeInstructionAsync(currentInstruction, context) + .thenCompose(result -> { + // 根据执行结果获取下游指令 + Instruction nextInstruction = finalCurrentInstruction.getNextInstruction(result.isSuccess()); + + if (nextInstruction != null) { + // 有下游指令,递归执行 + log.debug("根据执行结果选择下游指令: success={}, nextInstruction={}", + result.isSuccess(), nextInstruction.getName()); + return executeInstructionTreeAsync(transaction, context, startTime, nextInstruction); + } else { + // 没有下游指令,当前指令的结果就是事务的结果 + if (!result.isSuccess()) { + log.error("指令执行失败(无下游指令): instruction={}, error={}", + finalCurrentInstruction.getName(), result.getErrorMessage()); + return CompletableFuture.completedFuture( + CommandResult.failure( + transaction.getCommandType(), + result.getErrorMessage(), + finalCurrentInstruction.getName() + ) + ); + } else { + log.info("指令执行成功(无下游指令),事务完成: instruction={}, sn={}", + finalCurrentInstruction.getName(), context.getSn()); + return CompletableFuture.completedFuture( + CommandResult.success(transaction.getCommandType()) + ); + } + } + }); + } + + /** + * 异步执行单个指令 + * + * @param instruction 指令 + * @param context 执行上下文 + * @return CompletableFuture + */ + private CompletableFuture executeInstructionAsync( + Instruction instruction, + InstructionContext context) { + + log.debug("开始执行指令: instruction={}, sn={}", instruction.getName(), context.getSn()); + + // a. 判断是否可以执行 + if (!instruction.canExecute(context)) { + String error = "指令被拒绝"; + log.warn("指令不满足执行条件: instruction={}, sn={}", instruction.getName(), context.getSn()); + InstructionResult result = InstructionResult.failure(error); + instruction.onComplete(context, result); + return CompletableFuture.completedFuture(result); + } + + // b. 在线程池中执行远程调用(避免阻塞当前线程) + return CompletableFuture.supplyAsync(() -> { + try { + instruction.executeRemoteCall(context); + log.debug("远程调用已发送: instruction={}", instruction.getName()); + return true; + } catch (Exception e) { + log.error("远程调用失败: instruction={}, sn={}", instruction.getName(), context.getSn(), e); + return false; + } + }, commandExecutor).thenCompose(remoteCallSuccess -> { + if (!remoteCallSuccess) { + InstructionResult result = InstructionResult.failure("远程调用失败"); + instruction.onComplete(context, result); + return CompletableFuture.completedFuture(result); + } + + // c. 等待方法回调(异步) + CallbackConfig methodCallback = instruction.getMethodCallbackConfig(context); + if (methodCallback != null) { + // 自动设置为方法回调类型 + methodCallback.setCallbackType(CallbackConfig.CallbackType.METHOD); + + return waitForCallbackAsync(methodCallback, context) + .thenCompose(methodResult -> { + if (!methodResult.isSuccess()) { + instruction.onComplete(context, methodResult); + return CompletableFuture.completedFuture(methodResult); + } + + // d. 等待状态回调(异步) + CallbackConfig stateCallback = instruction.getStateCallbackConfig(context); + if (stateCallback != null) { + // 自动设置为状态回调类型 + stateCallback.setCallbackType(CallbackConfig.CallbackType.STATE); + + return waitForCallbackAsync(stateCallback, context) + .thenApply(stateResult -> { + instruction.onComplete(context, stateResult); + return stateResult; + }); + } + + // 没有状态回调,直接成功 + InstructionResult result = InstructionResult.success(); + instruction.onComplete(context, result); + return CompletableFuture.completedFuture(result); + }); + } + + // 没有方法回调,检查是否有状态回调 + CallbackConfig stateCallback = instruction.getStateCallbackConfig(context); + if (stateCallback != null) { + // 自动设置为状态回调类型 + stateCallback.setCallbackType(CallbackConfig.CallbackType.STATE); + + return waitForCallbackAsync(stateCallback, context) + .thenApply(stateResult -> { + instruction.onComplete(context, stateResult); + return stateResult; + }); + } + + // 没有任何回调,直接成功 + InstructionResult result = InstructionResult.success(); + instruction.onComplete(context, result); + return CompletableFuture.completedFuture(result); + }); + } + + /** + * 异步等待回调(不阻塞线程) + * + * 关键改进: + * 1. 不使用 future.get() 阻塞线程 + * 2. 使用 ScheduledExecutorService 实现超时 + * 3. 完全基于回调机制 + * + * @param callbackConfig 回调配置 + * @param context 执行上下文 + * @return CompletableFuture + */ + private CompletableFuture waitForCallbackAsync( + CallbackConfig callbackConfig, + InstructionContext context) { + + CompletableFuture future = new CompletableFuture<>(); + AtomicBoolean callbackReceived = new AtomicBoolean(false); + + // 注册回调(包含 tid/bid 过滤) + String callbackId = callbackRegistry.registerCallback( + callbackConfig.getTopic(), + messageBody -> { + // 判断消息是否匹配 + boolean matches = callbackConfig.matches(messageBody); + if (matches) { + // 匹配成功 + if (callbackReceived.compareAndSet(false, true)) { + future.complete(InstructionResult.success(messageBody)); + log.debug("收到匹配的回调消息: topic={}, type={}", + callbackConfig.getTopic(), callbackConfig.getCallbackType()); + } + } else { + // 不匹配:根据回调类型决定行为 + if (callbackConfig.getCallbackType() == CallbackConfig.CallbackType.METHOD) { + // 方法回调:不匹配就失败 + if (callbackReceived.compareAndSet(false, true)) { + future.complete(InstructionResult.failure("方法回调不匹配")); + log.warn("方法回调不匹配,指令失败: topic={}, expected={}, actual={}", + callbackConfig.getTopic(), + callbackConfig.getExpectedValue(), + messageBody); + } + } else { + // 状态回调:不匹配继续等待 + // 使用 CAS 确保只处理一次,然后重置状态 + if (callbackReceived.compareAndSet(false, true)) { + callbackReceived.set(false); // 重置状态,继续等待下一条消息 + log.debug("状态回调不匹配,继续等待: topic={}, expected={}, actual={}", + callbackConfig.getTopic(), + callbackConfig.getExpectedValue(), + messageBody); + } + } + } + }, + callbackConfig.getTimeoutMs(), + callbackConfig.getTidFieldPath(), + callbackConfig.getExpectedTid(), + callbackConfig.getBidFieldPath(), + callbackConfig.getExpectedBid() + ); + + // 设置超时(不阻塞线程) + timeoutScheduler.schedule(() -> { + // 使用 CAS 确保只处理一次 + if (callbackReceived.compareAndSet(false, true)) { + future.complete(InstructionResult.timeout()); + log.warn("等待回调超时: topic={}, timeout={}ms", + callbackConfig.getTopic(), callbackConfig.getTimeoutMs()); + } + }, callbackConfig.getTimeoutMs(), TimeUnit.MILLISECONDS); + + // 清理回调(无论成功还是超时) + return future.whenComplete((result, throwable) -> { + callbackRegistry.unregisterCallback(callbackId); + }); + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/config/ExecutorConfig.java b/src/main/java/com/ruoyi/device/domain/impl/machine/config/ExecutorConfig.java new file mode 100644 index 0000000..3651b3c --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/config/ExecutorConfig.java @@ -0,0 +1,72 @@ +package com.ruoyi.device.domain.impl.machine.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 线程池配置 + * 用于命令执行的异步任务 + */ +@Slf4j +@Configuration +public class ExecutorConfig { + + /** + * 命令执行线程池 + * + * 设计说明: + * 1. 核心线程数:CPU 核心数 * 2(适合 I/O 密集型任务) + * 2. 最大线程数:200(控制最大并发,防止线程爆炸) + * 3. 队列容量:10000(缓冲等待执行的任务) + * 4. 拒绝策略:CallerRunsPolicy(背压机制,让调用者线程执行) + * + * 性能预估: + * - 假设每个命令平均执行 10 秒 + * - 200 个线程可以同时处理 200 个命令 + * - 队列可以缓冲 10000 个命令 + * - 总容量:10200 个并发命令 + * - 吞吐量:200 / 10 = 20 个命令/秒 + */ + @Bean(name = "commandExecutor") + public Executor commandExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + // 核心线程数:根据 CPU 核心数设置 + int corePoolSize = Runtime.getRuntime().availableProcessors() * 2; + executor.setCorePoolSize(corePoolSize); + log.info("命令执行线程池核心线程数: {}", corePoolSize); + + // 最大线程数:限制最大并发,防止线程爆炸 + executor.setMaxPoolSize(200); + + // 队列容量:缓冲等待执行的任务 + executor.setQueueCapacity(10000); + + // 拒绝策略:队列满时,调用者线程执行(背压机制) + // 这样可以防止任务丢失,同时给系统施加背压 + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + + // 线程名称前缀(方便日志追踪) + executor.setThreadNamePrefix("cmd-exec-"); + + // 等待任务完成后再关闭(优雅关闭) + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + + // 允许核心线程超时(节省资源) + executor.setAllowCoreThreadTimeOut(true); + executor.setKeepAliveSeconds(60); + + executor.initialize(); + + log.info("命令执行线程池初始化完成: corePoolSize={}, maxPoolSize={}, queueCapacity={}", + corePoolSize, 200, 10000); + + return executor; + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/config/MachineFrameworkConfig.java b/src/main/java/com/ruoyi/device/domain/impl/machine/config/MachineFrameworkConfig.java new file mode 100644 index 0000000..3817587 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/config/MachineFrameworkConfig.java @@ -0,0 +1,29 @@ +package com.ruoyi.device.domain.impl.machine.config; + + +import com.ruoyi.device.domain.impl.machine.vendor.VendorRegistry; +import com.ruoyi.device.domain.impl.machine.vendor.dji.DjiVendorConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 设备框架配置类 + */ +@Slf4j +@Configuration +public class MachineFrameworkConfig { + + /** + * 自动注册所有厂家配置 + */ + @Bean + public CommandLineRunner registerVendors(VendorRegistry vendorRegistry, DjiVendorConfig djiVendorConfig) { + return args -> { + // 注册大疆厂家配置 + vendorRegistry.registerVendor(djiVendorConfig); + log.info("设备框架初始化完成,已注册厂家: {}", vendorRegistry.getAllVendorTypes()); + }; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/config/RedisConfig.java b/src/main/java/com/ruoyi/device/domain/impl/machine/config/RedisConfig.java new file mode 100644 index 0000000..6d945d8 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/config/RedisConfig.java @@ -0,0 +1,30 @@ +package com.ruoyi.device.domain.impl.machine.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +/** + * Redis 配置类 + * 用于配置 Redis Pub/Sub 相关的 Bean + */ +@Configuration +@ConditionalOnProperty(name = "machine.state.store.type", havingValue = "redis") +public class RedisConfig { + + /** + * 创建 Redis 消息监听容器(专用于机器框架的 MQTT 回调) + * 用于 Redis Pub/Sub 功能 + * + * 注意:使用特定的 Bean 名称避免与其他模块冲突 + */ + @Bean(name = "machineFrameworkRedisMessageListenerContainer") + public RedisMessageListenerContainer machineFrameworkRedisMessageListenerContainer( + RedisConnectionFactory connectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + return container; + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/AbstractInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/AbstractInstruction.java new file mode 100644 index 0000000..2776ab0 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/AbstractInstruction.java @@ -0,0 +1,57 @@ +package com.ruoyi.device.domain.impl.machine.instruction; + +/** + * 抽象指令基类,提供默认实现和下游节点管理 + */ +public abstract class AbstractInstruction implements Instruction { + + /** + * 成功后执行的下一个指令 + */ + private Instruction onSuccessInstruction; + + /** + * 失败后执行的下一个指令 + */ + private Instruction onFailureInstruction; + + + + + @Override + public Instruction getOnSuccessInstruction() { + return onSuccessInstruction; + } + + @Override + public Instruction getOnFailureInstruction() { + return onFailureInstruction; + } + + + + /** + * 设置成功后执行的指令(支持链式调用) + */ + public T onSuccess(Instruction instruction) { + this.onSuccessInstruction = instruction; + return (T) this; + } + + /** + * 设置失败后执行的指令(支持链式调用) + */ + public T onFailure(Instruction instruction) { + this.onFailureInstruction = instruction; + return (T) this; + } + + /** + * 设置无论成功失败都执行的指令(支持链式调用) + */ + public T then(Instruction instruction) { + this.onFailureInstruction = instruction; + this.onSuccessInstruction = instruction; + return (T) this; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/CallbackConfig.java b/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/CallbackConfig.java new file mode 100644 index 0000000..e3c870b --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/CallbackConfig.java @@ -0,0 +1,150 @@ +package com.ruoyi.device.domain.impl.machine.instruction; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.function.Predicate; + +/** + * 回调配置(用于方法回调和状态回调) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CallbackConfig { + + /** + * 回调类型枚举 + */ + public enum CallbackType { + /** + * 方法回调:设备对指令的直接响应 + * - 收到匹配的响应 → 成功 + * - 收到不匹配的响应 → 失败(立即) + * - 超时 → 失败 + */ + METHOD, + + /** + * 状态回调:等待设备状态变化 + * - 收到匹配的状态 → 成功 + * - 收到不匹配的状态 → 继续等待 + * - 超时 → 失败 + */ + STATE + } + + /** + * 监听的MQTT主题 + */ + private String topic; + + /** + * 字段路径(支持嵌套,如 "data.status") + */ + private String fieldPath; + + /** + * 期望的字段值 + */ + private Object expectedValue; + + /** + * 自定义判断逻辑(如果设置,则优先使用此逻辑) + */ + private Predicate customPredicate; + + /** + * 超时时间(毫秒) + */ + @Builder.Default + private long timeoutMs = 10000; + + /** + * 事务ID字段路径(用于匹配回调消息,如 "tid") + */ + private String tidFieldPath; + + /** + * 业务ID字段路径(用于匹配回调消息,如 "bid") + */ + private String bidFieldPath; + + /** + * 期望的事务ID值(从InstructionContext中获取) + */ + private String expectedTid; + + /** + * 期望的业务ID值(从InstructionContext中获取) + */ + private String expectedBid; + + /** + * 回调类型(由框架自动设置,不需要手动指定) + * - getMethodCallbackConfig() 返回的配置会被设置为 METHOD + * - getStateCallbackConfig() 返回的配置会被设置为 STATE + */ + private CallbackType callbackType; + + /** + * 判断消息是否匹配 + * 注意:tid/bid 的匹配已经在 MqttCallbackRegistry 注册层完成,这里只检查业务字段 + */ + public boolean matches(Object messageBody) { + if (customPredicate != null) { + return customPredicate.test(messageBody); + } + + // 检查业务字段是否匹配 + Object fieldValue = extractFieldValue(messageBody, fieldPath); + return expectedValue == null || expectedValue.equals(fieldValue); + } + + /** + * 从消息体中提取字段值 + */ + private Object extractFieldValue(Object messageBody, String path) { + if (messageBody == null || path == null) { + return null; + } + + // 如果 messageBody 是字符串,尝试解析为 JSON + Object current = messageBody; + if (messageBody instanceof String) { + try { + ObjectMapper objectMapper = new ObjectMapper(); + current = objectMapper.readValue((String) messageBody, Object.class); + } catch (Exception e) { + // 解析失败,返回 null + return null; + } + } + + String[] parts = path.split("\\."); + + for (String part : parts) { + if (current == null) { + return null; + } + + if (current instanceof java.util.Map) { + current = ((java.util.Map) current).get(part); + } else { + try { + java.lang.reflect.Field field = current.getClass().getDeclaredField(part); + field.setAccessible(true); + current = field.get(current); + } catch (Exception e) { + return null; + } + } + } + + return current; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/Instruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/Instruction.java new file mode 100644 index 0000000..e681f09 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/Instruction.java @@ -0,0 +1,87 @@ +package com.ruoyi.device.domain.impl.machine.instruction; + +/** + * 指令接口 + * 一个指令包含四个部分: + * a. 判断是否可以执行该指令 + * b. 执行远程调用(如MQTT发送) + * c. 等待方法回调,并判断该方法是否成功执行 + * d. 等待状态回调,并判断结果是否OK + */ +public interface Instruction { + /** + * 获取指令名称 + */ + String getName(); + + /** + * a. 判断是否可以执行该指令 + */ + default boolean canExecute(InstructionContext context){ + return true; + } + + /** + * b. 执行远程调用(如MQTT发送) + */ + default void executeRemoteCall(InstructionContext context) throws Exception{ + } + + /** + * c. 获取方法回调配置(可选) + * 返回null表示不需要方法回调 + */ + CallbackConfig getMethodCallbackConfig(InstructionContext context); + + /** + * d. 获取状态回调配置(可选) + * 返回null表示不需要状态回调 + */ + CallbackConfig getStateCallbackConfig(InstructionContext context); + + /** + * 获取指令超时时间(毫秒) + */ + default long getTimeoutMs() { + return 1000; // 默认10秒 + } + + /** + * 指令执行完成回调(无论成功失败都会调用) + */ + default void onComplete(InstructionContext context, InstructionResult result) { + // 默认空实现 + } + + /** + * 获取成功后执行的下一个指令 + */ + default Instruction getOnSuccessInstruction() { + return null; + } + + /** + * 获取失败后执行的下一个指令 + */ + default Instruction getOnFailureInstruction() { + return null; + } + + + + /** + * 根据执行结果获取下一个指令 + * + * @param success 是否成功 + * @return 下一个指令,如果没有则返回null + */ + default Instruction getNextInstruction(boolean success) { + + // 根据成功失败返回对应的指令 + if (success) { + return getOnSuccessInstruction(); + } else { + return getOnFailureInstruction(); + } + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/InstructionContext.java b/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/InstructionContext.java new file mode 100644 index 0000000..b610b4f --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/InstructionContext.java @@ -0,0 +1,85 @@ +package com.ruoyi.device.domain.impl.machine.instruction; + + +import com.ruoyi.device.domain.impl.machine.mqtt.MqttClient; +import lombok.Data; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * 指令执行上下文 + */ +@Data +public class InstructionContext { + /** + * 设备SN号 + */ + private String sn; + + /** + * 厂家类型 + */ + private String vendorType; + + /** + * MQTT客户端(用于发送MQTT消息) + */ + private MqttClient mqttClient; + + /** + * 上下文数据(用于在指令间传递数据) + */ + private Map contextData = new HashMap<>(); + + /** + * 命令参数 + */ + private Map commandParams = new HashMap<>(); + + /** + * 事务ID(Transaction ID)- 用于匹配回调消息 + * 在命令执行阶段生成,用于标识本次指令执行 + */ + private String tid; + + /** + * 业务ID(Business ID)- 用于匹配回调消息 + * 在命令执行阶段生成,用于标识本次业务操作 + */ + private String bid; + + public InstructionContext(String sn, String vendorType) { + this.sn = sn; + this.vendorType = vendorType; + // 自动生成 tid 和 bid + this.tid = UUID.randomUUID().toString(); + this.bid = UUID.randomUUID().toString(); + } + + public InstructionContext(String sn, String vendorType, MqttClient mqttClient) { + this.sn = sn; + this.vendorType = vendorType; + this.mqttClient = mqttClient; + // 自动生成 tid 和 bid + this.tid = UUID.randomUUID().toString(); + this.bid = UUID.randomUUID().toString(); + } + + public void putContextData(String key, Object value) { + contextData.put(key, value); + } + + public Object getContextData(String key) { + return contextData.get(key); + } + + public void putCommandParam(String key, Object value) { + commandParams.put(key, value); + } + + public Object getCommandParam(String key) { + return commandParams.get(key); + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/InstructionResult.java b/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/InstructionResult.java new file mode 100644 index 0000000..323adf2 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/instruction/InstructionResult.java @@ -0,0 +1,44 @@ +package com.ruoyi.device.domain.impl.machine.instruction; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 指令执行结果 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class InstructionResult { + /** + * 是否成功 + */ + private boolean success; + + /** + * 错误信息 + */ + private String errorMessage; + + /** + * 结果数据 + */ + private Object data; + + public static InstructionResult success() { + return new InstructionResult(true, null, null); + } + + public static InstructionResult success(Object data) { + return new InstructionResult(true, null, data); + } + + public static InstructionResult failure(String errorMessage) { + return new InstructionResult(false, errorMessage, null); + } + + public static InstructionResult timeout() { + return new InstructionResult(false, "指令执行超时", null); + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttCallbackHandler.java b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttCallbackHandler.java new file mode 100644 index 0000000..36559a3 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttCallbackHandler.java @@ -0,0 +1,45 @@ +package com.ruoyi.device.domain.impl.machine.mqtt; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.function.Consumer; + +/** + * MQTT回调处理器 + */ +@Data +@AllArgsConstructor +public class MqttCallbackHandler { + /** + * 回调ID(用于取消注册) + */ + private String callbackId; + + /** + * 监听的主题 + */ + private String topic; + + /** + * 消息处理器 + */ + private Consumer messageHandler; + + /** + * 超时时间(毫秒) + */ + private long timeoutMs; + + /** + * 注册时间 + */ + private long registerTime; + + /** + * 是否已超时 + */ + public boolean isTimeout() { + return System.currentTimeMillis() - registerTime > timeoutMs; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttCallbackRegistry.java b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttCallbackRegistry.java new file mode 100644 index 0000000..7cd545c --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttCallbackRegistry.java @@ -0,0 +1,361 @@ +package com.ruoyi.device.domain.impl.machine.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.ruoyi.device.domain.impl.machine.mqtt.store.MqttCallbackInfo; +import com.ruoyi.device.domain.impl.machine.mqtt.store.MqttCallbackStore; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + + +import java.net.InetAddress; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * MQTT回调注册中心 + * 用于注册和管理MQTT消息的回调处理器,他的 handleMessage 需要被真实的MQTT回调去调用 + * + * 架构说明: + * - 回调元数据存储在 MqttCallbackStore 中(支持内存/Redis) + * - Consumer 回调函数存储在本地内存中(无法序列化) + * - 多节点部署时,通过 Redis Pub/Sub 在节点间传递消息 + */ +@Slf4j +@Component +public class MqttCallbackRegistry { + + /** + * 回调存储层(支持内存、Redis等多种实现) + */ + private final MqttCallbackStore callbackStore; + + /** + * 回调ID -> 本地消息处理器(Consumer 无法序列化,只能存储在本地) + */ + private final Map> localHandlers = new ConcurrentHashMap<>(); + + /** + * 当前节点ID(用于 Redis Pub/Sub 路由) + */ + private String nodeId; + + /** + * ObjectMapper 用于序列化消息 + */ + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${machine.node.id:#{null}}") + private String configuredNodeId; + + public MqttCallbackRegistry(MqttCallbackStore callbackStore) { + this.callbackStore = callbackStore; + } + + @PostConstruct + public void init() { + // 初始化节点ID + if (configuredNodeId != null && !configuredNodeId.isEmpty()) { + nodeId = configuredNodeId; + } else { + // 自动生成节点ID:主机名 + UUID + try { + String hostname = InetAddress.getLocalHost().getHostName(); + nodeId = hostname + "-" + UUID.randomUUID().toString().substring(0, 8); + } catch (Exception e) { + nodeId = "node-" + UUID.randomUUID().toString().substring(0, 8); + } + } + + // 订阅当前节点的消息(用于 Redis Pub/Sub) + callbackStore.subscribeNodeMessages(nodeId, this::handleNodeMessage); + + log.info("MQTT回调注册中心初始化完成,节点ID: {}, 存储实现: {}", + nodeId, callbackStore.getClass().getSimpleName()); + } + + /** + * 注册回调 + * + * @param topic 监听的主题 + * @param messageHandler 消息处理器 + * @param timeoutMs 超时时间(毫秒) + * @return 回调ID(用于取消注册) + */ + public String registerCallback(String topic, Consumer messageHandler, long timeoutMs) { + return registerCallback(topic, messageHandler, timeoutMs, null, null, null, null); + } + + /** + * 注册回调(支持 tid/bid 过滤) + * + * @param topic 监听的主题 + * @param messageHandler 消息处理器 + * @param timeoutMs 超时时间(毫秒) + * @param tidFieldPath tid 字段路径(如 "tid") + * @param expectedTid 期望的 tid 值 + * @param bidFieldPath bid 字段路径(如 "bid") + * @param expectedBid 期望的 bid 值 + * @return 回调ID(用于取消注册) + */ + public String registerCallback(String topic, Consumer messageHandler, long timeoutMs, + String tidFieldPath, String expectedTid, + String bidFieldPath, String expectedBid) { + String callbackId = UUID.randomUUID().toString(); + + // 1. 创建回调信息并存储到存储层 + MqttCallbackInfo callbackInfo = MqttCallbackInfo.builder() + .callbackId(callbackId) + .topic(topic) + .timeoutMs(timeoutMs) + .registerTime(System.currentTimeMillis()) + .nodeId(nodeId) + .tidFieldPath(tidFieldPath) + .expectedTid(expectedTid) + .bidFieldPath(bidFieldPath) + .expectedBid(expectedBid) + .build(); + + callbackStore.registerCallback(callbackInfo); + + // 2. 将 Consumer 存储到本地内存 + localHandlers.put(callbackId, messageHandler); + + log.debug("注册MQTT回调: callbackId={}, topic={}, timeoutMs={}, nodeId={}, tid={}, bid={}", + callbackId, topic, timeoutMs, nodeId, expectedTid, expectedBid); + return callbackId; + } + + /** + * 取消注册回调 + * + * @param callbackId 回调ID + */ + public void unregisterCallback(String callbackId) { + // 1. 从存储层删除回调信息 + callbackStore.unregisterCallback(callbackId); + + // 2. 从本地内存删除 Consumer + localHandlers.remove(callbackId); + + log.debug("取消注册MQTT回调: callbackId={}", callbackId); + } + + /** + * 处理接收到的MQTT消息(由真实的 MQTT 客户端调用) + * + * @param topic 主题 + * @param messageBody 消息体 + */ + public void handleMessage(String topic, Object messageBody) { + // 1. 从存储层获取所有等待该 topic 的回调信息 + List callbacks = callbackStore.getCallbacksByTopic(topic); + if (callbacks.isEmpty()) { + return; + } + + log.debug("处理MQTT消息: topic={}, callbackCount={}", topic, callbacks.size()); + + // 2. 序列化消息体(用于跨节点传递) + String messageBodyJson; + try { + messageBodyJson = objectMapper.writeValueAsString(messageBody); + } catch (Exception e) { + log.error("序列化消息体失败: topic={}", topic, e); + return; + } + + // 3. 处理每个回调 + for (MqttCallbackInfo callbackInfo : callbacks) { + try { + // 检查是否超时 + if (callbackInfo.isTimeout()) { + log.warn("MQTT回调已超时: callbackId={}, topic={}", + callbackInfo.getCallbackId(), topic); + unregisterCallback(callbackInfo.getCallbackId()); + continue; + } + + // 检查 tid/bid 是否匹配(如果配置了) + if (!matchesTidBid(callbackInfo, messageBody)) { + log.debug("MQTT消息 tid/bid 不匹配,跳过回调: callbackId={}, topic={}", + callbackInfo.getCallbackId(), topic); + continue; + } + + // 判断回调是在本节点还是其他节点 + if (nodeId.equals(callbackInfo.getNodeId())) { + // 本节点的回调,直接执行 + executeLocalCallback(callbackInfo.getCallbackId(), messageBody); + } else { + // 其他节点的回调,通过 Redis Pub/Sub 转发 + callbackStore.publishMessageToNode( + callbackInfo.getNodeId(), + callbackInfo.getCallbackId(), + messageBodyJson + ); + log.debug("转发消息到节点: nodeId={}, callbackId={}", + callbackInfo.getNodeId(), callbackInfo.getCallbackId()); + } + } catch (Exception e) { + log.error("处理MQTT回调失败: callbackId={}, topic={}", + callbackInfo.getCallbackId(), topic, e); + } + } + } + + /** + * 检查消息的 tid/bid 是否匹配 + * + * @param callbackInfo 回调信息 + * @param messageBody 消息体 + * @return true 如果匹配或未配置 tid/bid,false 如果不匹配 + */ + private boolean matchesTidBid(MqttCallbackInfo callbackInfo, Object messageBody) { + // 1. 检查 tid 是否匹配(如果配置了) + if (callbackInfo.getTidFieldPath() != null && callbackInfo.getExpectedTid() != null) { + Object tidValue = extractFieldValue(messageBody, callbackInfo.getTidFieldPath()); + if (!callbackInfo.getExpectedTid().equals(tidValue)) { + log.debug("tid 不匹配: expected={}, actual={}", callbackInfo.getExpectedTid(), tidValue); + return false; // tid 不匹配 + } + } + + // 2. 检查 bid 是否匹配(如果配置了) + if (callbackInfo.getBidFieldPath() != null && callbackInfo.getExpectedBid() != null) { + Object bidValue = extractFieldValue(messageBody, callbackInfo.getBidFieldPath()); + if (!callbackInfo.getExpectedBid().equals(bidValue)) { + log.debug("bid 不匹配: expected={}, actual={}", callbackInfo.getExpectedBid(), bidValue); + return false; // bid 不匹配 + } + } + + // 3. tid/bid 都匹配或未配置 + return true; + } + + /** + * 从消息体中提取字段值 + * + * @param messageBody 消息体 + * @param fieldPath 字段路径(支持嵌套,如 "data.status") + * @return 字段值 + */ + private Object extractFieldValue(Object messageBody, String fieldPath) { + if (messageBody == null || fieldPath == null) { + return null; + } + + // 如果 messageBody 是字符串,尝试解析为 JSON + Object current = messageBody; + if (messageBody instanceof String) { + try { + current = objectMapper.readValue((String) messageBody, Object.class); + } catch (Exception e) { + log.warn("解析消息体失败: {}", messageBody); + return null; + } + } + + String[] parts = fieldPath.split("\\."); + + for (String part : parts) { + if (current == null) { + return null; + } + + if (current instanceof Map) { + current = ((Map) current).get(part); + } else { + try { + java.lang.reflect.Field field = current.getClass().getDeclaredField(part); + field.setAccessible(true); + current = field.get(current); + } catch (Exception e) { + log.debug("提取字段失败: fieldPath={}, part={}", fieldPath, part); + return null; + } + } + } + + return current; + } + + /** + * 执行本地回调 + * + * @param callbackId 回调ID + * @param messageBody 消息体 + */ + private void executeLocalCallback(String callbackId, Object messageBody) { + Consumer handler = localHandlers.get(callbackId); + if (handler != null) { + try { + handler.accept(messageBody); + log.debug("执行本地回调成功: callbackId={}", callbackId); + } catch (Exception e) { + log.error("执行本地回调失败: callbackId={}", callbackId, e); + } + } else { + log.warn("本地回调处理器不存在: callbackId={}", callbackId); + } + } + + /** + * 处理从 Redis Pub/Sub 接收到的节点消息 + * + * @param callbackId 回调ID + * @param messageBodyJson 消息体(JSON 字符串) + */ + private void handleNodeMessage(String callbackId, String messageBodyJson) { + try { + // 反序列化消息体 + Object messageBody = objectMapper.readValue(messageBodyJson, Object.class); + + // 执行本地回调 + executeLocalCallback(callbackId, messageBody); + } catch (Exception e) { + log.error("处理节点消息失败: callbackId={}", callbackId, e); + } + } + + /** + * 清理超时的回调 + */ + public void cleanupTimeoutCallbacks() { + List allCallbacks = callbackStore.getAllCallbacks(); + for (MqttCallbackInfo callbackInfo : allCallbacks) { + if (callbackInfo.isTimeout()) { + log.warn("清理超时的MQTT回调: callbackId={}, topic={}", + callbackInfo.getCallbackId(), callbackInfo.getTopic()); + unregisterCallback(callbackInfo.getCallbackId()); + } + } + } + + /** + * 获取当前注册的回调数量 + */ + public int getCallbackCount() { + return localHandlers.size(); + } + + /** + * 清理所有回调(仅用于测试环境) + * 警告:此方法会清理所有回调,包括未超时的,仅应在测试环境中使用 + */ + public void cleanupAllCallbacks() { + List allCallbacks = callbackStore.getAllCallbacks(); + for (MqttCallbackInfo callbackInfo : allCallbacks) { + log.debug("清理MQTT回调: callbackId={}, topic={}", + callbackInfo.getCallbackId(), callbackInfo.getTopic()); + unregisterCallback(callbackInfo.getCallbackId()); + } + log.info("已清理所有MQTT回调,共{}个", allCallbacks.size()); + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttClient.java b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttClient.java new file mode 100644 index 0000000..4a790c3 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/MqttClient.java @@ -0,0 +1,14 @@ +package com.ruoyi.device.domain.impl.machine.mqtt; + +import org.springframework.stereotype.Component; + +/** + * MQTT客户端 + */ +@Component +public class MqttClient { + + public void sendMessage(String topic, String message) { + + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/InMemoryMqttCallbackStore.java b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/InMemoryMqttCallbackStore.java new file mode 100644 index 0000000..4c04a64 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/InMemoryMqttCallbackStore.java @@ -0,0 +1,85 @@ +package com.ruoyi.device.domain.impl.machine.mqtt.store; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; + +/** + * 基于内存的 MQTT 回调存储实现 + * 适用于单节点部署或开发测试环境 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "machine.state.store.type", havingValue = "memory", matchIfMissing = true) +public class InMemoryMqttCallbackStore implements MqttCallbackStore { + + /** + * 主题 -> 回调信息列表 + */ + private final Map> topicCallbacks = new ConcurrentHashMap<>(); + + /** + * 回调ID -> 回调信息 + */ + private final Map callbackMap = new ConcurrentHashMap<>(); + + @Override + public void registerCallback(MqttCallbackInfo callbackInfo) { + topicCallbacks.computeIfAbsent(callbackInfo.getTopic(), k -> new CopyOnWriteArrayList<>()) + .add(callbackInfo); + callbackMap.put(callbackInfo.getCallbackId(), callbackInfo); + log.debug("注册MQTT回调到内存: callbackId={}, topic={}", + callbackInfo.getCallbackId(), callbackInfo.getTopic()); + } + + @Override + public void unregisterCallback(String callbackId) { + MqttCallbackInfo callbackInfo = callbackMap.remove(callbackId); + if (callbackInfo != null) { + CopyOnWriteArrayList callbacks = topicCallbacks.get(callbackInfo.getTopic()); + if (callbacks != null) { + callbacks.remove(callbackInfo); + if (callbacks.isEmpty()) { + topicCallbacks.remove(callbackInfo.getTopic()); + } + } + log.debug("从内存中取消注册MQTT回调: callbackId={}, topic={}", + callbackId, callbackInfo.getTopic()); + } + } + + @Override + public List getCallbacksByTopic(String topic) { + CopyOnWriteArrayList callbacks = topicCallbacks.get(topic); + return callbacks != null ? new ArrayList<>(callbacks) : new ArrayList<>(); + } + + @Override + public MqttCallbackInfo getCallbackById(String callbackId) { + return callbackMap.get(callbackId); + } + + @Override + public List getAllCallbacks() { + return new ArrayList<>(callbackMap.values()); + } + + @Override + public void publishMessageToNode(String nodeId, String callbackId, String messageBody) { + // 内存实现中,不需要跨节点通信,此方法为空操作 + log.trace("内存实现不需要发布消息到节点: nodeId={}, callbackId={}", nodeId, callbackId); + } + + @Override + public void subscribeNodeMessages(String nodeId, NodeMessageListener messageListener) { + // 内存实现中,不需要订阅节点消息,此方法为空操作 + log.trace("内存实现不需要订阅节点消息: nodeId={}", nodeId); + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/MqttCallbackInfo.java b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/MqttCallbackInfo.java new file mode 100644 index 0000000..d0107e0 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/MqttCallbackInfo.java @@ -0,0 +1,72 @@ +package com.ruoyi.device.domain.impl.machine.mqtt.store; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * MQTT 回调信息(可序列化到 Redis) + * 不包含 Consumer,只包含回调的元数据 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MqttCallbackInfo implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 回调ID(用于取消注册) + */ + private String callbackId; + + /** + * 监听的主题 + */ + private String topic; + + /** + * 超时时间(毫秒) + */ + private long timeoutMs; + + /** + * 注册时间 + */ + private long registerTime; + + /** + * 注册该回调的节点ID(用于 Redis Pub/Sub 路由) + */ + private String nodeId; + + /** + * 事务ID字段路径(用于匹配回调消息,如 "tid") + */ + private String tidFieldPath; + + /** + * 期望的事务ID值 + */ + private String expectedTid; + + /** + * 业务ID字段路径(用于匹配回调消息,如 "bid") + */ + private String bidFieldPath; + + /** + * 期望的业务ID值 + */ + private String expectedBid; + + /** + * 是否已超时 + */ + public boolean isTimeout() { + return System.currentTimeMillis() - registerTime > timeoutMs; + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/MqttCallbackStore.java b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/MqttCallbackStore.java new file mode 100644 index 0000000..c603f26 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/MqttCallbackStore.java @@ -0,0 +1,79 @@ +package com.ruoyi.device.domain.impl.machine.mqtt.store; + +import java.util.List; + +/** + * MQTT 回调存储接口 + * 提供回调信息的存储和获取抽象,支持多种实现(内存、Redis等) + */ +public interface MqttCallbackStore { + + /** + * 注册回调信息 + * + * @param callbackInfo 回调信息 + */ + void registerCallback(MqttCallbackInfo callbackInfo); + + /** + * 取消注册回调 + * + * @param callbackId 回调ID + */ + void unregisterCallback(String callbackId); + + /** + * 根据 topic 获取所有等待该 topic 的回调信息 + * + * @param topic MQTT 主题 + * @return 回调信息列表 + */ + List getCallbacksByTopic(String topic); + + /** + * 根据 callbackId 获取回调信息 + * + * @param callbackId 回调ID + * @return 回调信息,如果不存在返回 null + */ + MqttCallbackInfo getCallbackById(String callbackId); + + /** + * 获取所有回调信息(用于清理超时回调) + * + * @return 所有回调信息列表 + */ + List getAllCallbacks(); + + /** + * 发布消息到指定节点(用于 Redis Pub/Sub) + * 在内存实现中,此方法为空操作 + * + * @param nodeId 节点ID + * @param callbackId 回调ID + * @param messageBody 消息体(JSON 字符串) + */ + void publishMessageToNode(String nodeId, String callbackId, String messageBody); + + /** + * 订阅当前节点的消息(用于 Redis Pub/Sub) + * 在内存实现中,此方法为空操作 + * + * @param nodeId 当前节点ID + * @param messageListener 消息监听器 + */ + void subscribeNodeMessages(String nodeId, NodeMessageListener messageListener); + + /** + * 节点消息监听器 + */ + interface NodeMessageListener { + /** + * 处理接收到的消息 + * + * @param callbackId 回调ID + * @param messageBody 消息体(JSON 字符串) + */ + void onMessage(String callbackId, String messageBody); + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/RedisMqttCallbackStore.java b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/RedisMqttCallbackStore.java new file mode 100644 index 0000000..293edd6 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/mqtt/store/RedisMqttCallbackStore.java @@ -0,0 +1,267 @@ +package com.ruoyi.device.domain.impl.machine.mqtt.store; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +/** + * 基于 Redis 的 MQTT 回调存储实现 + * 适用于多节点部署的生产环境 + * + * 架构说明: + * 1. 回调信息存储在 Redis Hash 中:mqtt:callback:{callbackId} -> MqttCallbackInfo (JSON) + * 2. Topic 索引存储在 Redis Set 中:mqtt:topic:{topic} -> Set + * 3. 使用 Redis Pub/Sub 在节点间传递 MQTT 消息:mqtt:node:{nodeId} -> {callbackId, messageBody} + * + * 工作流程: + * - 节点A 注册回调 -> 存储到 Redis + * - 节点B 收到 MQTT 消息 -> 从 Redis 查询等待该 topic 的回调 -> 通过 Pub/Sub 发送到对应节点 + * - 节点A 收到 Pub/Sub 消息 -> 执行本地的 Consumer 回调 + * + * 使用方式: + * 1. 在 application.properties 中配置:machine.state.store.type=redis + * 2. 配置 Redis 连接信息 + * 3. 实现 Redis 相关的序列化和 Pub/Sub 逻辑 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "machine.state.store.type", havingValue = "redis") +public class RedisMqttCallbackStore implements MqttCallbackStore { + + private final StringRedisTemplate stringRedisTemplate; + private final RedisMessageListenerContainer redisMessageListenerContainer; + private final ObjectMapper objectMapper; + + // Redis key 前缀 + private static final String CALLBACK_KEY_PREFIX = "mqtt:callback:"; + private static final String TOPIC_INDEX_PREFIX = "mqtt:topic:"; + private static final String NODE_CHANNEL_PREFIX = "mqtt:node:"; + + // 配置回调信息的过期时间 + private static final long EXPIRE_SECONDS = 3600; // 1小时 + + /** + * Lua 脚本:注册 MQTT 回调 + * 使用 Lua 脚本保证原子性,避免竞态条件 + * + * KEYS[1]: Topic 索引 key (mqtt:topic:{topic}) + * KEYS[2]: 回调信息 key (mqtt:callback:{callbackId}) + * ARGV[1]: callbackId + * ARGV[2]: 过期时间(秒) + * ARGV[3]: 回调信息 JSON + * + * 返回值: 1 表示成功 + */ + private static final String REGISTER_CALLBACK_SCRIPT = + "redis.call('SADD', KEYS[1], ARGV[1]) " + + "redis.call('EXPIRE', KEYS[1], ARGV[2]) " + + "redis.call('SETEX', KEYS[2], ARGV[2], ARGV[3]) " + + "return 1"; + + /** + * Lua 脚本:取消注册 MQTT 回调 + * 使用 Lua 脚本保证原子性 + * + * KEYS[1]: Topic 索引 key (mqtt:topic:{topic}) + * KEYS[2]: 回调信息 key (mqtt:callback:{callbackId}) + * ARGV[1]: callbackId + * + * 返回值: 1 表示成功 + */ + private static final String UNREGISTER_CALLBACK_SCRIPT = + "redis.call('SREM', KEYS[1], ARGV[1]) " + + "redis.call('DEL', KEYS[2]) " + + "return 1"; + + public RedisMqttCallbackStore( + StringRedisTemplate stringRedisTemplate, + @Qualifier("machineFrameworkRedisMessageListenerContainer") RedisMessageListenerContainer redisMessageListenerContainer, + ObjectMapper objectMapper) { + this.stringRedisTemplate = stringRedisTemplate; + this.redisMessageListenerContainer = redisMessageListenerContainer; + this.objectMapper = objectMapper; + log.info("使用 Redis MQTT 回调存储实现"); + } + + @Override + public void registerCallback(MqttCallbackInfo callbackInfo) { + try { + // 1. 序列化回调信息为 JSON + String json = objectMapper.writeValueAsString(callbackInfo); + + // 2. 准备 Redis key + String topicKey = TOPIC_INDEX_PREFIX + callbackInfo.getTopic(); + String callbackKey = CALLBACK_KEY_PREFIX + callbackInfo.getCallbackId(); + + // 3. 使用 Lua 脚本原子性地注册回调 + // 先添加到 Topic 索引,再存储回调信息,避免竞态条件 + stringRedisTemplate.execute( + new DefaultRedisScript<>(REGISTER_CALLBACK_SCRIPT, Long.class), + Arrays.asList(topicKey, callbackKey), + callbackInfo.getCallbackId(), + String.valueOf(EXPIRE_SECONDS), + json + ); + + log.debug("注册MQTT回调到Redis: callbackId={}, topic={}", + callbackInfo.getCallbackId(), callbackInfo.getTopic()); + + } catch (JsonProcessingException e) { + log.error("序列化回调信息失败: callbackId={}, topic={}", + callbackInfo.getCallbackId(), callbackInfo.getTopic(), e); + throw new RuntimeException("注册MQTT回调失败: 序列化错误", e); + } catch (Exception e) { + log.error("注册MQTT回调到Redis失败: callbackId={}, topic={}", + callbackInfo.getCallbackId(), callbackInfo.getTopic(), e); + // 不抛出异常,让上层通过超时机制处理 + // 这样可以避免因为 Redis 临时故障导致整个命令执行失败 + } + } + + @Override + public void unregisterCallback(String callbackId) { + try { + // 1. 获取回调信息(需要知道 topic 才能删除索引) + MqttCallbackInfo callbackInfo = getCallbackById(callbackId); + if (callbackInfo == null) { + log.debug("回调信息不存在,无需取消注册: callbackId={}", callbackId); + return; + } + + // 2. 准备 Redis key + String topicKey = TOPIC_INDEX_PREFIX + callbackInfo.getTopic(); + String callbackKey = CALLBACK_KEY_PREFIX + callbackId; + + // 3. 使用 Lua 脚本原子性地取消注册回调 + stringRedisTemplate.execute( + new DefaultRedisScript<>(UNREGISTER_CALLBACK_SCRIPT, Long.class), + Arrays.asList(topicKey, callbackKey), + callbackId + ); + + log.debug("从Redis中取消注册MQTT回调: callbackId={}, topic={}", + callbackId, callbackInfo.getTopic()); + + } catch (Exception e) { + log.error("从Redis中取消注册MQTT回调失败: callbackId={}", callbackId, e); + // 不抛出异常,取消注册失败不影响主流程 + // 回调会因为 TTL 自动过期 + } + } + + @Override + public List getCallbacksByTopic(String topic) { + // 1. 从 Topic 索引获取所有 callbackId + String topicKey = TOPIC_INDEX_PREFIX + topic; + Set callbackIds = stringRedisTemplate.opsForSet().members(topicKey); + if (callbackIds == null || callbackIds.isEmpty()) { + return new ArrayList<>(); + } + + // 2. 批量获取回调信息 + List callbacks = new ArrayList<>(); + for (String callbackId : callbackIds) { + MqttCallbackInfo callbackInfo = getCallbackById(callbackId); + if (callbackInfo != null) { + callbacks.add(callbackInfo); + } + } + return callbacks; + } + + @Override + public MqttCallbackInfo getCallbackById(String callbackId) { + String callbackKey = CALLBACK_KEY_PREFIX + callbackId; + String json = stringRedisTemplate.opsForValue().get(callbackKey); + if (json == null) { + return null; + } + + try { + return objectMapper.readValue(json, MqttCallbackInfo.class); + } catch (JsonProcessingException e) { + log.error("反序列化回调信息失败: callbackId={}", callbackId, e); + return null; + } + } + + @Override + public List getAllCallbacks() { + // 1. 扫描所有 mqtt:callback:* 的 key + Set keys = stringRedisTemplate.keys(CALLBACK_KEY_PREFIX + "*"); + if (keys == null || keys.isEmpty()) { + return new ArrayList<>(); + } + + // 2. 批量获取回调信息 + List callbacks = new ArrayList<>(); + for (String key : keys) { + String callbackId = key.substring(CALLBACK_KEY_PREFIX.length()); + MqttCallbackInfo callbackInfo = getCallbackById(callbackId); + if (callbackInfo != null) { + callbacks.add(callbackInfo); + } + } + return callbacks; + } + + @Override + public void publishMessageToNode(String nodeId, String callbackId, String messageBody) { + try { + // 1. 构造消息体 + Map message = new HashMap<>(); + message.put("callbackId", callbackId); + message.put("messageBody", messageBody); + + // 2. 序列化消息 + String json = objectMapper.writeValueAsString(message); + + // 3. 发布到节点频道 + String channel = NODE_CHANNEL_PREFIX + nodeId; + stringRedisTemplate.convertAndSend(channel, json); + + log.debug("发布消息到节点: nodeId={}, callbackId={}, channel={}", + nodeId, callbackId, channel); + } catch (JsonProcessingException e) { + log.error("序列化节点消息失败: nodeId={}, callbackId={}", nodeId, callbackId, e); + } + } + + @Override + public void subscribeNodeMessages(String nodeId, NodeMessageListener messageListener) { + // 1. 创建消息监听器 + MessageListener redisMessageListener = (message, pattern) -> { + try { + String json = new String(message.getBody()); + Map data = objectMapper.readValue(json, + new TypeReference>() {}); + + String callbackId = data.get("callbackId"); + String messageBody = data.get("messageBody"); + + messageListener.onMessage(callbackId, messageBody); + } catch (Exception e) { + log.error("处理Redis Pub/Sub消息失败", e); + } + }; + + // 2. 订阅节点频道 + String channel = NODE_CHANNEL_PREFIX + nodeId; + redisMessageListenerContainer.addMessageListener(redisMessageListener, new ChannelTopic(channel)); + + log.info("订阅节点消息: nodeId={}, channel={}", nodeId, channel); + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/readme.txt b/src/main/java/com/ruoyi/device/domain/impl/machine/readme.txt new file mode 100644 index 0000000..4042f80 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/readme.txt @@ -0,0 +1,218 @@ + 生产需要实现 + MysqlSnVendorMappingRepository 这个类 + MQTT回调的地方需要转发到:MqttCallbackRegistry 这个类 + 需要实现 MqttClient 这边消息发送的逻辑 + 需要配置 sn.repository.type=mysql + + + 单节点部署(默认) + + # 使用内存存储(默认配置) + machine.state.store.type=memory + + # 以下配置生产需要修改为mysql,同时实现 MysqlSnVendorMappingRepository 这个类 + sn.repository.type=memory + + + 多节点部署 + # 切换到 Redis 存储 + machine.state.store.type=redis + # 配置节点ID(可选,不配置会自动生成) + machine.node.id=node-1 + #本地启动redis + #docker run --name some-redis -d -p 6379:6379 redi + # Redis 配置 + spring.redis.host=localhost + spring.redis.port=6379 + spring.redis.password=your-password + + +┌─────────────────────────────────────────────────────────────────┐ + │ 步骤1: 节点A 执行命令并注册回调 │ + └─────────────────────────────────────────────────────────────────┘ + 节点A: executeCommand(TAKE_OFF) + ↓ + 节点A: registerCallback(topic="dji/SN9527/response", ...) + ↓ + 节点A: MqttCallbackStore.registerCallback() + ↓ + Redis: 存储回调信息(使用两个 Key 的原因:性能优化) + + 【Key 1】回调详细信息(Hash 结构) + - Key: mqtt:callback:{callbackId} + - Value: {callbackId, topic, nodeId="nodeA", timeoutMs, registerTime, ...} + - 作用: 存储单个回调的完整信息 + - 查询: O(1) 时间复杂度,通过 callbackId 直接获取 + + 【Key 2】Topic 索引(Set 结构) + - Key: mqtt:topic:dji/SN9527/response + - Value: Set // 例如: ["abc-123", "def-456", "ghi-789"] + - 作用: 快速查询等待某个 topic 的所有回调 + - 查询: O(1) 时间复杂度,直接获取 callbackId 列表 + + 【为什么需要两个 Key?】 + 如果只用一个 Key 存储所有回调,查询时需要遍历所有回调并过滤 topic, + 时间复杂度为 O(n)。使用 Topic 索引后,可以直接获取目标回调列表, + 时间复杂度降为 O(1),大幅提升性能。 + + 【示例】 + 假设有 3 个回调: + - callbackId="abc-123", topic="dji/SN9527/response", nodeId="nodeA" + - callbackId="def-456", topic="dji/SN9527/state", nodeId="nodeB" + - callbackId="ghi-789", topic="dji/SN9527/response", nodeId="nodeA" + + Redis 存储结构: + mqtt:callback:abc-123 → {callbackId:"abc-123", topic:"dji/SN9527/response", nodeId:"nodeA"} + mqtt:callback:def-456 → {callbackId:"def-456", topic:"dji/SN9527/state", nodeId:"nodeB"} + mqtt:callback:ghi-789 → {callbackId:"ghi-789", topic:"dji/SN9527/response", nodeId:"nodeA"} + mqtt:topic:dji/SN9527/response → ["abc-123", "ghi-789"] + mqtt:topic:dji/SN9527/state → ["def-456"] + + 查询 topic="dji/SN9527/response" 的回调: + 1. 从索引获取: SMEMBERS mqtt:topic:dji/SN9527/response → ["abc-123", "ghi-789"] + 2. 批量获取详情: MGET mqtt:callback:abc-123 mqtt:callback:ghi-789 + 3. 总耗时: O(1) + O(k),k 是该 topic 的回调数量(通常很小) + + 【Redis 数据清理时机】 + Redis 中的回调数据有两种清理机制: + + ┌─────────────────────────────────────────────────────────────┐ + │ 1️⃣ 主动清理(业务逻辑触发) │ + └─────────────────────────────────────────────────────────────┘ + 触发时机: + ✅ 回调成功执行后(TransactionExecutor 的 finally 块) + ✅ 回调超时后(TransactionExecutor 的 finally 块) + ✅ handleMessage 检测到超时(转发前检查) + + 清理操作: + unregisterCallback(callbackId) + ↓ + 1. 获取回调信息: GET mqtt:callback:{callbackId} + 2. 删除回调信息: DEL mqtt:callback:{callbackId} + 3. 从索引中移除: SREM mqtt:topic:{topic} {callbackId} + + 示例: + T0: 注册回调,超时时间 10 秒 + T5: 收到 MQTT 响应,回调执行成功 + T5: 立即清理 Redis 数据 ✅ + - DEL mqtt:callback:abc-123 + - SREM mqtt:topic:dji/SN9527/response abc-123 + + ┌─────────────────────────────────────────────────────────────┐ + │ 2️⃣ 被动清理(Redis TTL 自动过期) │ + └─────────────────────────────────────────────────────────────┘ + 作用:兜底机制,防止异常情况下的数据残留 + + 设置方式: + // 注册回调时设置 TTL + SET mqtt:callback:{callbackId} {json} EX 3600 // 1小时后自动过期 + EXPIRE mqtt:topic:{topic} 3600 // 1小时后自动过期 + + 触发时机: + ⚠️ 应用异常崩溃,主动清理未执行 + ⚠️ 网络分区,无法删除 Redis 数据 + ⚠️ 代码 Bug,主动清理失败 + + 示例: + T0: 注册回调,TTL=3600秒(1小时) + T5: 应用崩溃,主动清理未执行 ❌ + T3600: Redis 自动删除过期数据 ✅ + - mqtt:callback:abc-123 自动过期删除 + - mqtt:topic:dji/SN9527/response 自动过期删除 + + 【推荐配置】 + TTL 应该设置为回调超时时间的 2-3 倍,例如: + - 回调超时: 10 秒 + - Redis TTL: 30 秒(10秒 × 3) + + 这样可以确保: + ✅ 正常情况下,主动清理会在 10 秒内完成 + ✅ 异常情况下,Redis 会在 30 秒后自动清理 + ✅ 避免设置过长的 TTL 导致内存浪费 + + 【注意事项】 + ⚠️ Topic 索引的 TTL 问题: + 如果同一个 topic 有多个回调,每次添加新回调时都会刷新 TTL。 + 这可能导致索引的 TTL 比单个回调的 TTL 更长。 + + 解决方案: + 方案1: 不为 Topic 索引设置 TTL,只在删除最后一个 callbackId 时删除索引 + 方案2: 每次查询时过滤掉已过期的 callbackId(推荐) + ↓ + 节点A: 本地内存存储 Consumer + - localHandlers.put(callbackId, consumer) + ↓ + 节点A: 订阅 Redis Pub/Sub 频道 + - Channel: mqtt:node:nodeA + + ┌─────────────────────────────────────────────────────────────────┐ + │ 步骤2: MQTT Broker 将响应路由到节点B(不是节点A) │ + └─────────────────────────────────────────────────────────────────┘ + MQTT Broker: 收到设备响应 + ↓ + MQTT Broker: 将消息路由到节点B(随机/轮询) + ↓ + 节点B: MqttCallbackRegistry.handleMessage(topic, messageBody) + + ┌─────────────────────────────────────────────────────────────────┐ + │ 步骤3: 节点B 从 Redis 查询等待该 topic 的回调 │ + └─────────────────────────────────────────────────────────────────┘ + 节点B: callbackStore.getCallbacksByTopic("dji/SN9527/response") + ↓ + Redis: 查询 mqtt:topic:dji/SN9527/response + ↓ + Redis: 返回 Set + ↓ + Redis: 批量获取回调信息 + - mqtt:callback:{callbackId1} → {nodeId="nodeA", ...} + - mqtt:callback:{callbackId2} → {nodeId="nodeA", ...} + ↓ + 节点B: 获得回调列表 List + + ┌─────────────────────────────────────────────────────────────────┐ + │ 步骤4: 节点B 判断回调属于哪个节点 │ + └─────────────────────────────────────────────────────────────────┘ + 节点B: for (MqttCallbackInfo callback : callbacks) { + if (nodeId.equals(callback.getNodeId())) { + // 本节点的回调,直接执行 + executeLocalCallback(...) + } else { + // 其他节点的回调,转发到目标节点 + callbackStore.publishMessageToNode(...) + } + } + + ┌─────────────────────────────────────────────────────────────────┐ + │ 步骤5: 节点B 通过 Redis Pub/Sub 转发消息到节点A │ + └─────────────────────────────────┘ + 节点B: callbackStore.publishMessageToNode( + nodeId="nodeA", + callbackId="xxx", + messageBody="{...}" // JSON 字符串 + ) + ↓ + Redis Pub/Sub: PUBLISH mqtt:node:nodeA + { + "callbackId": "xxx", + "messageBody": "{...}" + } + + ┌─────────────────────────────────────────────────────────────────┐ + │ 步骤6: 节点A 收到 Redis Pub/Sub 消息 │ + └─────────────────────────────────────────────────────────────────┘ + 节点A: Redis Pub/Sub Listener 收到消息 + ↓ + 节点A: handleNodeMessage(callbackId, messageBodyJson) + ↓ + 节点A: 反序列化消息体 + - Object messageBody = objectMapper.readValue(messageBodyJson) + ↓ + 节点A: executeLocalCallback(callbackId, messageBody) + ↓ + 节点A: 从本地内存获取 Consumer + - Consumer handler = localHandlers.get(callbackId) + ↓ + 节点A: 执行回调 + - handler.accept(messageBody) + ↓ + ✅ 命令执行成功! \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/state/AirportState.java b/src/main/java/com/ruoyi/device/domain/impl/machine/state/AirportState.java new file mode 100644 index 0000000..7652032 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/state/AirportState.java @@ -0,0 +1,15 @@ +package com.ruoyi.device.domain.impl.machine.state; +/** + * 机巢状态枚举 + */ +public enum AirportState { + /** + * 未知状态(服务器重启后的初始状态,等待第一次心跳同步),同时也是离线状态 + */ + UNKNOWN, + + /** + * 在线 + */ + ONLINE +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/state/CoverState.java b/src/main/java/com/ruoyi/device/domain/impl/machine/state/CoverState.java new file mode 100644 index 0000000..d1bc8f2 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/state/CoverState.java @@ -0,0 +1,22 @@ +package com.ruoyi.device.domain.impl.machine.state; + +/** + * 舱门状态枚举 + */ +public enum CoverState { + /** + * 未知状态(服务器重启后的初始状态,等待第一次心跳同步) + */ + UNKNOWN, + + /** + * 舱门已关闭 + */ + CLOSED, + + /** + * 舱门已打开 + */ + OPENED, + +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/state/DebugModeState.java b/src/main/java/com/ruoyi/device/domain/impl/machine/state/DebugModeState.java new file mode 100644 index 0000000..c453014 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/state/DebugModeState.java @@ -0,0 +1,21 @@ +package com.ruoyi.device.domain.impl.machine.state; + +/** + * 调试模式状态枚举 + */ +public enum DebugModeState { + /** + * 未知状态 + */ + UNKNOWN, + + /** + * 调试模式 + */ + ENTERED, + + /** + * 退出调试模式 + */ + EXITED +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/state/DrcState.java b/src/main/java/com/ruoyi/device/domain/impl/machine/state/DrcState.java new file mode 100644 index 0000000..64c003a --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/state/DrcState.java @@ -0,0 +1,23 @@ +package com.ruoyi.device.domain.impl.machine.state; + +/** + * 飞行控制模式(DRC)状态枚举 + */ +public enum DrcState { + /** + * 未知状态(服务器重启后的初始状态,等待第一次心跳同步) + */ + UNKNOWN, + + /** + * 退出状态(DRC模式已退出) + */ + EXITED, + + /** + * 进入状态(已进入DRC模式) + */ + ENTERED, + + +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/state/DroneState.java b/src/main/java/com/ruoyi/device/domain/impl/machine/state/DroneState.java new file mode 100644 index 0000000..5598ab2 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/state/DroneState.java @@ -0,0 +1,33 @@ +package com.ruoyi.device.domain.impl.machine.state; + +/** + * 无人机状态枚举 + * 分为:准备中 -> 飞行中 -> 返航 三个大状态 + */ +public enum DroneState { + /** + * 未知状态(服务器重启后的初始状态,等待第一次心跳同步),同时也是离线状态 + */ + UNKNOWN, + + /** + * 在线 + */ + ONLINE, + + /** + * 飞行中 + */ + FLYING, + + /** + * 到达目的地 + */ + ARRIVED, + + /** + * 返航中 + */ + RETURNING, + +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/state/MachineStates.java b/src/main/java/com/ruoyi/device/domain/impl/machine/state/MachineStates.java new file mode 100644 index 0000000..6501df4 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/state/MachineStates.java @@ -0,0 +1,50 @@ +package com.ruoyi.device.domain.impl.machine.state; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 设备的六套大状态 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MachineStates { + /** + * 无人机状态 + */ + private DroneState droneState = DroneState.UNKNOWN; + + /** + * 机巢状态 + */ + private AirportState airportState = AirportState.UNKNOWN; + + /** + * 舱门状态 + */ + private CoverState coverState = CoverState.UNKNOWN; + + /** + * DRC状态 + */ + private DrcState drcState = DrcState.UNKNOWN; + + /** + * 调试模式状态 + */ + private DebugModeState debugModeState = DebugModeState.UNKNOWN; + + /** + * 急停状态 + */ + private StopState stopState = StopState.UNKNOWN; + + /** + * 复制当前状态 + */ + public MachineStates copy() { + return new MachineStates(droneState, airportState, coverState, drcState, debugModeState, stopState); + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/state/StopState.java b/src/main/java/com/ruoyi/device/domain/impl/machine/state/StopState.java new file mode 100644 index 0000000..a967818 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/state/StopState.java @@ -0,0 +1,22 @@ +package com.ruoyi.device.domain.impl.machine.state; + +/** + * 急停状态 + */ +public enum StopState { + /** + * 未知状态(服务器重启后的初始状态,等待第一次心跳同步) + */ + UNKNOWN, + + /** + * 退出状态 + */ + EXITED, + + /** + * 进入状态 + */ + ENTERED, + +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/MachineStateManager.java b/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/MachineStateManager.java new file mode 100644 index 0000000..3ef1f8f --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/MachineStateManager.java @@ -0,0 +1,195 @@ +package com.ruoyi.device.domain.impl.machine.statemachine; + + +import com.ruoyi.device.domain.impl.machine.state.*; +import com.ruoyi.device.domain.impl.machine.statemachine.store.MachineStateStore; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 设备状态管理器 + * 负责状态的业务逻辑处理和变化通知,底层存储由 MachineStateStore 实现 + */ +@Slf4j +@Component +public class MachineStateManager { + + /** + * 状态存储层(支持内存、Redis等多种实现) + */ + private final MachineStateStore stateStore; + + /** + * 状态变化监听器 + */ + private final Map stateChangeListeners = new ConcurrentHashMap<>(); + public MachineStateManager(MachineStateStore stateStore) { + this.stateStore = stateStore; + log.info("设备状态管理器初始化完成,使用存储实现: {}", stateStore.getClass().getSimpleName()); + } + + /** + * 获取设备状态 + */ + public MachineStates getStates(String sn) { + return stateStore.getStates(sn); + } + + /** + * 设置无人机状态 + */ + public void setDroneState(String sn, DroneState newState) { + MachineStates states = getStates(sn); + DroneState oldState = states.getDroneState(); + + if (oldState != newState) { + states.setDroneState(newState); + stateStore.saveStates(sn, states); // 保存到存储层 + log.info("无人机状态变化: sn={}, {} -> {}", sn, oldState, newState); + notifyStateChange(sn, states.copy()); + } + } + + /** + * 设置机巢状态 + */ + public void setAirportState(String sn, AirportState newState) { + MachineStates states = getStates(sn); + AirportState oldState = states.getAirportState(); + + if (oldState != newState) { + states.setAirportState(newState); + stateStore.saveStates(sn, states); // 保存到存储层 + log.info("机巢状态变化: sn={}, {} -> {}", sn, oldState, newState); + notifyStateChange(sn, states.copy()); + } + } + + /** + * 设置舱门状态 + */ + public void setCoverState(String sn, CoverState newState) { + MachineStates states = getStates(sn); + CoverState oldState = states.getCoverState(); + + if (oldState != newState) { + states.setCoverState(newState); + stateStore.saveStates(sn, states); // 保存到存储层 + log.info("舱门状态变化: sn={}, {} -> {}", sn, oldState, newState); + notifyStateChange(sn, states.copy()); + } + } + + /** + * 设置DRC状态 + */ + public void setDrcState(String sn, DrcState newState) { + MachineStates states = getStates(sn); + DrcState oldState = states.getDrcState(); + + if (oldState != newState) { + states.setDrcState(newState); + stateStore.saveStates(sn, states); // 保存到存储层 + log.info("DRC状态变化: sn={}, {} -> {}", sn, oldState, newState); + notifyStateChange(sn, states.copy()); + } + } + + /** + * 批量更新状态(用于心跳同步) + * 只更新非UNKNOWN的状态,避免覆盖已有状态 + */ + public void updateStates(String sn, MachineStates newStates) { + updateStates(sn, newStates, false); + } + + /** + * 批量更新状态(用于心跳同步) + * + * @param sn 设备SN号 + * @param newStates 新状态 + * @param forceUpdate 是否强制更新所有状态(包括UNKNOWN) + */ + public void updateStates(String sn, MachineStates newStates, boolean forceUpdate) { + MachineStates currentStates = getStates(sn); + boolean changed = false; + + // 更新无人机状态(如果不是UNKNOWN或强制更新) + if (forceUpdate || newStates.getDroneState() != DroneState.UNKNOWN) { + if (currentStates.getDroneState() != newStates.getDroneState()) { + currentStates.setDroneState(newStates.getDroneState()); + changed = true; + } + } + + // 更新机巢状态(如果不是UNKNOWN或强制更新) + if (forceUpdate || newStates.getAirportState() != AirportState.UNKNOWN) { + if (currentStates.getAirportState() != newStates.getAirportState()) { + currentStates.setAirportState(newStates.getAirportState()); + changed = true; + } + } + + // 更新舱门状态(如果不是UNKNOWN或强制更新) + if (forceUpdate || newStates.getCoverState() != CoverState.UNKNOWN) { + if (currentStates.getCoverState() != newStates.getCoverState()) { + currentStates.setCoverState(newStates.getCoverState()); + changed = true; + } + } + + // 更新DRC状态(如果不是UNKNOWN或强制更新) + if (forceUpdate || newStates.getDrcState() != DrcState.UNKNOWN) { + if (currentStates.getDrcState() != newStates.getDrcState()) { + currentStates.setDrcState(newStates.getDrcState()); + changed = true; + } + } + + if (changed) { + stateStore.saveStates(sn, currentStates); // 保存到存储层 + log.info("设备状态批量更新: sn={}, states={}", sn, currentStates); + notifyStateChange(sn, currentStates.copy()); + } + } + + /** + * 注册状态变化监听器 + */ + public void registerStateChangeListener(String listenerId, StateChangeListener listener) { + stateChangeListeners.put(listenerId, listener); + log.debug("注册状态变化监听器: listenerId={}", listenerId); + } + + /** + * 取消注册状态变化监听器 + */ + public void unregisterStateChangeListener(String listenerId) { + stateChangeListeners.remove(listenerId); + log.debug("取消注册状态变化监听器: listenerId={}", listenerId); + } + + /** + * 通知状态变化 + */ + private void notifyStateChange(String sn, MachineStates newStates) { + for (StateChangeListener listener : stateChangeListeners.values()) { + try { + listener.onStateChange(sn, newStates); + } catch (Exception e) { + log.error("状态变化监听器执行失败: sn={}", sn, e); + } + } + } + + /** + * 移除设备状态(设备下线时调用) + */ + public void removeStates(String sn) { + stateStore.removeStates(sn); + log.info("移除设备状态: sn={}", sn); + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/StateChangeListener.java b/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/StateChangeListener.java new file mode 100644 index 0000000..fa63860 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/StateChangeListener.java @@ -0,0 +1,18 @@ +package com.ruoyi.device.domain.impl.machine.statemachine; + + +import com.ruoyi.device.domain.impl.machine.state.MachineStates; + +/** + * 状态变化监听器 + */ +@FunctionalInterface +public interface StateChangeListener { + /** + * 状态变化回调 + * + * @param sn 设备SN号 + * @param newStates 新状态 + */ + void onStateChange(String sn, MachineStates newStates); +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/InMemoryMachineStateStore.java b/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/InMemoryMachineStateStore.java new file mode 100644 index 0000000..cbf420f --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/InMemoryMachineStateStore.java @@ -0,0 +1,50 @@ +package com.ruoyi.device.domain.impl.machine.statemachine.store; + + +import com.ruoyi.device.domain.impl.machine.state.MachineStates; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 基于内存的设备状态存储实现 + * 适用于单节点部署或开发测试环境 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "machine.state.store.type", havingValue = "memory", matchIfMissing = true) +public class InMemoryMachineStateStore implements MachineStateStore { + + /** + * SN -> 设备状态 + */ + private final Map stateMap = new ConcurrentHashMap<>(); + + @Override + public MachineStates getStates(String sn) { + return stateMap.computeIfAbsent(sn, k -> { + log.debug("创建新的设备状态: sn={}", sn); + return new MachineStates(); + }); + } + + @Override + public void saveStates(String sn, MachineStates states) { + stateMap.put(sn, states); + log.debug("保存设备状态到内存: sn={}", sn); + } + + @Override + public void removeStates(String sn) { + stateMap.remove(sn); + log.debug("从内存中移除设备状态: sn={}", sn); + } + + @Override + public boolean exists(String sn) { + return stateMap.containsKey(sn); + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/MachineStateStore.java b/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/MachineStateStore.java new file mode 100644 index 0000000..a67cdbf --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/MachineStateStore.java @@ -0,0 +1,43 @@ +package com.ruoyi.device.domain.impl.machine.statemachine.store; + + +import com.ruoyi.device.domain.impl.machine.state.MachineStates; + +/** + * 设备状态存储接口 + * 提供状态的存储和获取抽象,支持多种实现(内存、Redis等) + */ +public interface MachineStateStore { + + /** + * 获取设备状态 + * 如果设备状态不存在,返回一个新的默认状态对象 + * + * @param sn 设备SN号 + * @return 设备状态 + */ + MachineStates getStates(String sn); + + /** + * 保存设备状态 + * + * @param sn 设备SN号 + * @param states 设备状态 + */ + void saveStates(String sn, MachineStates states); + + /** + * 移除设备状态 + * + * @param sn 设备SN号 + */ + void removeStates(String sn); + + /** + * 检查设备状态是否存在 + * + * @param sn 设备SN号 + * @return 是否存在 + */ + boolean exists(String sn); +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/RedisMachineStateStore.java b/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/RedisMachineStateStore.java new file mode 100644 index 0000000..3ce4dd4 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/statemachine/store/RedisMachineStateStore.java @@ -0,0 +1,93 @@ +package com.ruoyi.device.domain.impl.machine.statemachine.store; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.ruoyi.device.domain.impl.machine.state.MachineStates; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +/** + * 基于 Redis 的设备状态存储实现 + * 适用于多节点部署的生产环境 + * + * Redis 数据结构: + * - Key: machine:state:{sn} + * - Value: MachineStates (JSON) + * - TTL: 86400 秒(24小时) + * + * 使用方式: + * 1. 在 application.properties 中配置:machine.state.store.type=redis + * 2. 配置 Redis 连接信息 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "machine.state.store.type", havingValue = "redis") +public class RedisMachineStateStore implements MachineStateStore { + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + // Redis key 前缀 + private static final String KEY_PREFIX = "machine:state:"; + + // 配置状态的过期时间 + private static final long EXPIRE_SECONDS = 86400; // 24小时 + + public RedisMachineStateStore(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { + this.redisTemplate = redisTemplate; + this.objectMapper = objectMapper; + log.info("使用 Redis 设备状态存储实现"); + } + + @Override + public MachineStates getStates(String sn) { + String key = KEY_PREFIX + sn; + String json = redisTemplate.opsForValue().get(key); + + if (json == null) { + log.debug("Redis 中不存在设备状态,返回默认状态: sn={}", sn); + MachineStates states = new MachineStates(); + saveStates(sn, states); + return states; + } + + try { + MachineStates states = objectMapper.readValue(json, MachineStates.class); + log.debug("从 Redis 获取设备状态: sn={}", sn); + return states; + } catch (JsonProcessingException e) { + log.error("反序列化设备状态失败: sn={}", sn, e); + return new MachineStates(); + } + } + + @Override + public void saveStates(String sn, MachineStates states) { + try { + String key = KEY_PREFIX + sn; + String json = objectMapper.writeValueAsString(states); + redisTemplate.opsForValue().set(key, json, EXPIRE_SECONDS, TimeUnit.SECONDS); + log.debug("保存设备状态到 Redis: sn={}", sn); + } catch (JsonProcessingException e) { + log.error("序列化设备状态失败: sn={}", sn, e); + } + } + + @Override + public void removeStates(String sn) { + String key = KEY_PREFIX + sn; + redisTemplate.delete(key); + log.debug("从 Redis 中移除设备状态: sn={}", sn); + } + + @Override + public boolean exists(String sn) { + String key = KEY_PREFIX + sn; + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/VendorConfig.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/VendorConfig.java new file mode 100644 index 0000000..f0d20ee --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/VendorConfig.java @@ -0,0 +1,49 @@ +package com.ruoyi.device.domain.impl.machine.vendor; + +import com.ruoyi.device.domain.impl.machine.command.CommandType; +import com.ruoyi.device.domain.impl.machine.command.Transaction; +import com.ruoyi.device.domain.impl.machine.state.MachineStates; + +import java.util.List; +import java.util.Map; + +/** + * 厂家配置接口 + * 每个厂家需要实现此接口,定义其支持的命令和状态转换规则 + */ +public interface VendorConfig { + /** + * 获取厂家类型 + */ + String getVendorType(); + + /** + * 获取厂家名称 + */ + String getVendorName(); + + /** + * 获取指定命令的事务定义 + * + * @param commandType 命令类型 + * @return 事务定义,如果不支持该命令则返回null + */ + Transaction getTransaction(CommandType commandType); + + /** + * 判断在当前状态下是否可以执行指定命令 + * + * @param currentStates 当前状态 + * @param commandType 命令类型 + * @return 是否可以执行 + */ + boolean canExecuteCommand(MachineStates currentStates, CommandType commandType); + + /** + * 获取在当前状态下可以执行的命令列表 + * + * @param currentStates 当前状态 + * @return 可执行的命令列表 + */ + List getAvailableCommands(MachineStates currentStates); +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/VendorRegistry.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/VendorRegistry.java new file mode 100644 index 0000000..165b581 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/VendorRegistry.java @@ -0,0 +1,119 @@ +package com.ruoyi.device.domain.impl.machine.vendor; + + +import com.ruoyi.device.domain.impl.machine.vendor.store.SnVendorMappingStore; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 厂家注册中心 + * 管理厂家配置和 SN 到厂家的映射关系 + * + * 架构说明: + * - 厂家配置存储在本地内存(vendorConfigs) + * - SN 到厂家类型的映射通过 SnVendorMappingStore 存储(支持内存/Redis+MySQL) + */ +@Slf4j +@Component +public class VendorRegistry { + + /** + * 厂家类型 -> 厂家配置(存储在本地内存) + */ + private final Map vendorConfigs = new ConcurrentHashMap<>(); + + /** + * SN 到厂家类型的映射存储层(支持内存、Redis+MySQL等多种实现) + */ + private final SnVendorMappingStore mappingStore; + + public VendorRegistry(SnVendorMappingStore mappingStore) { + this.mappingStore = mappingStore; + log.info("厂家注册中心初始化完成,使用映射存储实现: {}", mappingStore.getClass().getSimpleName()); + } + + /** + * 注册厂家配置 + */ + public void registerVendor(VendorConfig vendorConfig) { + vendorConfigs.put(vendorConfig.getVendorType(), vendorConfig); + log.info("注册厂家配置: vendorType={}, vendorName={}", + vendorConfig.getVendorType(), vendorConfig.getVendorName()); + } + + /** + * 绑定SN到厂家 + * + * @deprecated 不建议直接调用此方法,应该通过数据库或配置文件管理 SN 映射关系 + */ + @Deprecated + public void bindSnToVendor(String sn, String vendorType) { + if (!vendorConfigs.containsKey(vendorType)) { + throw new IllegalArgumentException("未注册的厂家类型: " + vendorType); + } + mappingStore.saveMapping(sn, vendorType); + log.debug("绑定SN到厂家: sn={}, vendorType={}", sn, vendorType); + } + + /** + * 解绑SN + * + * @deprecated 不建议直接调用此方法,应该通过数据库或配置文件管理 SN 映射关系 + */ + @Deprecated + public void unbindSn(String sn) { + mappingStore.removeMapping(sn); + log.debug("解绑SN: sn={}", sn); + } + + /** + * 获取SN对应的厂家类型 + * + * 查询顺序(Redis+MySQL 模式): + * 1. 先从 Redis 缓存获取 + * 2. Redis 没有则从 MySQL 数据库获取 + * 3. 获取到后存入 Redis 缓存 + */ + public String getVendorType(String sn) { + return mappingStore.getVendorType(sn); + } + + /** + * 获取SN对应的厂家配置 + * + * 查询顺序(Redis+MySQL 模式): + * 1. 通过 mappingStore 获取厂家类型(Redis → MySQL → 缓存) + * 2. 根据厂家类型获取厂家配置 + */ + public VendorConfig getVendorConfig(String sn) { + String vendorType = getVendorType(sn); + if (vendorType == null) { + return null; + } + return vendorConfigs.get(vendorType); + } + + /** + * 根据厂家类型获取厂家配置 + */ + public VendorConfig getVendorConfigByType(String vendorType) { + return vendorConfigs.get(vendorType); + } + + /** + * 判断SN是否已绑定厂家 + */ + public boolean isSnBound(String sn) { + return mappingStore.exists(sn); + } + + /** + * 获取所有已注册的厂家类型 + */ + public java.util.Set getAllVendorTypes() { + return vendorConfigs.keySet(); + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/DjiVendorConfig.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/DjiVendorConfig.java new file mode 100644 index 0000000..ddf4148 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/DjiVendorConfig.java @@ -0,0 +1,121 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji; + +import com.ruoyi.device.domain.impl.machine.command.CommandType; +import com.ruoyi.device.domain.impl.machine.command.Transaction; +import com.ruoyi.device.domain.impl.machine.state.*; +import com.ruoyi.device.domain.impl.machine.vendor.VendorConfig; +import com.ruoyi.device.domain.impl.machine.vendor.dji.instruction.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 大疆无人机厂家配置 + */ +@Slf4j +@Component +public class DjiVendorConfig implements VendorConfig { + + private final Map transactionMap = new HashMap<>(); + + public DjiVendorConfig() { + initTransactions(); + } + + @Override + public String getVendorType() { + return "DJI"; + } + + @Override + public String getVendorName() { + return "大疆"; + } + + @Override + public Transaction getTransaction(CommandType commandType) { + return transactionMap.get(commandType); + } + + @Override + public boolean canExecuteCommand(MachineStates currentStates, CommandType commandType) { + DroneState droneState = currentStates.getDroneState(); + AirportState airportState = currentStates.getAirportState(); + CoverState coverState = currentStates.getCoverState(); + DebugModeState debugModeState = currentStates.getDebugModeState(); + StopState stopState = currentStates.getStopState(); + + switch (commandType) { + + case TAKE_OFF: + return droneState == DroneState.ONLINE && airportState == AirportState.ONLINE; + + case RETURN_HOME: + return droneState == DroneState.FLYING || droneState == DroneState.ARRIVED; + + + + default: + return true; + } + } + + @Override + public List getAvailableCommands(MachineStates currentStates) { + List availableCommands = new ArrayList<>(); + + for (CommandType commandType : CommandType.values()) { + if (canExecuteCommand(currentStates, commandType)) { + availableCommands.add(commandType); + } + } + return availableCommands; + } + + /** + * 初始化事务定义 + */ + private void initTransactions() { + // 起飞命令 + Transaction takeOffTransaction = new Transaction("起飞", CommandType.TAKE_OFF) + .root(new DjiTakeOffInstruction()) + .setTimeout(10000); + transactionMap.put(takeOffTransaction.getCommandType(), takeOffTransaction); + + /** + * 开仓命令 Transaction + * 流程说明: + * 1. root: 检查是否已在调试模式 + * - 成功:直接执行开仓命令 + * - 失败:先开启调试模式,再执行开仓命令 + */ + // 创建检查调试模式的指令(root) + DjiCheckDebugModeInstruction checkDebugMode = new DjiCheckDebugModeInstruction(); + + // 创建开仓指令(成功分支) + DjiOpenCoverInstruction openCoverAfterCheck = new DjiOpenCoverInstruction(); + + // 创建开启调试模式的指令(失败分支) + DjiEnableDebugModeInstruction enableDebugMode = new DjiEnableDebugModeInstruction(); + + // 创建开仓指令(失败分支的子指令) + DjiOpenCoverInstruction openCoverAfterEnable = new DjiOpenCoverInstruction(); + + // 构建指令树 + checkDebugMode + .onSuccess(openCoverAfterCheck) // 如果已在调试模式,直接开仓 + .onFailure(enableDebugMode // 如果不在调试模式,先开启调试模式 + .onSuccess(openCoverAfterEnable)); // 开启调试模式成功后,再开仓 + + Transaction openCoverTransaction = new Transaction("开仓", CommandType.OPEN_COVER) + .root(checkDebugMode) + .setTimeout(80000); // 总超时时间80秒 + transactionMap.put(openCoverTransaction.getCommandType(), openCoverTransaction); + + log.info("大疆厂家配置初始化完成,共配置{}个命令", transactionMap.size()); + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCancelPointInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCancelPointInstruction.java new file mode 100644 index 0000000..99b99e6 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCancelPointInstruction.java @@ -0,0 +1,57 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆取消指点指令 + */ +@Slf4j +public class DjiCancelPointInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_CANCEL_POINT"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + log.info("发送大疆取消指点指令: sn={}", sn); + + String topic = "dji/" + sn + "/command"; + String payload = "{\"cmd\":\"cancelPoint\"}"; + log.debug("MQTT发送: topic={}, payload={}", topic, payload); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/response") + .fieldPath("cmd") + .expectedValue("cancelPoint") + .timeoutMs(10000) + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/state") + .fieldPath("droneState") + .expectedValue("POINT_CANCELLED") + .timeoutMs(30000) + .build(); + } + + @Override + public long getTimeoutMs() { + return 30000; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCheckDebugModeInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCheckDebugModeInstruction.java new file mode 100644 index 0000000..2d6bcfb --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCheckDebugModeInstruction.java @@ -0,0 +1,51 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆检查调试模式指令 + * 用于判断设备是否已经进入调试模式 + */ +@Slf4j +public class DjiCheckDebugModeInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_CHECK_DEBUG_MODE"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + log.info("检查大疆设备调试模式状态: sn={}", sn); + // 不需要发送命令,只需要等待状态回调 + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + // 不需要方法回调 + return null; + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + // 等待设备状态回调,判断是否在调试模式 + return CallbackConfig.builder() + .topic("dji/" + sn + "/state") + .fieldPath("debugMode") + .expectedValue("ENABLED") + .timeoutMs(3000) // 3秒超时,如果没有收到说明不在调试模式 + .build(); + } + + @Override + public long getTimeoutMs() { + return 3000; + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCloseCoverInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCloseCoverInstruction.java new file mode 100644 index 0000000..882a690 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiCloseCoverInstruction.java @@ -0,0 +1,58 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆关闭舱门指令 + */ +@Slf4j +public class DjiCloseCoverInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_CLOSE_COVER"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + log.info("发送大疆关闭舱门指令: sn={}", sn); + + String topic = "dji/" + sn + "/command"; + String payload = "{\"cmd\":\"closeCover\"}"; + log.debug("MQTT发送: topic={}, payload={}", topic, payload); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/response") + .fieldPath("cmd") + .expectedValue("closeCover") + .timeoutMs(10000) + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/state") + .fieldPath("coverState") + .expectedValue("CLOSED") + .timeoutMs(60000) + .build(); + } + + @Override + public long getTimeoutMs() { + return 60000; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiEmergencyStopInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiEmergencyStopInstruction.java new file mode 100644 index 0000000..fdb2789 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiEmergencyStopInstruction.java @@ -0,0 +1,61 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆急停指令 + */ +@Slf4j +public class DjiEmergencyStopInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_EMERGENCY_STOP"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + log.info("发送大疆急停指令: sn={}", sn); + + String topic = "dji/" + sn + "/command"; + String payload = "{\"cmd\":\"emergencyStop\"}"; + log.debug("MQTT发送: topic={}, payload={}", topic, payload); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/response") + .fieldPath("cmd") + .expectedValue("emergencyStop") + .timeoutMs(5000) + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/state") + .fieldPath("droneState") + .customPredicate(state -> { + // 急停状态可能是 EMERGENCY_STOP 或 RETURN_EMERGENCY_STOP + return "EMERGENCY_STOP".equals(state) || "RETURN_EMERGENCY_STOP".equals(state); + }) + .timeoutMs(30000) + .build(); + } + + @Override + public long getTimeoutMs() { + return 30000; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiEnableDebugModeInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiEnableDebugModeInstruction.java new file mode 100644 index 0000000..5cd5aa7 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiEnableDebugModeInstruction.java @@ -0,0 +1,55 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆开启调试模式指令 + */ +@Slf4j +public class DjiEnableDebugModeInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_ENABLE_DEBUG_MODE"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + log.info("发送大疆开启调试模式指令: sn={}", sn); + + String topic = "dji/" + sn + "/command"; + String payload = "{\"cmd\":\"enableDebugMode\"}"; + + context.getMqttClient().sendMessage(topic, payload); + log.debug("MQTT发送成功: topic={}, payload={}", topic, payload); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + // 等待开启调试模式命令的ACK响应 + return CallbackConfig.builder() + .topic("dji/" + sn + "/response") + .fieldPath("cmd") + .expectedValue("enableDebugMode") + .timeoutMs(10000) + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + // 不需要状态回调,只要收到ACK就认为命令发送成功 + return null; + } + + @Override + public long getTimeoutMs() { + return 10000; + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiLandInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiLandInstruction.java new file mode 100644 index 0000000..b993dac --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiLandInstruction.java @@ -0,0 +1,59 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆降落指令 + */ +@Slf4j +public class DjiLandInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_LAND"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + log.info("发送大疆降落指令: sn={}", sn); + + // TODO: 实际的MQTT发送逻辑 + String topic = "dji/" + sn + "/command"; + String payload = "{\"cmd\":\"land\"}"; + log.debug("MQTT发送: topic={}, payload={}", topic, payload); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/response") + .fieldPath("cmd") + .expectedValue("land") + .timeoutMs(10000) + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/state") + .fieldPath("droneState") + .expectedValue("PREPARING") + .timeoutMs(60000) + .build(); + } + + @Override + public long getTimeoutMs() { + return 90000; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiOpenCoverInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiOpenCoverInstruction.java new file mode 100644 index 0000000..9d95eca --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiOpenCoverInstruction.java @@ -0,0 +1,60 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆打开舱门指令 + */ +@Slf4j +public class DjiOpenCoverInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_OPEN_COVER"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + log.info("发送大疆打开舱门指令: sn={}", sn); + + String topic = "dji/" + sn + "/command"; + String payload = "{\"cmd\":\"openCover\"}"; + + context.getMqttClient().sendMessage(topic, payload); + log.debug("MQTT发送成功: topic={}, payload={}", topic, payload); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/response") + .fieldPath("cmd") + .expectedValue("openCover") + .timeoutMs(10000) + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/state") + .fieldPath("coverState") + .expectedValue("OPENED") + .timeoutMs(60000) + .build(); + } + + @Override + public long getTimeoutMs() { + return 60000; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiPointFlyInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiPointFlyInstruction.java new file mode 100644 index 0000000..cdb9791 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiPointFlyInstruction.java @@ -0,0 +1,63 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆指点飞行指令 + */ +@Slf4j +public class DjiPointFlyInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_POINT_FLY"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + Object latitude = context.getCommandParam("latitude"); + Object longitude = context.getCommandParam("longitude"); + Object altitude = context.getCommandParam("altitude"); + + log.info("发送大疆指点飞行指令: sn={}, lat={}, lon={}, alt={}", sn, latitude, longitude, altitude); + + String topic = "dji/" + sn + "/command"; + String payload = String.format("{\"cmd\":\"pointFly\",\"latitude\":%s,\"longitude\":%s,\"altitude\":%s}", + latitude, longitude, altitude); + log.debug("MQTT发送: topic={}, payload={}", topic, payload); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/response") + .fieldPath("cmd") + .expectedValue("pointFly") + .timeoutMs(10000) + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/state") + .fieldPath("droneState") + .expectedValue("POINT_FLYING") + .timeoutMs(30000) + .build(); + } + + @Override + public long getTimeoutMs() { + return 90000; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiResumeFlightInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiResumeFlightInstruction.java new file mode 100644 index 0000000..3788807 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiResumeFlightInstruction.java @@ -0,0 +1,61 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆继续飞行指令 + */ +@Slf4j +public class DjiResumeFlightInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_RESUME_FLIGHT"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + log.info("发送大疆继续飞行指令: sn={}", sn); + + String topic = "dji/" + sn + "/command"; + String payload = "{\"cmd\":\"resumeFlight\"}"; + log.debug("MQTT发送: topic={}, payload={}", topic, payload); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/response") + .fieldPath("cmd") + .expectedValue("resumeFlight") + .timeoutMs(10000) + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/state") + .fieldPath("droneState") + .customPredicate(state -> { + // 继续飞行后可能变为 FLYING 或 RETURNING + return "FLYING".equals(state) || "RETURNING".equals(state); + }) + .timeoutMs(30000) + .build(); + } + + @Override + public long getTimeoutMs() { + return 60000; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiReturnHomeInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiReturnHomeInstruction.java new file mode 100644 index 0000000..ca0e908 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiReturnHomeInstruction.java @@ -0,0 +1,58 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆返航指令 + */ +@Slf4j +public class DjiReturnHomeInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_RETURN_HOME"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + log.info("发送大疆返航指令: sn={}", sn); + + String topic = "dji/" + sn + "/command"; + String payload = "{\"cmd\":\"returnHome\"}"; + log.debug("MQTT发送: topic={}, payload={}", topic, payload); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/response") + .fieldPath("cmd") + .expectedValue("returnHome") + .timeoutMs(10000) + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/state") + .fieldPath("droneState") + .expectedValue("RETURNING") + .timeoutMs(60000) + .build(); + } + + @Override + public long getTimeoutMs() { + return 120000; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiStartMissionInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiStartMissionInstruction.java new file mode 100644 index 0000000..e423db8 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiStartMissionInstruction.java @@ -0,0 +1,60 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆开始航线任务指令 + */ +@Slf4j +public class DjiStartMissionInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_START_MISSION"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + Object missionId = context.getCommandParam("missionId"); + + log.info("发送大疆开始航线任务指令: sn={}, missionId={}", sn, missionId); + + String topic = "dji/" + sn + "/command"; + String payload = String.format("{\"cmd\":\"startMission\",\"missionId\":\"%s\"}", missionId); + log.debug("MQTT发送: topic={}, payload={}", topic, payload); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/response") + .fieldPath("cmd") + .expectedValue("startMission") + .timeoutMs(10000) + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + return CallbackConfig.builder() + .topic("dji/" + sn + "/state") + .fieldPath("droneState") + .expectedValue("FLYING") + .timeoutMs(60000) + .build(); + } + + @Override + public long getTimeoutMs() { + return 120000; + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiTakeOffInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiTakeOffInstruction.java new file mode 100644 index 0000000..f8b56c1 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/dji/instruction/DjiTakeOffInstruction.java @@ -0,0 +1,65 @@ +package com.ruoyi.device.domain.impl.machine.vendor.dji.instruction; + + +import com.ruoyi.device.domain.impl.machine.instruction.AbstractInstruction; +import com.ruoyi.device.domain.impl.machine.instruction.CallbackConfig; +import com.ruoyi.device.domain.impl.machine.instruction.InstructionContext; +import lombok.extern.slf4j.Slf4j; + +/** + * 大疆起飞指令 + */ +@Slf4j +public class DjiTakeOffInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "DJI_TAKE_OFF"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + log.info("发送大疆起飞指令: sn={}", sn); + + // 通过 context 获取 MqttClient 并发送消息 + String topic = "dji/" + sn + "/command"; + String payload = "{\"cmd\":\"takeoff\"}"; + + context.getMqttClient().sendMessage(topic, payload); + log.debug("MQTT发送成功: topic={}, payload={}", topic, payload); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { +// return null; + String sn = context.getSn(); + + // 方法回调:等待起飞指令的ACK响应 + return CallbackConfig.builder() + .topic("dji/" + sn + "/response") + .fieldPath("data.result") + .expectedValue("takeoff") + .timeoutMs(10000) // 10秒超时 + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { +// return null; + String sn = context.getSn(); + + // 状态回调:等待无人机状态变为飞行中 + return CallbackConfig.builder() + .topic("dji/" + sn + "/state") + .fieldPath("droneState") + .expectedValue("FLYING") + .timeoutMs(10000) // 10秒超时 + .build(); + } + + @Override + public long getTimeoutMs() { + return 10000; // 10秒总超时 + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/InMemorySnVendorMappingRepository.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/InMemorySnVendorMappingRepository.java new file mode 100644 index 0000000..c725747 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/InMemorySnVendorMappingRepository.java @@ -0,0 +1,53 @@ +package com.ruoyi.device.domain.impl.machine.vendor.repository; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 基于内存的 SN 到厂家类型映射持久化存储实现 + * 适用于单节点部署或开发测试环境 + * + * 注意:这是默认实现,当没有配置数据库时使用 + * + * 使用方式: + * 1. 不配置 machine.sn.repository.type(默认使用内存实现) + * 2. 或在 application.properties 中配置:machine.sn.repository.type=memory + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "machine.sn.repository.type", havingValue = "memory", matchIfMissing = true) +public class InMemorySnVendorMappingRepository implements SnVendorMappingRepository { + + /** + * SN -> 厂家类型(模拟数据库存储) + */ + private final Map dataStore = new ConcurrentHashMap<>(); + + @Override + public String findVendorTypeBySn(String sn) { + String vendorType = dataStore.get(sn); + log.debug("从内存数据库查询 SN 映射: sn={}, vendorType={}", sn, vendorType); + return vendorType; + } + + @Override + public void save(String sn, String vendorType) { + dataStore.put(sn, vendorType); + log.debug("保存 SN 映射到内存数据库: sn={}, vendorType={}", sn, vendorType); + } + + @Override + public void delete(String sn) { + dataStore.remove(sn); + log.debug("从内存数据库删除 SN 映射: sn={}", sn); + } + + @Override + public boolean exists(String sn) { + return dataStore.containsKey(sn); + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/MysqlSnVendorMappingRepository.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/MysqlSnVendorMappingRepository.java new file mode 100644 index 0000000..4968396 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/MysqlSnVendorMappingRepository.java @@ -0,0 +1,97 @@ +package com.ruoyi.device.domain.impl.machine.vendor.repository; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * 基于 MySQL 的 SN 到厂家类型映射持久化存储实现 + * 适用于生产环境 + * + * MySQL 表结构(示例): + * CREATE TABLE sn_vendor_mapping ( + * sn VARCHAR(64) PRIMARY KEY COMMENT '设备SN号', + * vendor_type VARCHAR(32) NOT NULL COMMENT '厂家类型', + * created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + * updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + * INDEX idx_vendor_type (vendor_type) + * ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='SN到厂家类型映射表'; + * + * 使用方式: + * 1. 在 application.properties 中配置:machine.sn.repository.type=mysql + * 2. 配置 MySQL 数据源 + * 3. 创建上述表结构 + * 4. 实现下面的 CRUD 方法 + * + * 注意:当前为空实现,需要在连接数据库后完善 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "machine.sn.repository.type", havingValue = "mysql") +public class MysqlSnVendorMappingRepository implements SnVendorMappingRepository { + + // TODO: 注入 JdbcTemplate 或 MyBatis Mapper + // private final JdbcTemplate jdbcTemplate; + // + // public MysqlSnVendorMappingRepository(JdbcTemplate jdbcTemplate) { + // this.jdbcTemplate = jdbcTemplate; + // } + + public MysqlSnVendorMappingRepository() { + log.warn("使用 MySQL SN 映射持久化存储实现,但当前为空实现,请在连接数据库后完善"); + } + + @Override + public String findVendorTypeBySn(String sn) { + // TODO: 实现从 MySQL 查询 + // try { + // return jdbcTemplate.queryForObject( + // "SELECT vendor_type FROM sn_vendor_mapping WHERE sn = ?", + // String.class, + // sn + // ); + // } catch (EmptyResultDataAccessException e) { + // log.debug("MySQL 中不存在 SN 映射: sn={}", sn); + // return null; + // } + + log.warn("MySQL SN 映射持久化存储未实现,返回 null: sn={}", sn); + return null; + } + + @Override + public void save(String sn, String vendorType) { + // TODO: 实现保存到 MySQL + // jdbcTemplate.update( + // "INSERT INTO sn_vendor_mapping (sn, vendor_type) VALUES (?, ?) " + + // "ON DUPLICATE KEY UPDATE vendor_type = ?, updated_at = CURRENT_TIMESTAMP", + // sn, vendorType, vendorType + // ); + // log.debug("保存 SN 映射到 MySQL: sn={}, vendorType={}", sn, vendorType); + + log.warn("MySQL SN 映射持久化存储未实现,跳过保存: sn={}, vendorType={}", sn, vendorType); + } + + @Override + public void delete(String sn) { + // TODO: 实现从 MySQL 删除 + // jdbcTemplate.update("DELETE FROM sn_vendor_mapping WHERE sn = ?", sn); + // log.debug("从 MySQL 删除 SN 映射: sn={}", sn); + + log.warn("MySQL SN 映射持久化存储未实现,跳过删除: sn={}", sn); + } + + @Override + public boolean exists(String sn) { + // TODO: 实现检查 MySQL 中是否存在 + // Integer count = jdbcTemplate.queryForObject( + // "SELECT COUNT(*) FROM sn_vendor_mapping WHERE sn = ?", + // Integer.class, + // sn + // ); + // return count != null && count > 0; + + log.warn("MySQL SN 映射持久化存储未实现,返回 false: sn={}", sn); + return false; + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/SnVendorMappingRepository.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/SnVendorMappingRepository.java new file mode 100644 index 0000000..bc02d55 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/repository/SnVendorMappingRepository.java @@ -0,0 +1,46 @@ +package com.ruoyi.device.domain.impl.machine.vendor.repository; + +/** + * SN 到厂家类型映射的持久化存储接口 + * 提供数据库层的 CRUD 操作抽象,支持多种实现(内存、MySQL等) + * + * 职责说明: + * - 这是持久化层(数据库层)的抽象 + * - 与 SnVendorMappingStore 的区别: + * - SnVendorMappingStore:包含缓存逻辑(Redis + Repository) + * - SnVendorMappingRepository:纯粹的持久化存储(数据库) + */ +public interface SnVendorMappingRepository { + + /** + * 从持久化存储中查询 SN 对应的厂家类型 + * + * @param sn 设备SN号 + * @return 厂家类型,如果不存在返回 null + */ + String findVendorTypeBySn(String sn); + + /** + * 保存 SN 到厂家类型的映射到持久化存储 + * 如果已存在则更新 + * + * @param sn 设备SN号 + * @param vendorType 厂家类型 + */ + void save(String sn, String vendorType); + + /** + * 从持久化存储中删除 SN 的映射关系 + * + * @param sn 设备SN号 + */ + void delete(String sn); + + /** + * 检查持久化存储中是否存在 SN 的映射关系 + * + * @param sn 设备SN号 + * @return 是否存在 + */ + boolean exists(String sn); +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/InMemorySnVendorMappingStore.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/InMemorySnVendorMappingStore.java new file mode 100644 index 0000000..3b9c058 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/InMemorySnVendorMappingStore.java @@ -0,0 +1,47 @@ +package com.ruoyi.device.domain.impl.machine.vendor.store; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 基于内存的 SN 到厂家类型映射存储实现 + * 适用于单节点部署或开发测试环境 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "machine.state.store.type", havingValue = "memory", matchIfMissing = true) +public class InMemorySnVendorMappingStore implements SnVendorMappingStore { + + /** + * SN -> 厂家类型 + */ + private final Map snToVendorMap = new ConcurrentHashMap<>(); + + @Override + public String getVendorType(String sn) { + String vendorType = snToVendorMap.get(sn); + log.debug("从内存获取 SN 映射: sn={}, vendorType={}", sn, vendorType); + return vendorType; + } + + @Override + public void saveMapping(String sn, String vendorType) { + snToVendorMap.put(sn, vendorType); + log.debug("保存 SN 映射到内存: sn={}, vendorType={}", sn, vendorType); + } + + @Override + public void removeMapping(String sn) { + snToVendorMap.remove(sn); + log.debug("从内存中移除 SN 映射: sn={}", sn); + } + + @Override + public boolean exists(String sn) { + return snToVendorMap.containsKey(sn); + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/RedisSnVendorMappingStore.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/RedisSnVendorMappingStore.java new file mode 100644 index 0000000..3a09063 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/RedisSnVendorMappingStore.java @@ -0,0 +1,117 @@ +package com.ruoyi.device.domain.impl.machine.vendor.store; + + +import com.ruoyi.device.domain.impl.machine.vendor.repository.SnVendorMappingRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; + +/** + * 基于 Redis + Repository 的 SN 到厂家类型映射存储实现 + * 适用于多节点部署的生产环境 + * + * 架构说明: + * 1. Redis 作为缓存层,提供快速查询 + * 2. Repository 作为持久化层(支持内存/MySQL等实现) + * 3. 查询流程:Redis → Repository → 回写 Redis + * + * Redis 数据结构: + * - Key: machine:sn:vendor:{sn} + * - Value: {vendorType} + * - TTL: 86400 秒(24小时) + * + * 使用方式: + * 1. 在 application.properties 中配置:machine.state.store.type=redis + * 2. 配置 Redis 连接信息 + * 3. Repository 会根据配置自动选择实现(内存/MySQL) + * 4. 实现 Redis 的缓存逻辑 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "machine.state.store.type", havingValue = "redis") +public class RedisSnVendorMappingStore implements SnVendorMappingStore { + + private final StringRedisTemplate redisTemplate; + + /** + * 持久化存储层(支持内存/MySQL等实现) + */ + private final SnVendorMappingRepository repository; + + // Redis key 前缀 + private static final String KEY_PREFIX = "machine:sn:vendor:"; + + // 配置缓存过期时间 + private static final long CACHE_EXPIRE_SECONDS = 86400; // 24小时 + + public RedisSnVendorMappingStore(StringRedisTemplate redisTemplate, + SnVendorMappingRepository repository) { + this.redisTemplate = redisTemplate; + this.repository = repository; + log.info("使用 Redis+Repository SN 映射存储实现"); + log.info("持久化层实现: {}", repository.getClass().getSimpleName()); + } + + @Override + public String getVendorType(String sn) { + // 1. 先从 Redis 缓存获取 + String key = KEY_PREFIX + sn; + String vendorType = redisTemplate.opsForValue().get(key); + if (vendorType != null) { + log.debug("从 Redis 缓存获取 SN 映射: sn={}, vendorType={}", sn, vendorType); + return vendorType; + } + + // 2. Redis 没有,从 Repository 持久化层获取 + vendorType = repository.findVendorTypeBySn(sn); + + if (vendorType != null) { + // 3. 获取到后存入 Redis 缓存 + redisTemplate.opsForValue().set(key, vendorType, CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS); + log.debug("从 Repository 获取 SN 映射并缓存到 Redis: sn={}, vendorType={}", sn, vendorType); + return vendorType; + } + + log.debug("Repository 中不存在 SN 映射: sn={}", sn); + return null; + } + + @Override + public void saveMapping(String sn, String vendorType) { + // 1. 保存到 Repository 持久化层 + repository.save(sn, vendorType); + + // 2. 保存到 Redis 缓存 + String key = KEY_PREFIX + sn; + redisTemplate.opsForValue().set(key, vendorType, CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS); + + log.debug("保存 SN 映射到 Redis+Repository: sn={}, vendorType={}", sn, vendorType); + } + + @Override + public void removeMapping(String sn) { + // 1. 从 Repository 删除 + repository.delete(sn); + + // 2. 从 Redis 删除 + String key = KEY_PREFIX + sn; + redisTemplate.delete(key); + + log.debug("从 Redis+Repository 中移除 SN 映射: sn={}", sn); + } + + @Override + public boolean exists(String sn) { + // 1. 先检查 Redis + String key = KEY_PREFIX + sn; + if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) { + return true; + } + + // 2. 再检查 Repository + return repository.exists(sn); + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/SnVendorMappingStore.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/SnVendorMappingStore.java new file mode 100644 index 0000000..f0e4b08 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/store/SnVendorMappingStore.java @@ -0,0 +1,49 @@ +package com.ruoyi.device.domain.impl.machine.vendor.store; +/** + * SN 到厂家类型映射的存储接口 + * 提供 SN 与厂家类型映射关系的存储和获取抽象,支持多种实现(内存、Redis+MySQL等) + * + * 数据流: + * 1. 先从 Redis 缓存获取 + * 2. Redis 没有则从 MySQL 数据库获取 + * 3. 获取到后存入 Redis 缓存 + * 4. 都没有则返回 null + */ +public interface SnVendorMappingStore { + + /** + * 获取 SN 对应的厂家类型 + * + * 查询顺序: + * 1. 先从 Redis 缓存获取 + * 2. Redis 没有则从 MySQL 数据库获取 + * 3. 获取到后存入 Redis 缓存 + * + * @param sn 设备SN号 + * @return 厂家类型,如果不存在返回 null + */ + String getVendorType(String sn); + + /** + * 保存 SN 到厂家类型的映射 + * + * @param sn 设备SN号 + * @param vendorType 厂家类型 + */ + void saveMapping(String sn, String vendorType); + + /** + * 删除 SN 的映射关系 + * + * @param sn 设备SN号 + */ + void removeMapping(String sn); + + /** + * 检查 SN 是否已有映射关系 + * + * @param sn 设备SN号 + * @return 是否存在映射 + */ + boolean exists(String sn); +} \ No newline at end of file From a6b37bd26964c6f46fd798c053ec4bb3690254f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 13:57:03 +0800 Subject: [PATCH 02/17] =?UTF-8?q?=E8=BF=81=E7=A7=BBMQTT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 7 + .../device/domain/impl/djimqtt/README.md | 120 +++++++ .../djimqtt/callback/IDockDataCallback.java | 18 + .../djimqtt/callback/IDroneDataCallback.java | 18 + .../djimqtt/config/DjiMqttProperties.java | 66 ++++ .../djimqtt/example/DjiMqttUsageExample.java | 116 ++++++ .../handler/DjiMqttMessageHandler.java | 216 ++++++++++++ .../impl/djimqtt/model/DjiMqttMessage.java | 43 +++ .../domain/impl/djimqtt/model/DockData.java | 291 ++++++++++++++++ .../domain/impl/djimqtt/model/DroneData.java | 329 ++++++++++++++++++ .../djimqtt/model/dock/AirConditioner.java | 27 ++ .../djimqtt/model/dock/BackupBattery.java | 33 ++ .../djimqtt/model/dock/DockBatteryDetail.java | 39 +++ .../dock/DroneBatteryMaintenanceInfo.java | 41 +++ .../djimqtt/model/dock/DroneChargeState.java | 27 ++ .../impl/djimqtt/model/dock/NetworkState.java | 33 ++ .../impl/djimqtt/model/dock/WirelessLink.java | 75 ++++ .../djimqtt/model/drone/BatteryDetail.java | 75 ++++ .../impl/djimqtt/model/drone/BatteryInfo.java | 47 +++ .../impl/djimqtt/model/drone/CameraInfo.java | 167 +++++++++ .../impl/djimqtt/model/drone/CenterNode.java | 27 ++ .../model/drone/DistanceLimitStatus.java | 33 ++ .../djimqtt/model/drone/IrMeteringArea.java | 57 +++ .../djimqtt/model/drone/IrMeteringPoint.java | 33 ++ .../impl/djimqtt/model/drone/LeafNode.java | 33 ++ .../model/drone/LiveviewWorldRegion.java | 39 +++ .../djimqtt/model/drone/MaintainStatus.java | 23 ++ .../model/drone/MaintainStatusItem.java | 45 +++ .../model/drone/ObstacleAvoidance.java | 33 ++ .../djimqtt/model/drone/PositionState.java | 39 +++ .../impl/djimqtt/model/drone/StorageInfo.java | 27 ++ .../djimqtt/model/drone/TemperaturePoint.java | 33 ++ .../djimqtt/model/drone/WirelessLinkTopo.java | 35 ++ .../djimqtt/service/DjiMqttClientService.java | 159 +++++++++ src/main/resources/bootstrap.yml | 21 ++ 35 files changed, 2425 insertions(+) create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/callback/IDockDataCallback.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/callback/IDroneDataCallback.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/config/DjiMqttProperties.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/example/DjiMqttUsageExample.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/handler/DjiMqttMessageHandler.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DockData.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/AirConditioner.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/BackupBattery.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DockBatteryDetail.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DroneBatteryMaintenanceInfo.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DroneChargeState.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/NetworkState.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/WirelessLink.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/BatteryDetail.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/BatteryInfo.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/CameraInfo.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/CenterNode.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/DistanceLimitStatus.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/IrMeteringArea.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/IrMeteringPoint.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/LeafNode.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/LiveviewWorldRegion.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/MaintainStatus.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/MaintainStatusItem.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/ObstacleAvoidance.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/PositionState.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/StorageInfo.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/TemperaturePoint.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/WirelessLinkTopo.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java diff --git a/pom.xml b/pom.xml index ea47435..b6a0a18 100644 --- a/pom.xml +++ b/pom.xml @@ -105,6 +105,13 @@ provided + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + 1.2.5 + + diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md new file mode 100644 index 0000000..bd935c8 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md @@ -0,0 +1,120 @@ +# DJI MQTT 模块使用说明 + +## 概述 + +本模块实现了大疆MQTT消息的接收和处理功能,采用共享订阅方式,支持多实例部署。 + +## 功能特性 + +- ✅ 自动连接和重连 +- ✅ 共享订阅(多实例部署时只有一个实例消费消息) +- ✅ 自动区分无人机和机场数据 +- ✅ 回调接口设计,使用者无需关心osd和state的区别 +- ✅ 完整的数据模型定义 +- ✅ 原始数据保留(rawData字段) + +## 架构设计 + +``` +DjiMqttClientService (MQTT客户端) + ↓ +DjiMqttMessageHandler (消息处理器) + ↓ +IDroneDataCallback / IDockDataCallback (回调接口) + ↓ +使用者实现 +``` + +## 配置说明 + +在 `bootstrap.yml` 中配置: + +```yaml +dji: + mqtt: + host: mqtt.t-aaron.com + port: 10883 + version: 5 + client-id: ThingsBoard_gateway + username: admin + password: admin + connection-timeout: 30 + keep-alive-interval: 60 + auto-reconnect: true + clean-session: false +``` + +## 使用方法 + +### 1. 注入消息处理器 + +```java +@Autowired +private DjiMqttMessageHandler messageHandler; +``` + +### 2. 实现回调接口 + +```java +// 无人机数据回调 +messageHandler.registerDroneDataCallback(new IDroneDataCallback() { + @Override + public void onDroneData(DroneData droneData) { + // 处理无人机数据 + String sn = droneData.getDeviceSn(); + Double latitude = droneData.getLatitude(); + Double longitude = droneData.getLongitude(); + + // 访问原始数据 + Map rawData = droneData.getRawData(); + } +}); + +// 机场数据回调 +messageHandler.registerDockDataCallback(new IDockDataCallback() { + @Override + public void onDockData(DockData dockData) { + // 处理机场数据 + String sn = dockData.getDeviceSn(); + Integer modeCode = dockData.getModeCode(); + Float temperature = dockData.getTemperature(); + + // 访问原始数据 + Map rawData = dockData.getRawData(); + } +}); +``` + +## 数据模型 + +### DroneData(无人机数据) + +主要字段: +- `deviceSn`: 设备SN +- `messageType`: 消息类型(osd/state) +- `latitude/longitude`: 位置信息 +- `elevation/height`: 高度信息 +- `modeCode`: 飞行器状态 +- `rawData`: 原始数据(包含所有字段) + +### DockData(机场数据) + +主要字段: +- `deviceSn`: 设备SN +- `messageType`: 消息类型(osd/state) +- `latitude/longitude`: 位置信息 +- `modeCode`: 机场状态 +- `temperature/humidity`: 环境信息 +- `coverState`: 舱盖状态 +- `rawData`: 原始数据(包含所有字段) + +## 注意事项 + +1. **部分字段推送**:每次MQTT消息可能只包含部分字段,使用时需要判空 +2. **原始数据访问**:所有字段都保存在`rawData`中,可以通过Map访问 +3. **共享订阅**:多实例部署时,同一条消息只会被一个实例消费 +4. **自动重连**:连接断开后会自动重连 + +## 示例代码 + +参考 `DjiMqttUsageExample.java` 获取完整示例。 diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/callback/IDockDataCallback.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/callback/IDockDataCallback.java new file mode 100644 index 0000000..9fb14d6 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/callback/IDockDataCallback.java @@ -0,0 +1,18 @@ +package com.ruoyi.device.domain.impl.djimqtt.callback; + +import com.ruoyi.device.domain.impl.djimqtt.model.DockData; + +/** + * 机场数据回调接口 + * + * @author ruoyi + */ +public interface IDockDataCallback { + + /** + * 处理机场数据 + * + * @param dockData 机场数据 + */ + void onDockData(DockData dockData); +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/callback/IDroneDataCallback.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/callback/IDroneDataCallback.java new file mode 100644 index 0000000..4bb7f74 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/callback/IDroneDataCallback.java @@ -0,0 +1,18 @@ +package com.ruoyi.device.domain.impl.djimqtt.callback; + +import com.ruoyi.device.domain.impl.djimqtt.model.DroneData; + +/** + * 无人机数据回调接口 + * + * @author ruoyi + */ +public interface IDroneDataCallback { + + /** + * 处理无人机数据 + * + * @param droneData 无人机数据 + */ + void onDroneData(DroneData droneData); +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/config/DjiMqttProperties.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/config/DjiMqttProperties.java new file mode 100644 index 0000000..4be0041 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/config/DjiMqttProperties.java @@ -0,0 +1,66 @@ +package com.ruoyi.device.domain.impl.djimqtt.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * DJI MQTT配置属性 + * + * @author ruoyi + */ +@Data +@Component +@ConfigurationProperties(prefix = "dji.mqtt") +public class DjiMqttProperties { + + /** + * MQTT服务器地址 + */ + private String host = "mqtt.t-aaron.com"; + + /** + * MQTT服务器端口 + */ + private Integer port = 10883; + + /** + * MQTT协议版本 + */ + private Integer version = 5; + + /** + * 客户端ID + */ + private String clientId = "ThingsBoard_gateway"; + + /** + * 用户名 + */ + private String username = "admin"; + + /** + * 密码 + */ + private String password = "admin"; + + /** + * 连接超时时间(秒) + */ + private Integer connectionTimeout = 30; + + /** + * 保持连接时间(秒) + */ + private Integer keepAliveInterval = 60; + + /** + * 自动重连 + */ + private Boolean autoReconnect = true; + + /** + * 清除会话 + */ + private Boolean cleanSession = false; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/example/DjiMqttUsageExample.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/example/DjiMqttUsageExample.java new file mode 100644 index 0000000..9838ea6 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/example/DjiMqttUsageExample.java @@ -0,0 +1,116 @@ +package com.ruoyi.device.domain.impl.djimqtt.example; + +import com.ruoyi.device.domain.impl.djimqtt.callback.IDockDataCallback; +import com.ruoyi.device.domain.impl.djimqtt.callback.IDroneDataCallback; +import com.ruoyi.device.domain.impl.djimqtt.handler.DjiMqttMessageHandler; +import com.ruoyi.device.domain.impl.djimqtt.model.DockData; +import com.ruoyi.device.domain.impl.djimqtt.model.DroneData; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * DJI MQTT使用示例 + * + * 使用说明: + * 1. 注入 DjiMqttMessageHandler + * 2. 实现 IDroneDataCallback 或 IDockDataCallback 接口 + * 3. 在应用启动后注册回调 + * 4. 在回调方法中处理接收到的数据 + * + * @author ruoyi + */ +@Slf4j +@Component +public class DjiMqttUsageExample { + + @Autowired + private DjiMqttMessageHandler messageHandler; + + /** + * 应用启动后注册回调 + */ + @EventListener(ApplicationReadyEvent.class) + public void registerCallbacks() { + // 注册无人机数据回调 + messageHandler.registerDroneDataCallback(new IDroneDataCallback() { + @Override + public void onDroneData(DroneData droneData) { + handleDroneData(droneData); + } + }); + + // 注册机场数据回调 + messageHandler.registerDockDataCallback(new IDockDataCallback() { + @Override + public void onDockData(DockData dockData) { + handleDockData(dockData); + } + }); + + log.info("DJI MQTT回调已注册"); + } + + /** + * 处理无人机数据 + */ + private void handleDroneData(DroneData droneData) { + log.info("收到无人机数据 - SN: {}, Type: {}", + droneData.getDeviceSn(), + droneData.getMessageType()); + + // 示例:处理位置信息 + if (droneData.getLatitude() != null && droneData.getLongitude() != null) { + log.info("无人机位置 - 纬度: {}, 经度: {}, 高度: {}", + droneData.getLatitude(), + droneData.getLongitude(), + droneData.getElevation()); + } + + // 示例:处理电池信息(从rawData中获取) + if (droneData.getRawData() != null && droneData.getRawData().containsKey("battery")) { + Object battery = droneData.getRawData().get("battery"); + log.info("无人机电池信息: {}", battery); + } + + // 示例:处理相机信息 + if (droneData.getRawData() != null && droneData.getRawData().containsKey("cameras")) { + Object cameras = droneData.getRawData().get("cameras"); + log.info("无人机相机信息: {}", cameras); + } + } + + /** + * 处理机场数据 + */ + private void handleDockData(DockData dockData) { + log.info("收到机场数据 - SN: {}, Type: {}", + dockData.getDeviceSn(), + dockData.getMessageType()); + + // 示例:处理机场状态 + if (dockData.getModeCode() != null) { + log.info("机场状态: {}", dockData.getModeCode()); + } + + // 示例:处理环境信息 + if (dockData.getTemperature() != null) { + log.info("机场温度: {}°C, 湿度: {}%", + dockData.getTemperature(), + dockData.getHumidity()); + } + + // 示例:处理舱盖状态 + if (dockData.getCoverState() != null) { + log.info("舱盖状态: {}", dockData.getCoverState()); + } + + // 示例:处理飞行器充电状态(从rawData中获取) + if (dockData.getRawData() != null && dockData.getRawData().containsKey("drone_charge_state")) { + Object chargeState = dockData.getRawData().get("drone_charge_state"); + log.info("飞行器充电状态: {}", chargeState); + } + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/handler/DjiMqttMessageHandler.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/handler/DjiMqttMessageHandler.java new file mode 100644 index 0000000..e6489c6 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/handler/DjiMqttMessageHandler.java @@ -0,0 +1,216 @@ +package com.ruoyi.device.domain.impl.djimqtt.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.device.domain.impl.djimqtt.callback.IDockDataCallback; +import com.ruoyi.device.domain.impl.djimqtt.callback.IDroneDataCallback; +import com.ruoyi.device.domain.impl.djimqtt.model.DjiMqttMessage; +import com.ruoyi.device.domain.impl.djimqtt.model.DockData; +import com.ruoyi.device.domain.impl.djimqtt.model.DroneData; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * DJI MQTT消息处理器 + * + * @author ruoyi + */ +@Slf4j +@Component +public class DjiMqttMessageHandler { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * 无人机数据回调列表 + */ + private final List droneDataCallbacks = new ArrayList<>(); + + /** + * 机场数据回调列表 + */ + private final List dockDataCallbacks = new ArrayList<>(); + + /** + * 无人机SN正则表达式(根据文档示例:1581F6Q8X251C00G04H8) + */ + private static final Pattern DRONE_SN_PATTERN = Pattern.compile("^[0-9A-Z]{20}$"); + + /** + * 机场SN正则表达式(根据文档示例:7CTXN5K00B0AXM) + */ + private static final Pattern DOCK_SN_PATTERN = Pattern.compile("^[0-9A-Z]{14}$"); + + /** + * 注册无人机数据回调 + * + * @param callback 回调接口 + */ + public void registerDroneDataCallback(IDroneDataCallback callback) { + if (callback != null && !droneDataCallbacks.contains(callback)) { + droneDataCallbacks.add(callback); + log.info("注册无人机数据回调: {}", callback.getClass().getSimpleName()); + } + } + + /** + * 注册机场数据回调 + * + * @param callback 回调接口 + */ + public void registerDockDataCallback(IDockDataCallback callback) { + if (callback != null && !dockDataCallbacks.contains(callback)) { + dockDataCallbacks.add(callback); + log.info("注册机场数据回调: {}", callback.getClass().getSimpleName()); + } + } + + /** + * 处理MQTT消息 + * + * @param topic 主题 + * @param payload 消息内容 + */ + public void handleMessage(String topic, String payload) { + try { + log.debug("收到MQTT消息 - Topic: {}, Payload: {}", topic, payload); + + // 解析设备SN和消息类型 + String deviceSn = extractDeviceSnFromTopic(topic); + String messageType = extractMessageTypeFromTopic(topic); + + if (deviceSn == null || messageType == null) { + log.warn("无法从Topic解析设备SN或消息类型: {}", topic); + return; + } + + // 解析JSON消息 + @SuppressWarnings("unchecked") + DjiMqttMessage> message = objectMapper.readValue( + payload, + objectMapper.getTypeFactory().constructParametricType( + DjiMqttMessage.class, + Map.class + ) + ); + + // 判断是无人机还是机场 + if (isDroneSn(deviceSn)) { + handleDroneMessage(deviceSn, messageType, message); + } else if (isDockSn(deviceSn)) { + handleDockMessage(deviceSn, messageType, message); + } else { + log.warn("未知的设备SN格式: {}", deviceSn); + } + + } catch (Exception e) { + log.error("处理MQTT消息失败 - Topic: {}, Error: {}", topic, e.getMessage(), e); + } + } + + /** + * 处理无人机消息 + */ + private void handleDroneMessage(String deviceSn, String messageType, DjiMqttMessage> message) { + try { + DroneData droneData = objectMapper.convertValue(message.getData(), DroneData.class); + droneData.setDeviceSn(deviceSn); + droneData.setMessageType(messageType); + droneData.setRawData(message.getData()); + + log.debug("处理无人机数据 - SN: {}, Type: {}", deviceSn, messageType); + + // 通知所有回调 + for (IDroneDataCallback callback : droneDataCallbacks) { + try { + callback.onDroneData(droneData); + } catch (Exception e) { + log.error("无人机数据回调执行失败: {}", e.getMessage(), e); + } + } + } catch (Exception e) { + log.error("处理无人机消息失败 - SN: {}, Error: {}", deviceSn, e.getMessage(), e); + } + } + + /** + * 处理机场消息 + */ + private void handleDockMessage(String deviceSn, String messageType, DjiMqttMessage> message) { + try { + DockData dockData = objectMapper.convertValue(message.getData(), DockData.class); + dockData.setDeviceSn(deviceSn); + dockData.setMessageType(messageType); + dockData.setRawData(message.getData()); + + log.debug("处理机场数据 - SN: {}, Type: {}", deviceSn, messageType); + + // 通知所有回调 + for (IDockDataCallback callback : dockDataCallbacks) { + try { + callback.onDockData(dockData); + } catch (Exception e) { + log.error("机场数据回调执行失败: {}", e.getMessage(), e); + } + } + } catch (Exception e) { + log.error("处理机场消息失败 - SN: {}, Error: {}", deviceSn, e.getMessage(), e); + } + } + + /** + * 从Topic中提取设备SN + * Topic格式: thing/product/{deviceSn}/osd 或 thing/product/{deviceSn}/state + */ + private String extractDeviceSnFromTopic(String topic) { + if (topic == null) { + return null; + } + String[] parts = topic.split("/"); + if (parts.length >= 3) { + return parts[2]; + } + return null; + } + + /** + * 从Topic中提取消息类型 + */ + private String extractMessageTypeFromTopic(String topic) { + if (topic == null) { + return null; + } + String[] parts = topic.split("/"); + if (parts.length >= 4) { + return parts[3]; // osd 或 state + } + return null; + } + + /** + * 判断是否为无人机SN + */ + private boolean isDroneSn(String sn) { + if (sn == null) { + return false; + } + Matcher matcher = DRONE_SN_PATTERN.matcher(sn); + return matcher.matches(); + } + + /** + * 判断是否为机场SN + */ + private boolean isDockSn(String sn) { + if (sn == null) { + return false; + } + Matcher matcher = DOCK_SN_PATTERN.matcher(sn); + return matcher.matches(); + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java new file mode 100644 index 0000000..52d529e --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java @@ -0,0 +1,43 @@ +package com.ruoyi.device.domain.impl.djimqtt.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * DJI MQTT消息基础结构 + * + * @author ruoyi + */ +@Data +public class DjiMqttMessage { + + /** + * 事务ID + */ + @JsonProperty("tid") + private String tid; + + /** + * 业务ID + */ + @JsonProperty("bid") + private String bid; + + /** + * 时间戳 + */ + @JsonProperty("timestamp") + private Long timestamp; + + /** + * 数据内容 + */ + @JsonProperty("data") + private T data; + + /** + * 网关设备SN + */ + @JsonProperty("gateway") + private String gateway; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DockData.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DockData.java new file mode 100644 index 0000000..39551a3 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DockData.java @@ -0,0 +1,291 @@ +package com.ruoyi.device.domain.impl.djimqtt.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.ruoyi.device.domain.impl.djimqtt.model.dock.*; +import com.ruoyi.device.domain.impl.djimqtt.model.drone.MaintainStatus; +import com.ruoyi.device.domain.impl.djimqtt.model.drone.PositionState; +import com.ruoyi.device.domain.impl.djimqtt.model.drone.StorageInfo; +import lombok.Data; + +import java.util.Map; + +/** + * 机场完整数据(从osd和state主题接收) + * 注意:每次推送可能只包含部分字段 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class DockData { + + // ========== 元数据 ========== + + /** + * 设备SN(从topic中解析) + */ + private String deviceSn; + + /** + * 消息类型(osd或state) + */ + private String messageType; + + /** + * 原始数据(用于存储所有字段) + */ + private Map rawData; + + // ========== 基础信息 ========== + + /** + * Home 点有效性:0-无效,1-有效 + */ + @JsonProperty("home_position_is_valid") + private Integer homePositionIsValid; + + /** + * 机场朝向角 + */ + @JsonProperty("heading") + private Double heading; + + /** + * 固件版本 + */ + @JsonProperty("firmware_version") + private String firmwareVersion; + + /** + * 机场状态 + */ + @JsonProperty("mode_code") + private Integer modeCode; + + /** + * 机场任务状态 + */ + @JsonProperty("flighttask_step_code") + private Integer flighttaskStepCode; + + // ========== 位置信息 ========== + + /** + * 纬度 + */ + @JsonProperty("latitude") + private Double latitude; + + /** + * 经度 + */ + @JsonProperty("longitude") + private Double longitude; + + /** + * 椭球高度(米) + */ + @JsonProperty("height") + private Double height; + + // ========== 环境信息 ========== + + /** + * 舱内温度(摄氏度) + */ + @JsonProperty("temperature") + private Float temperature; + + /** + * 舱内湿度(%RH) + */ + @JsonProperty("humidity") + private Float humidity; + + /** + * 环境温度(摄氏度) + */ + @JsonProperty("environment_temperature") + private Float environmentTemperature; + + /** + * 风速(米/秒) + */ + @JsonProperty("wind_speed") + private Float windSpeed; + + /** + * 降雨量:0-无雨,1-小雨,2-中雨,3-大雨 + */ + @JsonProperty("rainfall") + private Integer rainfall; + + // ========== 设备状态 ========== + + /** + * 舱盖状态:0-关闭,1-打开,2-半开,3-舱盖状态异常 + */ + @JsonProperty("cover_state") + private Integer coverState; + + /** + * 飞行器是否在舱:0-舱外,1-舱内 + */ + @JsonProperty("drone_in_dock") + private Integer droneInDock; + + /** + * DRC链路状态:0-未连接,1-连接中,2-已连接 + */ + @JsonProperty("drc_state") + private Integer drcState; + + /** + * 紧急停止按钮状态:0-关闭,1-开启 + */ + @JsonProperty("emergency_stop_state") + private Integer emergencyStopState; + + /** + * 补光灯状态:0-关闭,1-打开 + */ + @JsonProperty("supplement_light_state") + private Integer supplementLightState; + + /** + * 机场声光报警状态:0-关闭,1-开启 + */ + @JsonProperty("alarm_state") + private Integer alarmState; + + // ========== 电池和电源 ========== + + /** + * 飞行器电池保养信息 + */ + @JsonProperty("drone_battery_maintenance_info") + private DroneBatteryMaintenanceInfo droneBatteryMaintenanceInfo; + + /** + * 飞行器充电状态 + */ + @JsonProperty("drone_charge_state") + private DroneChargeState droneChargeState; + + /** + * 机场备用电池信息 + */ + @JsonProperty("backup_battery") + private BackupBattery backupBattery; + + /** + * 电池运行模式:1-计划模式,2-待命模式 + */ + @JsonProperty("battery_store_mode") + private Integer batteryStoreMode; + + /** + * 工作电流(毫安) + */ + @JsonProperty("working_current") + private Float workingCurrent; + + /** + * 工作电压(毫伏) + */ + @JsonProperty("working_voltage") + private Integer workingVoltage; + + // ========== 网络和存储 ========== + + /** + * 网络状态 + */ + @JsonProperty("network_state") + private NetworkState networkState; + + /** + * 图传链路 + */ + @JsonProperty("wireless_link") + private WirelessLink wirelessLink; + + /** + * 存储容量 + */ + @JsonProperty("storage") + private StorageInfo storage; + + // ========== 空调和配置 ========== + + /** + * 机场空调工作状态信息 + */ + @JsonProperty("air_conditioner") + private AirConditioner airConditioner; + + /** + * 空中回传:false-关闭,true-开启 + */ + @JsonProperty("air_transfer_enable") + private Boolean airTransferEnable; + + /** + * 机场静音模式:0-非静音模式,1-静音模式 + */ + @JsonProperty("silent_mode") + private Integer silentMode; + + // ========== 保养和固件 ========== + + /** + * 保养信息 + */ + @JsonProperty("maintain_status") + private MaintainStatus maintainStatus; + + /** + * 搜星状态 + */ + @JsonProperty("position_state") + private PositionState positionState; + + /** + * 机场激活时间(unix 时间戳) + */ + @JsonProperty("activation_time") + private Integer activationTime; + + /** + * 固件升级状态:0-未升级,1-升级中 + */ + @JsonProperty("firmware_upgrade_status") + private Integer firmwareUpgradeStatus; + + /** + * 固件一致性:0-不需要一致性升级,1-需要一致性升级 + */ + @JsonProperty("compatible_status") + private Integer compatibleStatus; + + // ========== 统计信息 ========== + + /** + * 机场累计运行时长(秒) + */ + @JsonProperty("acc_time") + private Integer accTime; + + /** + * 机场累计作业次数 + */ + @JsonProperty("job_number") + private Integer jobNumber; + + /** + * 首次上电时间(毫秒) + */ + @JsonProperty("first_power_on") + private Integer firstPowerOn; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java new file mode 100644 index 0000000..1b359ee --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java @@ -0,0 +1,329 @@ +package com.ruoyi.device.domain.impl.djimqtt.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.ruoyi.device.domain.impl.djimqtt.model.drone.*; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * 无人机完整数据(从osd和state主题接收) + * 注意:每次推送可能只包含部分字段 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class DroneData { + + // ========== 元数据 ========== + + /** + * 设备SN(从topic中解析) + */ + private String deviceSn; + + /** + * 消息类型(osd或state) + */ + private String messageType; + + /** + * 原始数据(用于存储所有字段) + */ + private Map rawData; + + // ========== 基础信息 ========== + + /** + * 飞行器图传连接质量最好的网关SN + */ + @JsonProperty("best_link_gateway") + private String bestLinkGateway; + + /** + * 图传连接拓扑 + */ + @JsonProperty("wireless_link_topo") + private WirelessLinkTopo wirelessLinkTopo; + + /** + * 飞行器相机信息 + */ + @JsonProperty("cameras") + private List cameras; + + /** + * 飞行安全数据库版本 + */ + @JsonProperty("flysafe_database_version") + private String flysafeDatabaseVersion; + + /** + * 离线地图开关:false-关闭,true-开启 + */ + @JsonProperty("offline_map_enable") + private Boolean offlineMapEnable; + + // ========== 返航和限制 ========== + + /** + * 返航高度模式当前值:0-智能高度,1-设定高度 + */ + @JsonProperty("current_rth_mode") + private Integer currentRthMode; + + /** + * 返航高度模式设置值:0-智能高度,1-设定高度 + */ + @JsonProperty("rth_mode") + private Integer rthMode; + + /** + * 飞行器避障状态 + */ + @JsonProperty("obstacle_avoidance") + private ObstacleAvoidance obstacleAvoidance; + + /** + * 是否接近限飞区:0-未达到,1-接近 + */ + @JsonProperty("is_near_area_limit") + private Integer isNearAreaLimit; + + /** + * 是否接近设定的限制高度:0-未达到,1-接近 + */ + @JsonProperty("is_near_height_limit") + private Integer isNearHeightLimit; + + /** + * 飞行器限高(米) + */ + @JsonProperty("height_limit") + private Integer heightLimit; + + /** + * 飞行器限远状态 + */ + @JsonProperty("distance_limit_status") + private DistanceLimitStatus distanceLimitStatus; + + // ========== 设备状态 ========== + + /** + * 飞行器夜航灯状态:0-关闭,1-打开 + */ + @JsonProperty("night_lights_state") + private Integer nightLightsState; + + /** + * 飞行器激活时间(unix 时间戳) + */ + @JsonProperty("activation_time") + private Integer activationTime; + + /** + * 保养信息 + */ + @JsonProperty("maintain_status") + private MaintainStatus maintainStatus; + + /** + * 飞行器累计飞行总架次 + */ + @JsonProperty("total_flight_sorties") + private Integer totalFlightSorties; + + /** + * 飞行器累计飞行总里程(米) + */ + @JsonProperty("total_flight_distance") + private Float totalFlightDistance; + + /** + * 飞行器累计飞行航时(秒) + */ + @JsonProperty("total_flight_time") + private Float totalFlightTime; + + // ========== 位置信息 ========== + + /** + * 搜星状态 + */ + @JsonProperty("position_state") + private PositionState positionState; + + /** + * 存储容量 + */ + @JsonProperty("storage") + private StorageInfo storage; + + /** + * 当前位置纬度 + */ + @JsonProperty("latitude") + private Double latitude; + + /** + * 当前位置经度 + */ + @JsonProperty("longitude") + private Double longitude; + + /** + * 相对起飞点高度(米) + */ + @JsonProperty("elevation") + private Float elevation; + + /** + * 绝对高度(米) + */ + @JsonProperty("height") + private Double height; + + /** + * Home点纬度 + */ + @JsonProperty("home_latitude") + private Double homeLatitude; + + /** + * Home点经度 + */ + @JsonProperty("home_longitude") + private Double homeLongitude; + + /** + * 距离Home点的距离(米) + */ + @JsonProperty("home_distance") + private Float homeDistance; + + // ========== 姿态信息 ========== + + /** + * 偏航轴角度 + */ + @JsonProperty("attitude_head") + private Integer attitudeHead; + + /** + * 横滚轴角度 + */ + @JsonProperty("attitude_roll") + private Float attitudeRoll; + + /** + * 俯仰轴角度 + */ + @JsonProperty("attitude_pitch") + private Float attitudePitch; + + // ========== 速度和风速 ========== + + /** + * 水平速度(米/秒) + */ + @JsonProperty("horizontal_speed") + private Float horizontalSpeed; + + /** + * 垂直速度(米/秒) + */ + @JsonProperty("vertical_speed") + private Float verticalSpeed; + + /** + * 当前风向 + */ + @JsonProperty("wind_direction") + private Integer windDirection; + + /** + * 风速(米/秒) + */ + @JsonProperty("wind_speed") + private Float windSpeed; + + // ========== 电池信息 ========== + + /** + * 飞行器电池信息 + */ + @JsonProperty("battery") + private BatteryInfo battery; + + /** + * 严重低电量告警百分比 + */ + @JsonProperty("serious_low_battery_warning_threshold") + private Integer seriousLowBatteryWarningThreshold; + + /** + * 低电量告警百分比 + */ + @JsonProperty("low_battery_warning_threshold") + private Integer lowBatteryWarningThreshold; + + /** + * 返航预留电量百分比 + */ + @JsonProperty("remaining_power_for_return_home") + private Integer remainingPowerForReturnHome; + + // ========== 控制和状态 ========== + + /** + * 当前控制源 + */ + @JsonProperty("control_source") + private String controlSource; + + /** + * 固件升级状态:0-未升级,1-升级中 + */ + @JsonProperty("firmware_upgrade_status") + private Integer firmwareUpgradeStatus; + + /** + * 固件一致性:0-不需要一致性升级,1-需要一致性升级 + */ + @JsonProperty("compatible_status") + private Integer compatibleStatus; + + /** + * 固件版本 + */ + @JsonProperty("firmware_version") + private String firmwareVersion; + + /** + * 档位 + */ + @JsonProperty("gear") + private Integer gear; + + /** + * 飞行器状态 + */ + @JsonProperty("mode_code") + private Integer modeCode; + + /** + * 飞行器进入当前状态的原因 + */ + @JsonProperty("mode_code_reason") + private Integer modeCodeReason; + + /** + * 航迹ID + */ + @JsonProperty("track_id") + private String trackId; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/AirConditioner.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/AirConditioner.java new file mode 100644 index 0000000..09ba8ce --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/AirConditioner.java @@ -0,0 +1,27 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.dock; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 机场空调工作状态信息 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class AirConditioner { + + /** + * 机场空调状态 + */ + @JsonProperty("air_conditioner_state") + private Integer airConditionerState; + + /** + * 剩余等待可切换时间(秒) + */ + @JsonProperty("switch_time") + private Integer switchTime; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/BackupBattery.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/BackupBattery.java new file mode 100644 index 0000000..e703140 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/BackupBattery.java @@ -0,0 +1,33 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.dock; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 机场备用电池信息 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class BackupBattery { + + /** + * 备用电池开关:0-关闭,1-开启 + */ + @JsonProperty("switch") + private Integer switchState; + + /** + * 备用电池电压(毫伏) + */ + @JsonProperty("voltage") + private Integer voltage; + + /** + * 备用电池温度(摄氏度) + */ + @JsonProperty("temperature") + private Float temperature; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DockBatteryDetail.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DockBatteryDetail.java new file mode 100644 index 0000000..3b271e3 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DockBatteryDetail.java @@ -0,0 +1,39 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.dock; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 机场电池详细信息 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class DockBatteryDetail { + + /** + * 电池剩余电量 + */ + @JsonProperty("capacity_percent") + private Integer capacityPercent; + + /** + * 电池序号:0-左电池,1-右电池 + */ + @JsonProperty("index") + private Integer index; + + /** + * 电压(毫伏) + */ + @JsonProperty("voltage") + private Integer voltage; + + /** + * 温度(摄氏度) + */ + @JsonProperty("temperature") + private Float temperature; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DroneBatteryMaintenanceInfo.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DroneBatteryMaintenanceInfo.java new file mode 100644 index 0000000..f7992b3 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DroneBatteryMaintenanceInfo.java @@ -0,0 +1,41 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.dock; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * 飞行器电池保养信息 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class DroneBatteryMaintenanceInfo { + + /** + * 保养状态:0-无需保养,1-待保养,2-正在保养 + */ + @JsonProperty("maintenance_state") + private Integer maintenanceState; + + /** + * 电池保养剩余时间(小时) + */ + @JsonProperty("maintenance_time_left") + private Integer maintenanceTimeLeft; + + /** + * 电池加热保温状态:0-未开启,1-加热中,2-保温中 + */ + @JsonProperty("heat_state") + private Integer heatState; + + /** + * 电池详细信息 + */ + @JsonProperty("batteries") + private List batteries; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DroneChargeState.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DroneChargeState.java new file mode 100644 index 0000000..683de78 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/DroneChargeState.java @@ -0,0 +1,27 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.dock; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 飞行器充电状态 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class DroneChargeState { + + /** + * 电量百分比 + */ + @JsonProperty("capacity_percent") + private Integer capacityPercent; + + /** + * 充电状态:0-空闲,1-充电中 + */ + @JsonProperty("state") + private Integer state; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/NetworkState.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/NetworkState.java new file mode 100644 index 0000000..1a74135 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/NetworkState.java @@ -0,0 +1,33 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.dock; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 网络状态 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class NetworkState { + + /** + * 网络类型:1-4G,2-以太网 + */ + @JsonProperty("type") + private Integer type; + + /** + * 网络质量:0-无信号,1-差,2-较差,3-一般,4-较好,5-好 + */ + @JsonProperty("quality") + private Integer quality; + + /** + * 网络速率(KB/s) + */ + @JsonProperty("rate") + private Float rate; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/WirelessLink.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/WirelessLink.java new file mode 100644 index 0000000..b285fd1 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/dock/WirelessLink.java @@ -0,0 +1,75 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.dock; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 图传链路 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class WirelessLink { + + /** + * 飞行器上 Dongle 数量 + */ + @JsonProperty("dongle_number") + private Integer dongleNumber; + + /** + * 4G 链路连接状态:0-断开,1-连接 + */ + @JsonProperty("4g_link_state") + private Integer fourGLinkState; + + /** + * SDR 链路连接状态:0-断开,1-连接 + */ + @JsonProperty("sdr_link_state") + private Integer sdrLinkState; + + /** + * 机场的图传链路模式:0-SDR 模式,1-4G 融合模式 + */ + @JsonProperty("link_workmode") + private Integer linkWorkmode; + + /** + * SDR 信号质量(0-5) + */ + @JsonProperty("sdr_quality") + private Integer sdrQuality; + + /** + * 总体 4G 信号质量(0-5) + */ + @JsonProperty("4g_quality") + private Integer fourGQuality; + + /** + * 天端 4G 信号质量(0-5) + */ + @JsonProperty("4g_uav_quality") + private Integer fourGUavQuality; + + /** + * 地端 4G 信号质量(0-5) + */ + @JsonProperty("4g_gnd_quality") + private Integer fourGGndQuality; + + /** + * SDR 频段 + */ + @JsonProperty("sdr_freq_band") + private Float sdrFreqBand; + + /** + * 4G 频段 + */ + @JsonProperty("4g_freq_band") + private Float fourGFreqBand; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/BatteryDetail.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/BatteryDetail.java new file mode 100644 index 0000000..4bce2c6 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/BatteryDetail.java @@ -0,0 +1,75 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 电池详细信息 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class BatteryDetail { + + /** + * 电池剩余电量 + */ + @JsonProperty("capacity_percent") + private Integer capacityPercent; + + /** + * 电池序号 + */ + @JsonProperty("index") + private Integer index; + + /** + * 电池序列号(SN) + */ + @JsonProperty("sn") + private String sn; + + /** + * 电池类型 + */ + @JsonProperty("type") + private Integer type; + + /** + * 电池子类型 + */ + @JsonProperty("sub_type") + private Integer subType; + + /** + * 固件版本 + */ + @JsonProperty("firmware_version") + private String firmwareVersion; + + /** + * 电池循环次数 + */ + @JsonProperty("loop_times") + private Integer loopTimes; + + /** + * 电压(毫伏) + */ + @JsonProperty("voltage") + private Integer voltage; + + /** + * 温度(摄氏度) + */ + @JsonProperty("temperature") + private Float temperature; + + /** + * 高电压存储天数 + */ + @JsonProperty("high_voltage_storage_days") + private Integer highVoltageStorageDays; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/BatteryInfo.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/BatteryInfo.java new file mode 100644 index 0000000..a0f7d20 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/BatteryInfo.java @@ -0,0 +1,47 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * 飞行器电池信息 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class BatteryInfo { + + /** + * 电池的总剩余电量 + */ + @JsonProperty("capacity_percent") + private Integer capacityPercent; + + /** + * 剩余飞行时间(秒) + */ + @JsonProperty("remain_flight_time") + private Integer remainFlightTime; + + /** + * 返航所需电量百分比 + */ + @JsonProperty("return_home_power") + private Integer returnHomePower; + + /** + * 强制降落电量百分比 + */ + @JsonProperty("landing_power") + private Integer landingPower; + + /** + * 电池详细信息 + */ + @JsonProperty("batteries") + private List batteries; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/CameraInfo.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/CameraInfo.java new file mode 100644 index 0000000..58d8779 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/CameraInfo.java @@ -0,0 +1,167 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * 飞行器相机信息 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class CameraInfo { + + /** + * 剩余拍照张数 + */ + @JsonProperty("remain_photo_num") + private Integer remainPhotoNum; + + /** + * 剩余录像时间(秒) + */ + @JsonProperty("remain_record_duration") + private Integer remainRecordDuration; + + /** + * 视频录制时长(秒) + */ + @JsonProperty("record_time") + private Integer recordTime; + + /** + * 负载编号 + */ + @JsonProperty("payload_index") + private String payloadIndex; + + /** + * 相机模式:0-拍照,1-录像,2-智能低光,3-全景拍照 + */ + @JsonProperty("camera_mode") + private Integer cameraMode; + + /** + * 拍照状态:0-空闲,1-拍照中 + */ + @JsonProperty("photo_state") + private Integer photoState; + + /** + * 录像状态:0-空闲,1-录像中 + */ + @JsonProperty("recording_state") + private Integer recordingState; + + /** + * 变焦倍数 + */ + @JsonProperty("zoom_factor") + private Float zoomFactor; + + /** + * 红外变焦倍数 + */ + @JsonProperty("ir_zoom_factor") + private Float irZoomFactor; + + /** + * 视场角(FOV)在 liveview 中的区域 + */ + @JsonProperty("liveview_world_region") + private LiveviewWorldRegion liveviewWorldRegion; + + /** + * 照片存储设置集合 + */ + @JsonProperty("photo_storage_settings") + private List photoStorageSettings; + + /** + * 视频存储设置集合 + */ + @JsonProperty("video_storage_settings") + private List videoStorageSettings; + + /** + * 广角镜头曝光模式 + */ + @JsonProperty("wide_exposure_mode") + private Integer wideExposureMode; + + /** + * 广角镜头感光度 + */ + @JsonProperty("wide_iso") + private Integer wideIso; + + /** + * 广角镜头快门速度 + */ + @JsonProperty("wide_shutter_speed") + private Integer wideShutterSpeed; + + /** + * 广角镜头曝光值 + */ + @JsonProperty("wide_exposure_value") + private Integer wideExposureValue; + + /** + * 变焦镜头曝光模式 + */ + @JsonProperty("zoom_exposure_mode") + private Integer zoomExposureMode; + + /** + * 变焦镜头感光度 + */ + @JsonProperty("zoom_iso") + private Integer zoomIso; + + /** + * 变焦镜头快门速度 + */ + @JsonProperty("zoom_shutter_speed") + private Integer zoomShutterSpeed; + + /** + * 变焦镜头曝光值 + */ + @JsonProperty("zoom_exposure_value") + private Integer zoomExposureValue; + + /** + * 变焦镜头对焦模式 + */ + @JsonProperty("zoom_focus_mode") + private Integer zoomFocusMode; + + /** + * 变焦镜头对焦值 + */ + @JsonProperty("zoom_focus_value") + private Integer zoomFocusValue; + + /** + * 红外测温模式 + */ + @JsonProperty("ir_metering_mode") + private Integer irMeteringMode; + + /** + * 红外测温点 + */ + @JsonProperty("ir_metering_point") + private IrMeteringPoint irMeteringPoint; + + /** + * 红外测温区域 + */ + @JsonProperty("ir_metering_area") + private IrMeteringArea irMeteringArea; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/CenterNode.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/CenterNode.java new file mode 100644 index 0000000..4ef0532 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/CenterNode.java @@ -0,0 +1,27 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 飞行器对频信息(中心节点) + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class CenterNode { + + /** + * 扰码信息 + */ + @JsonProperty("sdr_id") + private Integer sdrId; + + /** + * 设备sn + */ + @JsonProperty("sn") + private String sn; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/DistanceLimitStatus.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/DistanceLimitStatus.java new file mode 100644 index 0000000..8a02845 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/DistanceLimitStatus.java @@ -0,0 +1,33 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 飞行器限远状态 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class DistanceLimitStatus { + + /** + * 是否开启限远:0-未设置,1-已设置 + */ + @JsonProperty("state") + private Integer state; + + /** + * 限远距离(米) + */ + @JsonProperty("distance_limit") + private Integer distanceLimit; + + /** + * 是否接近设定的限制距离:0-未达到,1-接近 + */ + @JsonProperty("is_near_distance_limit") + private Integer isNearDistanceLimit; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/IrMeteringArea.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/IrMeteringArea.java new file mode 100644 index 0000000..f330556 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/IrMeteringArea.java @@ -0,0 +1,57 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 红外测温区域 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class IrMeteringArea { + + /** + * 测温区域左上角点坐标 x + */ + @JsonProperty("x") + private Double x; + + /** + * 测温区域左上角点坐标 y + */ + @JsonProperty("y") + private Double y; + + /** + * 测温区域宽度 + */ + @JsonProperty("width") + private Double width; + + /** + * 测温区域高度 + */ + @JsonProperty("height") + private Double height; + + /** + * 测温区域平均温度 + */ + @JsonProperty("aver_temperature") + private Double averTemperature; + + /** + * 测温区域最低温度点 + */ + @JsonProperty("min_temperature_point") + private TemperaturePoint minTemperaturePoint; + + /** + * 测温区域最高温度点 + */ + @JsonProperty("max_temperature_point") + private TemperaturePoint maxTemperaturePoint; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/IrMeteringPoint.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/IrMeteringPoint.java new file mode 100644 index 0000000..b111799 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/IrMeteringPoint.java @@ -0,0 +1,33 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 红外测温点 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class IrMeteringPoint { + + /** + * 测温点坐标 x + */ + @JsonProperty("x") + private Double x; + + /** + * 测温点坐标 y + */ + @JsonProperty("y") + private Double y; + + /** + * 测温点的温度 + */ + @JsonProperty("temperature") + private Double temperature; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/LeafNode.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/LeafNode.java new file mode 100644 index 0000000..fd0a8f6 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/LeafNode.java @@ -0,0 +1,33 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 机场或遥控器对频信息(叶子节点) + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class LeafNode { + + /** + * 扰码信息 + */ + @JsonProperty("sdr_id") + private Integer sdrId; + + /** + * 设备sn + */ + @JsonProperty("sn") + private String sn; + + /** + * 控制源序号 + */ + @JsonProperty("control_source_index") + private Integer controlSourceIndex; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/LiveviewWorldRegion.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/LiveviewWorldRegion.java new file mode 100644 index 0000000..0aaa130 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/LiveviewWorldRegion.java @@ -0,0 +1,39 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 视场角(FOV)在 liveview 中的区域 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class LiveviewWorldRegion { + + /** + * 左上角的 x 轴起始点 + */ + @JsonProperty("left") + private Float left; + + /** + * 左上角的 y 轴起始点 + */ + @JsonProperty("top") + private Float top; + + /** + * 右下角的 x 轴起始点 + */ + @JsonProperty("right") + private Float right; + + /** + * 右下角的 y 轴起始点 + */ + @JsonProperty("bottom") + private Float bottom; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/MaintainStatus.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/MaintainStatus.java new file mode 100644 index 0000000..21962a3 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/MaintainStatus.java @@ -0,0 +1,23 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * 保养信息 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class MaintainStatus { + + /** + * 保养信息数组 + */ + @JsonProperty("maintain_status_array") + private List maintainStatusArray; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/MaintainStatusItem.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/MaintainStatusItem.java new file mode 100644 index 0000000..cf5afe3 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/MaintainStatusItem.java @@ -0,0 +1,45 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 保养信息项 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class MaintainStatusItem { + + /** + * 保养状态:0-无保养,1-有保养 + */ + @JsonProperty("state") + private Integer state; + + /** + * 上一次保养类型:1-飞行器基础保养,2-飞行器常规保养,3-飞行器深度保养 + */ + @JsonProperty("last_maintain_type") + private Integer lastMaintainType; + + /** + * 上一次保养时间 + */ + @JsonProperty("last_maintain_time") + private Long lastMaintainTime; + + /** + * 上一次保养时飞行航时(小时) + */ + @JsonProperty("last_maintain_flight_time") + private Integer lastMaintainFlightTime; + + /** + * 上一次保养时飞行架次 + */ + @JsonProperty("last_maintain_flight_sorties") + private Integer lastMaintainFlightSorties; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/ObstacleAvoidance.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/ObstacleAvoidance.java new file mode 100644 index 0000000..20aff06 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/ObstacleAvoidance.java @@ -0,0 +1,33 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 飞行器避障状态 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class ObstacleAvoidance { + + /** + * 水平避障状态:0-关闭,1-开启 + */ + @JsonProperty("horizon") + private Integer horizon; + + /** + * 上视避障状态:0-关闭,1-开启 + */ + @JsonProperty("upside") + private Integer upside; + + /** + * 下视避障状态:0-关闭,1-开启 + */ + @JsonProperty("downside") + private Integer downside; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/PositionState.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/PositionState.java new file mode 100644 index 0000000..24cca01 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/PositionState.java @@ -0,0 +1,39 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 搜星状态 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class PositionState { + + /** + * 是否收敛:0-未开始,1-收敛中,2-收敛成功,3-收敛失败 + */ + @JsonProperty("is_fixed") + private Integer isFixed; + + /** + * 搜星档位:1-1档,2-2档,3-3档,4-4档,5-5档,10-RTK fixed + */ + @JsonProperty("quality") + private Integer quality; + + /** + * GPS 搜星数量 + */ + @JsonProperty("gps_number") + private Integer gpsNumber; + + /** + * RTK 搜星数量 + */ + @JsonProperty("rtk_number") + private Integer rtkNumber; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/StorageInfo.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/StorageInfo.java new file mode 100644 index 0000000..9b86afc --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/StorageInfo.java @@ -0,0 +1,27 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 存储容量 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class StorageInfo { + + /** + * 总容量(KB) + */ + @JsonProperty("total") + private Integer total; + + /** + * 已使用容量(KB) + */ + @JsonProperty("used") + private Integer used; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/TemperaturePoint.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/TemperaturePoint.java new file mode 100644 index 0000000..fcf557b --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/TemperaturePoint.java @@ -0,0 +1,33 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * 温度点 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class TemperaturePoint { + + /** + * 温度点坐标 x + */ + @JsonProperty("x") + private Double x; + + /** + * 温度点坐标 y + */ + @JsonProperty("y") + private Double y; + + /** + * 温度点的温度 + */ + @JsonProperty("temperature") + private Double temperature; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/WirelessLinkTopo.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/WirelessLinkTopo.java new file mode 100644 index 0000000..675f20d --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/WirelessLinkTopo.java @@ -0,0 +1,35 @@ +package com.ruoyi.device.domain.impl.djimqtt.model.drone; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +import java.util.List; + +/** + * 图传连接拓扑 + * + * @author ruoyi + */ +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class WirelessLinkTopo { + + /** + * 加密编码 + */ + @JsonProperty("secret_code") + private List secretCode; + + /** + * 飞行器对频信息 + */ + @JsonProperty("center_node") + private CenterNode centerNode; + + /** + * 当前连接的机场或遥控器对频信息 + */ + @JsonProperty("leaf_nodes") + private List leafNodes; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java new file mode 100644 index 0000000..8ac6054 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java @@ -0,0 +1,159 @@ +package com.ruoyi.device.domain.impl.djimqtt.service; + +import com.ruoyi.device.domain.impl.djimqtt.config.DjiMqttProperties; +import com.ruoyi.device.domain.impl.djimqtt.handler.DjiMqttMessageHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.paho.client.mqttv3.*; +import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import jakarta.annotation.PreDestroy; + +/** + * DJI MQTT客户端服务 + * 采用共享订阅方式,支持多实例部署 + * + * @author ruoyi + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DjiMqttClientService { + + private final DjiMqttProperties mqttProperties; + private final DjiMqttMessageHandler messageHandler; + + private MqttClient mqttClient; + + /** + * 无人机OSD主题(共享订阅) + */ + private static final String DRONE_OSD_TOPIC = "$share/dji-group/thing/product/+/osd"; + + /** + * 无人机State主题(共享订阅) + */ + private static final String DRONE_STATE_TOPIC = "$share/dji-group/thing/product/+/state"; + + /** + * 应用启动后自动连接 + */ + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + connect(); + } + + /** + * 连接到MQTT服务器 + */ + public void connect() { + try { + if (mqttClient != null && mqttClient.isConnected()) { + log.info("MQTT客户端已连接,无需重复连接"); + return; + } + + String broker = String.format("tcp://%s:%d", mqttProperties.getHost(), mqttProperties.getPort()); + log.info("开始连接DJI MQTT服务器: {}", broker); + + // 创建MQTT客户端 + mqttClient = new MqttClient(broker, mqttProperties.getClientId(), new MemoryPersistence()); + + // 配置连接选项 + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(mqttProperties.getUsername()); + options.setPassword(mqttProperties.getPassword().toCharArray()); + options.setConnectionTimeout(mqttProperties.getConnectionTimeout()); + options.setKeepAliveInterval(mqttProperties.getKeepAliveInterval()); + options.setAutomaticReconnect(mqttProperties.getAutoReconnect()); + options.setCleanSession(mqttProperties.getCleanSession()); + + // 设置MQTT版本 + if (mqttProperties.getVersion() == 5) { + options.setMqttVersion(MqttConnectOptions.MQTT_VERSION_3_1_1); + log.info("使用MQTT协议版本: 3.1.1"); + } + + // 设置回调 + mqttClient.setCallback(new MqttCallback() { + @Override + public void connectionLost(Throwable cause) { + log.error("MQTT连接丢失: {}", cause.getMessage(), cause); + } + + @Override + public void messageArrived(String topic, MqttMessage message) { + try { + String payload = new String(message.getPayload()); + messageHandler.handleMessage(topic, payload); + } catch (Exception e) { + log.error("处理MQTT消息失败: {}", e.getMessage(), e); + } + } + + @Override + public void deliveryComplete(IMqttDeliveryToken token) { + // 不需要处理发送完成事件 + } + }); + + // 连接 + mqttClient.connect(options); + log.info("成功连接到DJI MQTT服务器"); + + // 订阅主题 + subscribe(); + + } catch (Exception e) { + log.error("连接DJI MQTT服务器失败: {}", e.getMessage(), e); + } + } + + /** + * 订阅主题 + */ + private void subscribe() { + try { + if (mqttClient == null || !mqttClient.isConnected()) { + log.warn("MQTT客户端未连接,无法订阅主题"); + return; + } + + // 订阅无人机OSD主题(共享订阅) + mqttClient.subscribe(DRONE_OSD_TOPIC, 1); + log.info("成功订阅主题: {}", DRONE_OSD_TOPIC); + + // 订阅无人机State主题(共享订阅) + mqttClient.subscribe(DRONE_STATE_TOPIC, 1); + log.info("成功订阅主题: {}", DRONE_STATE_TOPIC); + + } catch (Exception e) { + log.error("订阅MQTT主题失败: {}", e.getMessage(), e); + } + } + + /** + * 断开连接 + */ + @PreDestroy + public void disconnect() { + try { + if (mqttClient != null && mqttClient.isConnected()) { + mqttClient.disconnect(); + mqttClient.close(); + log.info("已断开DJI MQTT连接"); + } + } catch (Exception e) { + log.error("断开DJI MQTT连接失败: {}", e.getMessage(), e); + } + } + + /** + * 检查连接状态 + */ + public boolean isConnected() { + return mqttClient != null && mqttClient.isConnected(); + } +} diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml index 2ca5c1c..c9b63f8 100644 --- a/src/main/resources/bootstrap.yml +++ b/src/main/resources/bootstrap.yml @@ -34,3 +34,24 @@ device: # 匹配这些正则表达式的设备将被跳过,不进行同步 # 当前配置:过滤所有以 TH 开头的设备 exclude-patterns: TH.* +machine: + state: + store: + type: redis # 或 memory,根据部署方式选择 + sn: + repository: + type: memory # 或 redis/mysql,根据需求选择 + +# DJI MQTT配置 +dji: + mqtt: + host: mqtt.t-aaron.com + port: 10883 + version: 5 + client-id: ThingsBoard_gateway + username: admin + password: admin + connection-timeout: 30 + keep-alive-interval: 60 + auto-reconnect: true + clean-session: false \ No newline at end of file From bbda3b541d59c88ac6d7970d1aaaedb5791e8471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 14:05:09 +0800 Subject: [PATCH 03/17] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E5=A4=A7=E7=96=86MQTT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../device/domain/impl/djimqtt/README.md | 120 ---------- .../djimqtt/config/DjiMqttClientConfig.java | 76 ++++++ .../djimqtt/example/DjiMqttUsageExample.java | 221 ++++++++++-------- .../djimqtt/manager/DjiMqttClientManager.java | 125 ++++++++++ .../djimqtt/service/DjiMqttClientService.java | 111 +++++---- 5 files changed, 384 insertions(+), 269 deletions(-) delete mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/config/DjiMqttClientConfig.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/manager/DjiMqttClientManager.java diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md deleted file mode 100644 index bd935c8..0000000 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md +++ /dev/null @@ -1,120 +0,0 @@ -# DJI MQTT 模块使用说明 - -## 概述 - -本模块实现了大疆MQTT消息的接收和处理功能,采用共享订阅方式,支持多实例部署。 - -## 功能特性 - -- ✅ 自动连接和重连 -- ✅ 共享订阅(多实例部署时只有一个实例消费消息) -- ✅ 自动区分无人机和机场数据 -- ✅ 回调接口设计,使用者无需关心osd和state的区别 -- ✅ 完整的数据模型定义 -- ✅ 原始数据保留(rawData字段) - -## 架构设计 - -``` -DjiMqttClientService (MQTT客户端) - ↓ -DjiMqttMessageHandler (消息处理器) - ↓ -IDroneDataCallback / IDockDataCallback (回调接口) - ↓ -使用者实现 -``` - -## 配置说明 - -在 `bootstrap.yml` 中配置: - -```yaml -dji: - mqtt: - host: mqtt.t-aaron.com - port: 10883 - version: 5 - client-id: ThingsBoard_gateway - username: admin - password: admin - connection-timeout: 30 - keep-alive-interval: 60 - auto-reconnect: true - clean-session: false -``` - -## 使用方法 - -### 1. 注入消息处理器 - -```java -@Autowired -private DjiMqttMessageHandler messageHandler; -``` - -### 2. 实现回调接口 - -```java -// 无人机数据回调 -messageHandler.registerDroneDataCallback(new IDroneDataCallback() { - @Override - public void onDroneData(DroneData droneData) { - // 处理无人机数据 - String sn = droneData.getDeviceSn(); - Double latitude = droneData.getLatitude(); - Double longitude = droneData.getLongitude(); - - // 访问原始数据 - Map rawData = droneData.getRawData(); - } -}); - -// 机场数据回调 -messageHandler.registerDockDataCallback(new IDockDataCallback() { - @Override - public void onDockData(DockData dockData) { - // 处理机场数据 - String sn = dockData.getDeviceSn(); - Integer modeCode = dockData.getModeCode(); - Float temperature = dockData.getTemperature(); - - // 访问原始数据 - Map rawData = dockData.getRawData(); - } -}); -``` - -## 数据模型 - -### DroneData(无人机数据) - -主要字段: -- `deviceSn`: 设备SN -- `messageType`: 消息类型(osd/state) -- `latitude/longitude`: 位置信息 -- `elevation/height`: 高度信息 -- `modeCode`: 飞行器状态 -- `rawData`: 原始数据(包含所有字段) - -### DockData(机场数据) - -主要字段: -- `deviceSn`: 设备SN -- `messageType`: 消息类型(osd/state) -- `latitude/longitude`: 位置信息 -- `modeCode`: 机场状态 -- `temperature/humidity`: 环境信息 -- `coverState`: 舱盖状态 -- `rawData`: 原始数据(包含所有字段) - -## 注意事项 - -1. **部分字段推送**:每次MQTT消息可能只包含部分字段,使用时需要判空 -2. **原始数据访问**:所有字段都保存在`rawData`中,可以通过Map访问 -3. **共享订阅**:多实例部署时,同一条消息只会被一个实例消费 -4. **自动重连**:连接断开后会自动重连 - -## 示例代码 - -参考 `DjiMqttUsageExample.java` 获取完整示例。 diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/config/DjiMqttClientConfig.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/config/DjiMqttClientConfig.java new file mode 100644 index 0000000..94b395f --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/config/DjiMqttClientConfig.java @@ -0,0 +1,76 @@ +package com.ruoyi.device.domain.impl.djimqtt.config; + +import lombok.Builder; +import lombok.Data; + +/** + * DJI MQTT客户端配置 + * 用于动态创建MQTT客户端 + * + * @author ruoyi + */ +@Data +@Builder +public class DjiMqttClientConfig { + + /** + * MQTT服务器地址 + */ + private String host; + + /** + * MQTT服务器端口 + */ + private Integer port; + + /** + * 客户端ID(必须唯一) + */ + private String clientId; + + /** + * 用户名 + */ + private String username; + + /** + * 密码 + */ + private String password; + + /** + * 连接超时时间(秒) + */ + @Builder.Default + private Integer connectionTimeout = 30; + + /** + * 保持连接时间(秒) + */ + @Builder.Default + private Integer keepAliveInterval = 60; + + /** + * 自动重连 + */ + @Builder.Default + private Boolean autoReconnect = true; + + /** + * 清除会话 + */ + @Builder.Default + private Boolean cleanSession = false; + + /** + * 是否使用共享订阅 + */ + @Builder.Default + private Boolean useSharedSubscription = true; + + /** + * 共享订阅组名 + */ + @Builder.Default + private String sharedGroupName = "dji-group"; +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/example/DjiMqttUsageExample.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/example/DjiMqttUsageExample.java index 9838ea6..9b34bde 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/example/DjiMqttUsageExample.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/example/DjiMqttUsageExample.java @@ -2,7 +2,9 @@ package com.ruoyi.device.domain.impl.djimqtt.example; import com.ruoyi.device.domain.impl.djimqtt.callback.IDockDataCallback; import com.ruoyi.device.domain.impl.djimqtt.callback.IDroneDataCallback; +import com.ruoyi.device.domain.impl.djimqtt.config.DjiMqttClientConfig; import com.ruoyi.device.domain.impl.djimqtt.handler.DjiMqttMessageHandler; +import com.ruoyi.device.domain.impl.djimqtt.manager.DjiMqttClientManager; import com.ruoyi.device.domain.impl.djimqtt.model.DockData; import com.ruoyi.device.domain.impl.djimqtt.model.DroneData; import lombok.extern.slf4j.Slf4j; @@ -12,13 +14,14 @@ import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; /** - * DJI MQTT使用示例 + * DJI MQTT使用示例(支持多客户端) * * 使用说明: - * 1. 注入 DjiMqttMessageHandler - * 2. 实现 IDroneDataCallback 或 IDockDataCallback 接口 - * 3. 在应用启动后注册回调 - * 4. 在回调方法中处理接收到的数据 + * 1. 注入 DjiMqttClientManager + * 2. 使用 DjiMqttClientConfig.builder() 创建配置 + * 3. 调用 manager.createClient(config) 创建客户端 + * 4. 通过 manager.getHandler(clientId) 获取消息处理器 + * 5. 注册回调处理数据 * * @author ruoyi */ @@ -26,91 +29,125 @@ import org.springframework.stereotype.Component; @Component public class DjiMqttUsageExample { - @Autowired - private DjiMqttMessageHandler messageHandler; - - /** - * 应用启动后注册回调 - */ - @EventListener(ApplicationReadyEvent.class) - public void registerCallbacks() { - // 注册无人机数据回调 - messageHandler.registerDroneDataCallback(new IDroneDataCallback() { - @Override - public void onDroneData(DroneData droneData) { - handleDroneData(droneData); - } - }); - - // 注册机场数据回调 - messageHandler.registerDockDataCallback(new IDockDataCallback() { - @Override - public void onDockData(DockData dockData) { - handleDockData(dockData); - } - }); - - log.info("DJI MQTT回调已注册"); - } - - /** - * 处理无人机数据 - */ - private void handleDroneData(DroneData droneData) { - log.info("收到无人机数据 - SN: {}, Type: {}", - droneData.getDeviceSn(), - droneData.getMessageType()); - - // 示例:处理位置信息 - if (droneData.getLatitude() != null && droneData.getLongitude() != null) { - log.info("无人机位置 - 纬度: {}, 经度: {}, 高度: {}", - droneData.getLatitude(), - droneData.getLongitude(), - droneData.getElevation()); - } - - // 示例:处理电池信息(从rawData中获取) - if (droneData.getRawData() != null && droneData.getRawData().containsKey("battery")) { - Object battery = droneData.getRawData().get("battery"); - log.info("无人机电池信息: {}", battery); - } - - // 示例:处理相机信息 - if (droneData.getRawData() != null && droneData.getRawData().containsKey("cameras")) { - Object cameras = droneData.getRawData().get("cameras"); - log.info("无人机相机信息: {}", cameras); - } - } - - /** - * 处理机场数据 - */ - private void handleDockData(DockData dockData) { - log.info("收到机场数据 - SN: {}, Type: {}", - dockData.getDeviceSn(), - dockData.getMessageType()); - - // 示例:处理机场状态 - if (dockData.getModeCode() != null) { - log.info("机场状态: {}", dockData.getModeCode()); - } - - // 示例:处理环境信息 - if (dockData.getTemperature() != null) { - log.info("机场温度: {}°C, 湿度: {}%", - dockData.getTemperature(), - dockData.getHumidity()); - } - - // 示例:处理舱盖状态 - if (dockData.getCoverState() != null) { - log.info("舱盖状态: {}", dockData.getCoverState()); - } - - // 示例:处理飞行器充电状态(从rawData中获取) - if (dockData.getRawData() != null && dockData.getRawData().containsKey("drone_charge_state")) { - Object chargeState = dockData.getRawData().get("drone_charge_state"); - log.info("飞行器充电状态: {}", chargeState); - } - } +// @Autowired +// private DjiMqttClientManager clientManager; +// +// /** +// * 应用启动后创建MQTT客户端 +// */ +// @EventListener(ApplicationReadyEvent.class) +// public void onApplicationReady() { +// // 示例1:创建第一个MQTT客户端 +// createClient1(); +// +// // 示例2:创建第二个MQTT客户端(不同的服务器) +// createClient2(); +// } +// +// /** +// * 创建第一个MQTT客户端 +// */ +// private void createClient1() { +// // 构建配置 +// DjiMqttClientConfig config = DjiMqttClientConfig.builder() +// .host("mqtt.t-aaron.com") +// .port(10883) +// .clientId("client_1") +// .username("admin") +// .password("admin") +// .useSharedSubscription(true) +// .sharedGroupName("dji-group-1") +// .build(); +// +// // 创建客户端 +// String clientId = clientManager.createClient(config); +// +// // 获取消息处理器 +// DjiMqttMessageHandler handler = clientManager.getHandler(clientId); +// +// // 注册无人机数据回调 +// handler.registerDroneDataCallback(new IDroneDataCallback() { +// @Override +// public void onDroneData(DroneData droneData) { +// handleDroneDataForClient1(droneData); +// } +// }); +// +// // 注册机场数据回调 +// handler.registerDockDataCallback(new IDockDataCallback() { +// @Override +// public void onDockData(DockData dockData) { +// handleDockDataForClient1(dockData); +// } +// }); +// +// log.info("客户端1已创建并注册回调"); +// } +// +// /** +// * 创建第二个MQTT客户端(连接到不同的服务器) +// */ +// private void createClient2() { +// // 构建配置 +// DjiMqttClientConfig config = DjiMqttClientConfig.builder() +// .host("mqtt.another-server.com") +// .port(1883) +// .clientId("client_2") +// .username("user2") +// .password("pass2") +// .useSharedSubscription(false) // 不使用共享订阅 +// .build(); +// +// // 创建客户端 +// String clientId = clientManager.createClient(config); +// +// // 获取消息处理器 +// DjiMqttMessageHandler handler = clientManager.getHandler(clientId); +// +// // 注册回调 +// handler.registerDroneDataCallback(droneData -> handleDroneDataForClient2(droneData)); +// handler.registerDockDataCallback(dockData -> handleDockDataForClient2(dockData)); +// +// log.info("客户端2已创建并注册回调"); +// } +// +// /** +// * 处理客户端1的无人机数据 +// */ +// private void handleDroneDataForClient1(DroneData droneData) { +// log.info("[客户端1] 收到无人机数据 - SN: {}, Type: {}", +// droneData.getDeviceSn(), +// droneData.getMessageType()); +// +// // 处理位置信息 +// if (droneData.getLatitude() != null && droneData.getLongitude() != null) { +// log.info("[客户端1] 无人机位置 - 纬度: {}, 经度: {}, 高度: {}", +// droneData.getLatitude(), +// droneData.getLongitude(), +// droneData.getElevation()); +// } +// } +// +// /** +// * 处理客户端1的机场数据 +// */ +// private void handleDockDataForClient1(DockData dockData) { +// log.info("[客户端1] 收到机场数据 - SN: {}, Type: {}", +// dockData.getDeviceSn(), +// dockData.getMessageType()); +// } +// +// /** +// * 处理客户端2的无人机数据 +// */ +// private void handleDroneDataForClient2(DroneData droneData) { +// log.info("[客户端2] 收到无人机数据 - SN: {}", droneData.getDeviceSn()); +// } +// +// /** +// * 处理客户端2的机场数据 +// */ +// private void handleDockDataForClient2(DockData dockData) { +// log.info("[客户端2] 收到机场数据 - SN: {}", dockData.getDeviceSn()); +// } } diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/manager/DjiMqttClientManager.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/manager/DjiMqttClientManager.java new file mode 100644 index 0000000..200e633 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/manager/DjiMqttClientManager.java @@ -0,0 +1,125 @@ +package com.ruoyi.device.domain.impl.djimqtt.manager; + +import com.ruoyi.device.domain.impl.djimqtt.config.DjiMqttClientConfig; +import com.ruoyi.device.domain.impl.djimqtt.handler.DjiMqttMessageHandler; +import com.ruoyi.device.domain.impl.djimqtt.service.DjiMqttClientService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * DJI MQTT客户端管理器 + * 管理多个MQTT客户端实例 + * + * @author ruoyi + */ +@Slf4j +@Component +public class DjiMqttClientManager { + + /** + * 客户端映射表 clientId -> DjiMqttClientService + */ + private final Map clients = new ConcurrentHashMap<>(); + + /** + * 消息处理器映射表 clientId -> DjiMqttMessageHandler + */ + private final Map handlers = new ConcurrentHashMap<>(); + + /** + * 创建并连接MQTT客户端 + * + * @param config 客户端配置 + * @return 客户端ID + */ + public String createClient(DjiMqttClientConfig config) { + String clientId = config.getClientId(); + + if (clients.containsKey(clientId)) { + log.warn("MQTT客户端[{}]已存在", clientId); + return clientId; + } + + // 为每个客户端创建独立的消息处理器 + DjiMqttMessageHandler handler = new DjiMqttMessageHandler(); + handlers.put(clientId, handler); + + // 创建客户端 + DjiMqttClientService client = new DjiMqttClientService(config, handler); + clients.put(clientId, client); + + // 连接 + client.connect(); + + log.info("成功创建MQTT客户端[{}]", clientId); + return clientId; + } + + /** + * 获取消息处理器 + * + * @param clientId 客户端ID + * @return 消息处理器 + */ + public DjiMqttMessageHandler getHandler(String clientId) { + return handlers.get(clientId); + } + + /** + * 获取客户端 + * + * @param clientId 客户端ID + * @return 客户端服务 + */ + public DjiMqttClientService getClient(String clientId) { + return clients.get(clientId); + } + + /** + * 断开并移除客户端 + * + * @param clientId 客户端ID + */ + public void removeClient(String clientId) { + DjiMqttClientService client = clients.remove(clientId); + if (client != null) { + client.disconnect(); + log.info("已移除MQTT客户端[{}]", clientId); + } + + handlers.remove(clientId); + } + + /** + * 断开所有客户端 + */ + public void disconnectAll() { + clients.forEach((clientId, client) -> { + try { + client.disconnect(); + log.info("已断开MQTT客户端[{}]", clientId); + } catch (Exception e) { + log.error("断开MQTT客户端[{}]失败: {}", clientId, e.getMessage(), e); + } + }); + clients.clear(); + handlers.clear(); + } + + /** + * 获取所有客户端ID + */ + public java.util.Set getAllClientIds() { + return clients.keySet(); + } + + /** + * 检查客户端是否存在 + */ + public boolean hasClient(String clientId) { + return clients.containsKey(clientId); + } +} diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java index 8ac6054..b98cb34 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java @@ -1,48 +1,43 @@ package com.ruoyi.device.domain.impl.djimqtt.service; -import com.ruoyi.device.domain.impl.djimqtt.config.DjiMqttProperties; +import com.ruoyi.device.domain.impl.djimqtt.config.DjiMqttClientConfig; import com.ruoyi.device.domain.impl.djimqtt.handler.DjiMqttMessageHandler; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.eclipse.paho.client.mqttv3.*; import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; -import jakarta.annotation.PreDestroy; /** - * DJI MQTT客户端服务 - * 采用共享订阅方式,支持多实例部署 + * DJI MQTT客户端服务(可实例化) + * 支持动态创建多个客户端 * * @author ruoyi */ @Slf4j -@Service -@RequiredArgsConstructor public class DjiMqttClientService { - private final DjiMqttProperties mqttProperties; + private final DjiMqttClientConfig config; private final DjiMqttMessageHandler messageHandler; - private MqttClient mqttClient; /** - * 无人机OSD主题(共享订阅) + * 无人机OSD主题 */ - private static final String DRONE_OSD_TOPIC = "$share/dji-group/thing/product/+/osd"; + private static final String DRONE_OSD_TOPIC = "thing/product/+/osd"; /** - * 无人机State主题(共享订阅) + * 无人机State主题 */ - private static final String DRONE_STATE_TOPIC = "$share/dji-group/thing/product/+/state"; + private static final String DRONE_STATE_TOPIC = "thing/product/+/state"; /** - * 应用启动后自动连接 + * 构造函数 + * + * @param config 客户端配置 + * @param messageHandler 消息处理器 */ - @EventListener(ApplicationReadyEvent.class) - public void onApplicationReady() { - connect(); + public DjiMqttClientService(DjiMqttClientConfig config, DjiMqttMessageHandler messageHandler) { + this.config = config; + this.messageHandler = messageHandler; } /** @@ -51,36 +46,28 @@ public class DjiMqttClientService { public void connect() { try { if (mqttClient != null && mqttClient.isConnected()) { - log.info("MQTT客户端已连接,无需重复连接"); + log.info("MQTT客户端[{}]已连接,无需重复连接", config.getClientId()); return; } - String broker = String.format("tcp://%s:%d", mqttProperties.getHost(), mqttProperties.getPort()); - log.info("开始连接DJI MQTT服务器: {}", broker); + String broker = String.format("tcp://%s:%d", config.getHost(), config.getPort()); + log.info("开始连接DJI MQTT服务器[{}]: {}", config.getClientId(), broker); - // 创建MQTT客户端 - mqttClient = new MqttClient(broker, mqttProperties.getClientId(), new MemoryPersistence()); + mqttClient = new MqttClient(broker, config.getClientId(), new MemoryPersistence()); - // 配置连接选项 MqttConnectOptions options = new MqttConnectOptions(); - options.setUserName(mqttProperties.getUsername()); - options.setPassword(mqttProperties.getPassword().toCharArray()); - options.setConnectionTimeout(mqttProperties.getConnectionTimeout()); - options.setKeepAliveInterval(mqttProperties.getKeepAliveInterval()); - options.setAutomaticReconnect(mqttProperties.getAutoReconnect()); - options.setCleanSession(mqttProperties.getCleanSession()); + options.setUserName(config.getUsername()); + options.setPassword(config.getPassword().toCharArray()); + options.setConnectionTimeout(config.getConnectionTimeout()); + options.setKeepAliveInterval(config.getKeepAliveInterval()); + options.setAutomaticReconnect(config.getAutoReconnect()); + options.setCleanSession(config.getCleanSession()); + options.setMqttVersion(MqttConnectOptions.MQTT_VERSION_3_1_1); - // 设置MQTT版本 - if (mqttProperties.getVersion() == 5) { - options.setMqttVersion(MqttConnectOptions.MQTT_VERSION_3_1_1); - log.info("使用MQTT协议版本: 3.1.1"); - } - - // 设置回调 mqttClient.setCallback(new MqttCallback() { @Override public void connectionLost(Throwable cause) { - log.error("MQTT连接丢失: {}", cause.getMessage(), cause); + log.error("MQTT客户端[{}]连接丢失: {}", config.getClientId(), cause.getMessage(), cause); } @Override @@ -89,25 +76,23 @@ public class DjiMqttClientService { String payload = new String(message.getPayload()); messageHandler.handleMessage(topic, payload); } catch (Exception e) { - log.error("处理MQTT消息失败: {}", e.getMessage(), e); + log.error("MQTT客户端[{}]处理消息失败: {}", config.getClientId(), e.getMessage(), e); } } @Override public void deliveryComplete(IMqttDeliveryToken token) { - // 不需要处理发送完成事件 + // 不需要处理 } }); - // 连接 mqttClient.connect(options); - log.info("成功连接到DJI MQTT服务器"); + log.info("MQTT客户端[{}]成功连接到服务器", config.getClientId()); - // 订阅主题 subscribe(); } catch (Exception e) { - log.error("连接DJI MQTT服务器失败: {}", e.getMessage(), e); + log.error("MQTT客户端[{}]连接失败: {}", config.getClientId(), e.getMessage(), e); } } @@ -117,36 +102,41 @@ public class DjiMqttClientService { private void subscribe() { try { if (mqttClient == null || !mqttClient.isConnected()) { - log.warn("MQTT客户端未连接,无法订阅主题"); + log.warn("MQTT客户端[{}]未连接,无法订阅主题", config.getClientId()); return; } - // 订阅无人机OSD主题(共享订阅) - mqttClient.subscribe(DRONE_OSD_TOPIC, 1); - log.info("成功订阅主题: {}", DRONE_OSD_TOPIC); + String osdTopic = config.getUseSharedSubscription() + ? String.format("$share/%s/%s", config.getSharedGroupName(), DRONE_OSD_TOPIC) + : DRONE_OSD_TOPIC; - // 订阅无人机State主题(共享订阅) - mqttClient.subscribe(DRONE_STATE_TOPIC, 1); - log.info("成功订阅主题: {}", DRONE_STATE_TOPIC); + String stateTopic = config.getUseSharedSubscription() + ? String.format("$share/%s/%s", config.getSharedGroupName(), DRONE_STATE_TOPIC) + : DRONE_STATE_TOPIC; + + mqttClient.subscribe(osdTopic, 1); + log.info("MQTT客户端[{}]成功订阅主题: {}", config.getClientId(), osdTopic); + + mqttClient.subscribe(stateTopic, 1); + log.info("MQTT客户端[{}]成功订阅主题: {}", config.getClientId(), stateTopic); } catch (Exception e) { - log.error("订阅MQTT主题失败: {}", e.getMessage(), e); + log.error("MQTT客户端[{}]订阅主题失败: {}", config.getClientId(), e.getMessage(), e); } } /** * 断开连接 */ - @PreDestroy public void disconnect() { try { if (mqttClient != null && mqttClient.isConnected()) { mqttClient.disconnect(); mqttClient.close(); - log.info("已断开DJI MQTT连接"); + log.info("MQTT客户端[{}]已断开连接", config.getClientId()); } } catch (Exception e) { - log.error("断开DJI MQTT连接失败: {}", e.getMessage(), e); + log.error("MQTT客户端[{}]断开连接失败: {}", config.getClientId(), e.getMessage(), e); } } @@ -156,4 +146,11 @@ public class DjiMqttClientService { public boolean isConnected() { return mqttClient != null && mqttClient.isConnected(); } + + /** + * 获取客户端ID + */ + public String getClientId() { + return config.getClientId(); + } } From a7b3fa2ee28c19567e8186a35de75fba51bccc96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 14:13:45 +0800 Subject: [PATCH 04/17] xx --- .../device/domain/impl/djimqtt/README.md | 202 ++++++++++++++++++ .../config/DjiMqttProperties.java | 2 +- .../ruoyi/device/service/impl/DjiService.java | 70 ++++++ 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md rename src/main/java/com/ruoyi/device/{domain/impl/djimqtt => service}/config/DjiMqttProperties.java (95%) create mode 100644 src/main/java/com/ruoyi/device/service/impl/DjiService.java diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md new file mode 100644 index 0000000..1798220 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/README.md @@ -0,0 +1,202 @@ +# DJI MQTT 模块使用说明(支持多客户端) + +## 概述 + +本模块实现了大疆MQTT消息的接收和处理功能,支持动态创建多个MQTT客户端,每个客户端可以连接到不同的服务器。 + +## 核心特性 + +✅ **多客户端支持** - 可以同时创建多个MQTT客户端 +✅ **动态配置** - 每个客户端可以独立配置IP、端口、用户名等 +✅ **独立消息处理** - 每个客户端有独立的消息处理器 +✅ **共享订阅可选** - 支持开启/关闭共享订阅 +✅ **完整数据模型** - 包含所有无人机和机场字段 +✅ **自动区分设备** - 自动识别无人机和机场数据 + +## 架构设计 + +``` +DjiMqttClientManager (管理器) + ↓ +DjiMqttClientService (客户端实例) + ↓ +DjiMqttMessageHandler (消息处理器) + ↓ +IDroneDataCallback / IDockDataCallback (回调接口) +``` + +## 快速开始 + +### 1. 注入管理器 + +```java +@Autowired +private DjiMqttClientManager clientManager; +``` + +### 2. 创建客户端 + +```java +// 构建配置 +DjiMqttClientConfig config = DjiMqttClientConfig.builder() + .host("mqtt.t-aaron.com") // MQTT服务器地址 + .port(10883) // 端口 + .clientId("my_client_1") // 客户端ID(必须唯一) + .username("admin") // 用户名 + .password("admin") // 密码 + .useSharedSubscription(true) // 是否使用共享订阅 + .sharedGroupName("dji-group") // 共享订阅组名 + .build(); + +// 创建并连接客户端 +String clientId = clientManager.createClient(config); +``` + +### 3. 注册回调 + +```java +// 获取消息处理器 +DjiMqttMessageHandler handler = clientManager.getHandler(clientId); + +// 注册无人机数据回调 +handler.registerDroneDataCallback(droneData -> { + System.out.println("无人机SN: " + droneData.getDeviceSn()); + System.out.println("位置: " + droneData.getLatitude() + ", " + droneData.getLongitude()); +}); + +// 注册机场数据回调 +handler.registerDockDataCallback(dockData -> { + System.out.println("机场SN: " + dockData.getDeviceSn()); + System.out.println("温度: " + dockData.getTemperature()); +}); +``` + +## 多客户端示例 + +### 场景:同时连接多个MQTT服务器 + +```java +@Component +public class MyMqttService { + + @Autowired + private DjiMqttClientManager clientManager; + + public void init() { + // 客户端1:连接到服务器A + DjiMqttClientConfig config1 = DjiMqttClientConfig.builder() + .host("mqtt.server-a.com") + .port(10883) + .clientId("client_a") + .username("admin") + .password("admin") + .useSharedSubscription(true) + .build(); + + String clientId1 = clientManager.createClient(config1); + DjiMqttMessageHandler handler1 = clientManager.getHandler(clientId1); + handler1.registerDroneDataCallback(data -> processServerA(data)); + + // 客户端2:连接到服务器B + DjiMqttClientConfig config2 = DjiMqttClientConfig.builder() + .host("mqtt.server-b.com") + .port(1883) + .clientId("client_b") + .username("user2") + .password("pass2") + .useSharedSubscription(false) + .build(); + + String clientId2 = clientManager.createClient(config2); + DjiMqttMessageHandler handler2 = clientManager.getHandler(clientId2); + handler2.registerDroneDataCallback(data -> processServerB(data)); + } +} +``` + +## 管理器API + +### 创建客户端 +```java +String clientId = clientManager.createClient(config); +``` + +### 获取消息处理器 +```java +DjiMqttMessageHandler handler = clientManager.getHandler(clientId); +``` + +### 获取客户端 +```java +DjiMqttClientService client = clientManager.getClient(clientId); +boolean isConnected = client.isConnected(); +``` + +### 移除客户端 +```java +clientManager.removeClient(clientId); +``` + +### 断开所有客户端 +```java +clientManager.disconnectAll(); +``` + +### 获取所有客户端ID +```java +Set clientIds = clientManager.getAllClientIds(); +``` + +## 配置参数说明 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| host | String | 是 | - | MQTT服务器地址 | +| port | Integer | 是 | - | MQTT服务器端口 | +| clientId | String | 是 | - | 客户端ID(必须唯一) | +| username | String | 是 | - | 用户名 | +| password | String | 是 | - | 密码 | +| connectionTimeout | Integer | 否 | 30 | 连接超时时间(秒) | +| keepAliveInterval | Integer | 否 | 60 | 保持连接时间(秒) | +| autoReconnect | Boolean | 否 | true | 自动重连 | +| cleanSession | Boolean | 否 | false | 清除会话 | +| useSharedSubscription | Boolean | 否 | true | 是否使用共享订阅 | +| sharedGroupName | String | 否 | dji-group | 共享订阅组名 | + +## 数据模型 + +### DroneData(无人机数据) + +包含100+字段,主要分类: +- **基础信息**:固件版本、飞行器状态、档位等 +- **位置信息**:经纬度、高度、Home点等 +- **姿态信息**:偏航角、横滚角、俯仰角 +- **速度信息**:水平速度、垂直速度、风速 +- **电池信息**:电量、剩余飞行时间、电池详情 +- **相机信息**:拍照录像状态、变焦、红外测温等 +- **避障信息**:水平/上视/下视避障状态 +- **保养信息**:保养状态、累计飞行时间/架次 + +### DockData(机场数据) + +包含60+字段,主要分类: +- **基础信息**:固件版本、机场状态、任务状态 +- **位置信息**:经纬度、高度、朝向角 +- **环境信息**:温度、湿度、风速、降雨量 +- **设备状态**:舱盖状态、飞行器在舱状态、补光灯等 +- **电池信息**:充电状态、备用电池、电池保养 +- **网络信息**:网络类型、质量、速率 +- **图传信息**:4G/SDR链路状态、信号质量 + +## 注意事项 + +1. **clientId必须唯一**:每个MQTT客户端的clientId必须全局唯一 +2. **部分字段推送**:每次MQTT消息可能只包含部分字段,使用时需要判空 +3. **原始数据访问**:所有字段都保存在`rawData`中,可以通过Map访问 +4. **共享订阅**:多实例部署时建议开启共享订阅,避免重复消费 +5. **独立处理器**:每个客户端有独立的消息处理器,互不影响 +6. **自动重连**:连接断开后会自动重连(可配置) + +## 完整示例 + +参考 `DjiMqttUsageExample.java` 获取完整示例代码。 diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/config/DjiMqttProperties.java b/src/main/java/com/ruoyi/device/service/config/DjiMqttProperties.java similarity index 95% rename from src/main/java/com/ruoyi/device/domain/impl/djimqtt/config/DjiMqttProperties.java rename to src/main/java/com/ruoyi/device/service/config/DjiMqttProperties.java index 4be0041..b318d03 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/config/DjiMqttProperties.java +++ b/src/main/java/com/ruoyi/device/service/config/DjiMqttProperties.java @@ -1,4 +1,4 @@ -package com.ruoyi.device.domain.impl.djimqtt.config; +package com.ruoyi.device.service.config; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/src/main/java/com/ruoyi/device/service/impl/DjiService.java b/src/main/java/com/ruoyi/device/service/impl/DjiService.java new file mode 100644 index 0000000..b43de63 --- /dev/null +++ b/src/main/java/com/ruoyi/device/service/impl/DjiService.java @@ -0,0 +1,70 @@ +package com.ruoyi.device.service.impl; + +import com.ruoyi.device.domain.impl.djimqtt.callback.IDockDataCallback; +import com.ruoyi.device.domain.impl.djimqtt.callback.IDroneDataCallback; +import com.ruoyi.device.domain.impl.djimqtt.config.DjiMqttClientConfig; +import com.ruoyi.device.service.config.DjiMqttProperties; +import com.ruoyi.device.domain.impl.djimqtt.handler.DjiMqttMessageHandler; +import com.ruoyi.device.domain.impl.djimqtt.manager.DjiMqttClientManager; +import com.ruoyi.device.domain.impl.djimqtt.model.DockData; +import com.ruoyi.device.domain.impl.djimqtt.model.DroneData; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class DjiService { + + @Autowired + private DjiMqttClientManager clientManager; + + @Autowired + private DjiMqttProperties mqttProperties; + + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + + // 从配置文件读取配置 + DjiMqttClientConfig config = DjiMqttClientConfig.builder() + .host(mqttProperties.getHost()) + .port(mqttProperties.getPort()) + .clientId(mqttProperties.getClientId()) + .username(mqttProperties.getUsername()) + .password(mqttProperties.getPassword()) + .connectionTimeout(mqttProperties.getConnectionTimeout()) + .keepAliveInterval(mqttProperties.getKeepAliveInterval()) + .autoReconnect(mqttProperties.getAutoReconnect()) + .cleanSession(mqttProperties.getCleanSession()) + .useSharedSubscription(true) + .sharedGroupName("dji-group") + .build(); + + // 创建客户端 + String clientId = clientManager.createClient(config); + + // 获取消息处理器 + DjiMqttMessageHandler handler = clientManager.getHandler(clientId); + + // 注册无人机数据回调 + handler.registerDroneDataCallback(new IDroneDataCallback() { + @Override + public void onDroneData(DroneData droneData) { + log.info("droneData:{}", droneData); + } + }); + + // 注册机场数据回调 + handler.registerDockDataCallback(new IDockDataCallback() { + @Override + public void onDockData(DockData dockData) { + log.info("droneData:{}", dockData); + } + }); + + log.info("客户端已创建并注册回调"); + } + +} From ad38bce4597c881bf367897b58f6284565b9ea13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 14:26:08 +0800 Subject: [PATCH 05/17] xx --- .../com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java | 2 +- .../model/drone/{BatteryInfo.java => DroneBatteryInfo.java} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/{BatteryInfo.java => DroneBatteryInfo.java} (96%) diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java index 1b359ee..d6449a5 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java @@ -257,7 +257,7 @@ public class DroneData { * 飞行器电池信息 */ @JsonProperty("battery") - private BatteryInfo battery; + private DroneBatteryInfo battery; /** * 严重低电量告警百分比 diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/BatteryInfo.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/DroneBatteryInfo.java similarity index 96% rename from src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/BatteryInfo.java rename to src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/DroneBatteryInfo.java index a0f7d20..4d08752 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/BatteryInfo.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/drone/DroneBatteryInfo.java @@ -13,7 +13,7 @@ import java.util.List; */ @Data @JsonIgnoreProperties(ignoreUnknown = true) -public class BatteryInfo { +public class DroneBatteryInfo { /** * 电池的总剩余电量 From 0b47cbbf5767d1e17b3ae7f41a83aaeb68d7f72f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 14:29:48 +0800 Subject: [PATCH 06/17] xx --- .../com/ruoyi/device/domain/impl/djimqtt/model/DockData.java | 4 ++-- .../com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DockData.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DockData.java index 39551a3..665131e 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DockData.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DockData.java @@ -255,7 +255,7 @@ public class DockData { * 机场激活时间(unix 时间戳) */ @JsonProperty("activation_time") - private Integer activationTime; + private Long activationTime; /** * 固件升级状态:0-未升级,1-升级中 @@ -287,5 +287,5 @@ public class DockData { * 首次上电时间(毫秒) */ @JsonProperty("first_power_on") - private Integer firstPowerOn; + private Long firstPowerOn; } diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java index d6449a5..bacdd29 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DroneData.java @@ -123,7 +123,7 @@ public class DroneData { * 飞行器激活时间(unix 时间戳) */ @JsonProperty("activation_time") - private Integer activationTime; + private Long activationTime; /** * 保养信息 From 035e558ef70ce1699facd7a70678f42df5f2eac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 14:42:25 +0800 Subject: [PATCH 07/17] xx --- pom.xml | 7 +- .../djimqtt/service/DjiMqttClientService.java | 141 ++++++++++-------- 2 files changed, 82 insertions(+), 66 deletions(-) diff --git a/pom.xml b/pom.xml index b6a0a18..b59866c 100644 --- a/pom.xml +++ b/pom.xml @@ -105,11 +105,10 @@ provided - + - org.eclipse.paho - org.eclipse.paho.client.mqttv3 - 1.2.5 + org.springframework.integration + spring-integration-mqtt diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java index b98cb34..d08d7c4 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java @@ -3,12 +3,17 @@ package com.ruoyi.device.domain.impl.djimqtt.service; import com.ruoyi.device.domain.impl.djimqtt.config.DjiMqttClientConfig; import com.ruoyi.device.domain.impl.djimqtt.handler.DjiMqttMessageHandler; import lombok.extern.slf4j.Slf4j; -import org.eclipse.paho.client.mqttv3.*; -import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.springframework.integration.mqtt.core.DefaultMqttPahoClientFactory; +import org.springframework.integration.mqtt.core.MqttPahoClientFactory; +import org.springframework.integration.mqtt.inbound.MqttPahoMessageDrivenChannelAdapter; +import org.springframework.integration.mqtt.support.DefaultPahoMessageConverter; +import org.springframework.integration.channel.DirectChannel; +import org.springframework.messaging.Message; /** - * DJI MQTT客户端服务(可实例化) - * 支持动态创建多个客户端 + * DJI MQTT客户端服务(基于Spring Integration MQTT) + * 支持动态创建多个客户端,自动重连和重新订阅 * * @author ruoyi */ @@ -17,7 +22,7 @@ public class DjiMqttClientService { private final DjiMqttClientConfig config; private final DjiMqttMessageHandler messageHandler; - private MqttClient mqttClient; + private MqttPahoMessageDrivenChannelAdapter adapter; /** * 无人机OSD主题 @@ -45,7 +50,7 @@ public class DjiMqttClientService { */ public void connect() { try { - if (mqttClient != null && mqttClient.isConnected()) { + if (adapter != null && adapter.isRunning()) { log.info("MQTT客户端[{}]已连接,无需重复连接", config.getClientId()); return; } @@ -53,75 +58,88 @@ public class DjiMqttClientService { String broker = String.format("tcp://%s:%d", config.getHost(), config.getPort()); log.info("开始连接DJI MQTT服务器[{}]: {}", config.getClientId(), broker); - mqttClient = new MqttClient(broker, config.getClientId(), new MemoryPersistence()); + // 创建MQTT客户端工厂 + MqttPahoClientFactory clientFactory = createClientFactory(broker); - MqttConnectOptions options = new MqttConnectOptions(); - options.setUserName(config.getUsername()); - options.setPassword(config.getPassword().toCharArray()); - options.setConnectionTimeout(config.getConnectionTimeout()); - options.setKeepAliveInterval(config.getKeepAliveInterval()); - options.setAutomaticReconnect(config.getAutoReconnect()); - options.setCleanSession(config.getCleanSession()); - options.setMqttVersion(MqttConnectOptions.MQTT_VERSION_3_1_1); + // 构建订阅主题 + String[] topics = buildTopics(); - mqttClient.setCallback(new MqttCallback() { - @Override - public void connectionLost(Throwable cause) { - log.error("MQTT客户端[{}]连接丢失: {}", config.getClientId(), cause.getMessage(), cause); - } + // 创建消息驱动适配器 + adapter = new MqttPahoMessageDrivenChannelAdapter( + config.getClientId(), + clientFactory, + topics + ); - @Override - public void messageArrived(String topic, MqttMessage message) { - try { - String payload = new String(message.getPayload()); - messageHandler.handleMessage(topic, payload); - } catch (Exception e) { - log.error("MQTT客户端[{}]处理消息失败: {}", config.getClientId(), e.getMessage(), e); - } - } + // 设置消息转换器 + adapter.setConverter(new DefaultPahoMessageConverter()); - @Override - public void deliveryComplete(IMqttDeliveryToken token) { - // 不需要处理 - } - }); + // 设置QoS + adapter.setQos(1); - mqttClient.connect(options); - log.info("MQTT客户端[{}]成功连接到服务器", config.getClientId()); + // 创建消息通道并设置消息处理器 + DirectChannel channel = new DirectChannel(); + channel.subscribe(this::handleMessage); + adapter.setOutputChannel(channel); - subscribe(); + // 启动适配器 + adapter.start(); + + log.info("MQTT客户端[{}]成功连接到服务器并订阅主题", config.getClientId()); } catch (Exception e) { log.error("MQTT客户端[{}]连接失败: {}", config.getClientId(), e.getMessage(), e); + throw new RuntimeException("MQTT连接失败", e); } } /** - * 订阅主题 + * 创建MQTT客户端工厂 */ - private void subscribe() { + private MqttPahoClientFactory createClientFactory(String broker) { + DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory(); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setServerURIs(new String[]{broker}); + options.setUserName(config.getUsername()); + options.setPassword(config.getPassword().toCharArray()); + options.setConnectionTimeout(config.getConnectionTimeout()); + options.setKeepAliveInterval(config.getKeepAliveInterval()); + options.setAutomaticReconnect(config.getAutoReconnect()); + options.setCleanSession(config.getCleanSession()); + + factory.setConnectionOptions(options); + return factory; + } + + /** + * 构建订阅主题 + */ + private String[] buildTopics() { + String osdTopic = config.getUseSharedSubscription() + ? String.format("$share/%s/%s", config.getSharedGroupName(), DRONE_OSD_TOPIC) + : DRONE_OSD_TOPIC; + + String stateTopic = config.getUseSharedSubscription() + ? String.format("$share/%s/%s", config.getSharedGroupName(), DRONE_STATE_TOPIC) + : DRONE_STATE_TOPIC; + + return new String[]{osdTopic, stateTopic}; + } + + /** + * 处理接收到的MQTT消息 + */ + private void handleMessage(Message message) { try { - if (mqttClient == null || !mqttClient.isConnected()) { - log.warn("MQTT客户端[{}]未连接,无法订阅主题", config.getClientId()); - return; + String topic = (String) message.getHeaders().get("mqtt_receivedTopic"); + String payload = (String) message.getPayload(); + + if (topic != null && payload != null) { + messageHandler.handleMessage(topic, payload); } - - String osdTopic = config.getUseSharedSubscription() - ? String.format("$share/%s/%s", config.getSharedGroupName(), DRONE_OSD_TOPIC) - : DRONE_OSD_TOPIC; - - String stateTopic = config.getUseSharedSubscription() - ? String.format("$share/%s/%s", config.getSharedGroupName(), DRONE_STATE_TOPIC) - : DRONE_STATE_TOPIC; - - mqttClient.subscribe(osdTopic, 1); - log.info("MQTT客户端[{}]成功订阅主题: {}", config.getClientId(), osdTopic); - - mqttClient.subscribe(stateTopic, 1); - log.info("MQTT客户端[{}]成功订阅主题: {}", config.getClientId(), stateTopic); - } catch (Exception e) { - log.error("MQTT客户端[{}]订阅主题失败: {}", config.getClientId(), e.getMessage(), e); + log.error("MQTT客户端[{}]处理消息失败: ", config.getClientId(), e.getMessage(), e); } } @@ -130,9 +148,8 @@ public class DjiMqttClientService { */ public void disconnect() { try { - if (mqttClient != null && mqttClient.isConnected()) { - mqttClient.disconnect(); - mqttClient.close(); + if (adapter != null && adapter.isRunning()) { + adapter.stop(); log.info("MQTT客户端[{}]已断开连接", config.getClientId()); } } catch (Exception e) { @@ -144,7 +161,7 @@ public class DjiMqttClientService { * 检查连接状态 */ public boolean isConnected() { - return mqttClient != null && mqttClient.isConnected(); + return adapter != null && adapter.isRunning(); } /** From 9999fd922b03174496c55c5fdb2e7e02fceeb80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 14:52:30 +0800 Subject: [PATCH 08/17] xx --- .../domain/impl/djimqtt/service/DjiMqttClientService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java index d08d7c4..4b6503a 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java @@ -135,11 +135,11 @@ public class DjiMqttClientService { String topic = (String) message.getHeaders().get("mqtt_receivedTopic"); String payload = (String) message.getPayload(); - if (topic != null && payload != null) { + if (topic != null) { messageHandler.handleMessage(topic, payload); } } catch (Exception e) { - log.error("MQTT客户端[{}]处理消息失败: ", config.getClientId(), e.getMessage(), e); + log.error("MQTT客户端[{}]处理消息失败: {}", config.getClientId(), e.getMessage(), e); } } From d4b3d86b3cce9a29e7e65f704442d45106ee0586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 14:57:29 +0800 Subject: [PATCH 09/17] xx --- .../domain/impl/djimqtt/service/DjiMqttClientService.java | 3 +++ src/main/resources/bootstrap.yml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java index 4b6503a..ad3ab9a 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java @@ -108,6 +108,9 @@ public class DjiMqttClientService { options.setAutomaticReconnect(config.getAutoReconnect()); options.setCleanSession(config.getCleanSession()); + // 设置MQTT版本为5.0 + options.setMqttVersion(MqttConnectOptions.MQTT_VERSION_5_0); + factory.setConnectionOptions(options); return factory; } diff --git a/src/main/resources/bootstrap.yml b/src/main/resources/bootstrap.yml index c9b63f8..140942b 100644 --- a/src/main/resources/bootstrap.yml +++ b/src/main/resources/bootstrap.yml @@ -48,7 +48,7 @@ dji: host: mqtt.t-aaron.com port: 10883 version: 5 - client-id: ThingsBoard_gateway + client-id: mqttx_c1c67436 username: admin password: admin connection-timeout: 30 From faa7ca1790539370374427897721963d5a2acccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 15:03:05 +0800 Subject: [PATCH 10/17] xx --- pom.xml | 7 + .../djimqtt/service/DjiMqttClientService.java | 176 ++++++++++-------- 2 files changed, 102 insertions(+), 81 deletions(-) diff --git a/pom.xml b/pom.xml index b59866c..f22b25b 100644 --- a/pom.xml +++ b/pom.xml @@ -111,6 +111,13 @@ spring-integration-mqtt + + + org.eclipse.paho + org.eclipse.paho.mqttv5.client + 1.2.5 + + diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java index ad3ab9a..c158eae 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/service/DjiMqttClientService.java @@ -3,17 +3,19 @@ package com.ruoyi.device.domain.impl.djimqtt.service; import com.ruoyi.device.domain.impl.djimqtt.config.DjiMqttClientConfig; import com.ruoyi.device.domain.impl.djimqtt.handler.DjiMqttMessageHandler; import lombok.extern.slf4j.Slf4j; -import org.eclipse.paho.client.mqttv3.MqttConnectOptions; -import org.springframework.integration.mqtt.core.DefaultMqttPahoClientFactory; -import org.springframework.integration.mqtt.core.MqttPahoClientFactory; -import org.springframework.integration.mqtt.inbound.MqttPahoMessageDrivenChannelAdapter; -import org.springframework.integration.mqtt.support.DefaultPahoMessageConverter; -import org.springframework.integration.channel.DirectChannel; -import org.springframework.messaging.Message; +import org.eclipse.paho.mqttv5.client.IMqttToken; +import org.eclipse.paho.mqttv5.client.MqttCallback; +import org.eclipse.paho.mqttv5.client.MqttClient; +import org.eclipse.paho.mqttv5.client.MqttConnectionOptions; +import org.eclipse.paho.mqttv5.client.MqttDisconnectResponse; +import org.eclipse.paho.mqttv5.client.persist.MemoryPersistence; +import org.eclipse.paho.mqttv5.common.MqttException; +import org.eclipse.paho.mqttv5.common.MqttMessage; +import org.eclipse.paho.mqttv5.common.packet.MqttProperties; /** - * DJI MQTT客户端服务(基于Spring Integration MQTT) - * 支持动态创建多个客户端,自动重连和重新订阅 + * DJI MQTT客户端服务(基于Eclipse Paho MQTT v5) + * 支持MQTT 5.0协议,动态创建多个客户端 * * @author ruoyi */ @@ -22,7 +24,7 @@ public class DjiMqttClientService { private final DjiMqttClientConfig config; private final DjiMqttMessageHandler messageHandler; - private MqttPahoMessageDrivenChannelAdapter adapter; + private MqttClient mqttClient; /** * 无人机OSD主题 @@ -50,7 +52,7 @@ public class DjiMqttClientService { */ public void connect() { try { - if (adapter != null && adapter.isRunning()) { + if (mqttClient != null && mqttClient.isConnected()) { log.info("MQTT客户端[{}]已连接,无需重复连接", config.getClientId()); return; } @@ -58,91 +60,102 @@ public class DjiMqttClientService { String broker = String.format("tcp://%s:%d", config.getHost(), config.getPort()); log.info("开始连接DJI MQTT服务器[{}]: {}", config.getClientId(), broker); - // 创建MQTT客户端工厂 - MqttPahoClientFactory clientFactory = createClientFactory(broker); + mqttClient = new MqttClient(broker, config.getClientId(), new MemoryPersistence()); - // 构建订阅主题 - String[] topics = buildTopics(); + MqttConnectionOptions options = new MqttConnectionOptions(); + options.setUserName(config.getUsername()); + options.setPassword(config.getPassword().getBytes()); + options.setConnectionTimeout(config.getConnectionTimeout()); + options.setKeepAliveInterval(config.getKeepAliveInterval()); + options.setAutomaticReconnect(config.getAutoReconnect()); + options.setCleanStart(config.getCleanSession()); - // 创建消息驱动适配器 - adapter = new MqttPahoMessageDrivenChannelAdapter( - config.getClientId(), - clientFactory, - topics - ); + mqttClient.setCallback(new MqttCallback() { + @Override + public void disconnected(MqttDisconnectResponse disconnectResponse) { + log.error("MQTT客户端[{}]连接丢失: {}", config.getClientId(), + disconnectResponse.getReasonString()); - // 设置消息转换器 - adapter.setConverter(new DefaultPahoMessageConverter()); + if (config.getAutoReconnect()) { + log.info("MQTT客户端[{}]将自动重连...", config.getClientId()); + } + } - // 设置QoS - adapter.setQos(1); + @Override + public void mqttErrorOccurred(MqttException exception) { + log.error("MQTT客户端[{}]发生错误: {}", config.getClientId(), + exception.getMessage(), exception); + } - // 创建消息通道并设置消息处理器 - DirectChannel channel = new DirectChannel(); - channel.subscribe(this::handleMessage); - adapter.setOutputChannel(channel); + @Override + public void messageArrived(String topic, MqttMessage message) { + try { + String payload = new String(message.getPayload()); + messageHandler.handleMessage(topic, payload); + } catch (Exception e) { + log.error("MQTT客户端[{}]处理消息失败: {}", config.getClientId(), + e.getMessage(), e); + } + } - // 启动适配器 - adapter.start(); + @Override + public void deliveryComplete(IMqttToken token) { + // 不需要处理 + } - log.info("MQTT客户端[{}]成功连接到服务器并订阅主题", config.getClientId()); + @Override + public void connectComplete(boolean reconnect, String serverURI) { + if (reconnect) { + log.info("MQTT客户端[{}]重连成功: {}", config.getClientId(), serverURI); + // 重连后重新订阅 + subscribe(); + } else { + log.info("MQTT客户端[{}]首次连接成功: {}", config.getClientId(), serverURI); + } + } + + @Override + public void authPacketArrived(int reasonCode, MqttProperties properties) { + // 不需要处理 + } + }); + + mqttClient.connect(options); + log.info("MQTT客户端[{}]成功连接到服务器", config.getClientId()); + + subscribe(); } catch (Exception e) { log.error("MQTT客户端[{}]连接失败: {}", config.getClientId(), e.getMessage(), e); - throw new RuntimeException("MQTT连接失败", e); } } /** - * 创建MQTT客户端工厂 + * 订阅主题 */ - private MqttPahoClientFactory createClientFactory(String broker) { - DefaultMqttPahoClientFactory factory = new DefaultMqttPahoClientFactory(); - - MqttConnectOptions options = new MqttConnectOptions(); - options.setServerURIs(new String[]{broker}); - options.setUserName(config.getUsername()); - options.setPassword(config.getPassword().toCharArray()); - options.setConnectionTimeout(config.getConnectionTimeout()); - options.setKeepAliveInterval(config.getKeepAliveInterval()); - options.setAutomaticReconnect(config.getAutoReconnect()); - options.setCleanSession(config.getCleanSession()); - - // 设置MQTT版本为5.0 - options.setMqttVersion(MqttConnectOptions.MQTT_VERSION_5_0); - - factory.setConnectionOptions(options); - return factory; - } - - /** - * 构建订阅主题 - */ - private String[] buildTopics() { - String osdTopic = config.getUseSharedSubscription() - ? String.format("$share/%s/%s", config.getSharedGroupName(), DRONE_OSD_TOPIC) - : DRONE_OSD_TOPIC; - - String stateTopic = config.getUseSharedSubscription() - ? String.format("$share/%s/%s", config.getSharedGroupName(), DRONE_STATE_TOPIC) - : DRONE_STATE_TOPIC; - - return new String[]{osdTopic, stateTopic}; - } - - /** - * 处理接收到的MQTT消息 - */ - private void handleMessage(Message message) { + private void subscribe() { try { - String topic = (String) message.getHeaders().get("mqtt_receivedTopic"); - String payload = (String) message.getPayload(); - - if (topic != null) { - messageHandler.handleMessage(topic, payload); + if (mqttClient == null || !mqttClient.isConnected()) { + log.warn("MQTT客户端[{}]未连接,无法订阅主题", config.getClientId()); + return; } + + String osdTopic = config.getUseSharedSubscription() + ? String.format("$share/%s/%s", config.getSharedGroupName(), DRONE_OSD_TOPIC) + : DRONE_OSD_TOPIC; + + String stateTopic = config.getUseSharedSubscription() + ? String.format("$share/%s/%s", config.getSharedGroupName(), DRONE_STATE_TOPIC) + : DRONE_STATE_TOPIC; + + mqttClient.subscribe(osdTopic, 1); + log.info("MQTT客户端[{}]成功订阅主题: {}", config.getClientId(), osdTopic); + + mqttClient.subscribe(stateTopic, 1); + log.info("MQTT客户端[{}]成功订阅主题: {}", config.getClientId(), stateTopic); + } catch (Exception e) { - log.error("MQTT客户端[{}]处理消息失败: {}", config.getClientId(), e.getMessage(), e); + log.error("MQTT客户端[{}]订阅主题失败: {}", config.getClientId(), e.getMessage(), e); } } @@ -151,8 +164,9 @@ public class DjiMqttClientService { */ public void disconnect() { try { - if (adapter != null && adapter.isRunning()) { - adapter.stop(); + if (mqttClient != null && mqttClient.isConnected()) { + mqttClient.disconnect(); + mqttClient.close(); log.info("MQTT客户端[{}]已断开连接", config.getClientId()); } } catch (Exception e) { @@ -164,7 +178,7 @@ public class DjiMqttClientService { * 检查连接状态 */ public boolean isConnected() { - return adapter != null && adapter.isRunning(); + return mqttClient != null && mqttClient.isConnected(); } /** From cec370f0ddd6e147583c019dc7b96792412c29f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 15:10:25 +0800 Subject: [PATCH 11/17] xx --- .../device/domain/impl/djimqtt/model/DjiMqttMessage.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java index 52d529e..613350b 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java @@ -40,4 +40,10 @@ public class DjiMqttMessage { */ @JsonProperty("gateway") private String gateway; + + /** + * 消息来源 + */ + @JsonProperty("source") + private String source; } From 9f0efa5320d479b1cc3e35d7625424fef8d44db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 15:11:06 +0800 Subject: [PATCH 12/17] xxx --- src/main/java/com/ruoyi/device/service/impl/DjiService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/ruoyi/device/service/impl/DjiService.java b/src/main/java/com/ruoyi/device/service/impl/DjiService.java index b43de63..54d5344 100644 --- a/src/main/java/com/ruoyi/device/service/impl/DjiService.java +++ b/src/main/java/com/ruoyi/device/service/impl/DjiService.java @@ -52,7 +52,7 @@ public class DjiService { handler.registerDroneDataCallback(new IDroneDataCallback() { @Override public void onDroneData(DroneData droneData) { - log.info("droneData:{}", droneData); +// log.info("droneData:{}", droneData); } }); @@ -60,7 +60,7 @@ public class DjiService { handler.registerDockDataCallback(new IDockDataCallback() { @Override public void onDockData(DockData dockData) { - log.info("droneData:{}", dockData); +// log.info("droneData:{}", dockData); } }); From fe6e2c6cf83f9bd208c84974a2fa4f0667e45702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 15:16:29 +0800 Subject: [PATCH 13/17] xx --- .../device/domain/impl/djimqtt/model/DjiMqttMessage.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java index 613350b..58b791f 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java +++ b/src/main/java/com/ruoyi/device/domain/impl/djimqtt/model/DjiMqttMessage.java @@ -46,4 +46,10 @@ public class DjiMqttMessage { */ @JsonProperty("source") private String source; + + /** + * 是否需要回复 + */ + @JsonProperty("need_reply") + private Integer needReply; } From dfc5033eabce1de542a544b69f2605d919f3dc7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 15:26:47 +0800 Subject: [PATCH 14/17] x --- .../thingsboard/attributes/battery/BatteryData.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/ruoyi/device/domain/model/thingsboard/attributes/battery/BatteryData.java b/src/main/java/com/ruoyi/device/domain/model/thingsboard/attributes/battery/BatteryData.java index 19d371b..ef470ff 100644 --- a/src/main/java/com/ruoyi/device/domain/model/thingsboard/attributes/battery/BatteryData.java +++ b/src/main/java/com/ruoyi/device/domain/model/thingsboard/attributes/battery/BatteryData.java @@ -25,6 +25,9 @@ public class BatteryData { @JsonProperty("return_home_power") private Integer returnHomePower; + @JsonProperty("remain_job_time") + private Integer remainJobTime; + // 构造方法 public BatteryData() { } @@ -70,6 +73,14 @@ public class BatteryData { this.returnHomePower = returnHomePower; } + public Integer getRemainJobTime() { + return remainJobTime; + } + + public void setRemainJobTime(Integer remainJobTime) { + this.remainJobTime = remainJobTime; + } + @Override public String toString() { return "BatteryData{" + @@ -78,6 +89,7 @@ public class BatteryData { ", landingPower=" + landingPower + ", remainFlightTime=" + remainFlightTime + ", returnHomePower=" + returnHomePower + + ", remainJobTime=" + remainJobTime + '}'; } } \ No newline at end of file From a636c9a858b696b3cdb5ce2fad638c0630dae149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 15:52:20 +0800 Subject: [PATCH 15/17] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=85=85=E6=94=BE?= =?UTF-8?q?=E7=94=B5=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constants/DeviceTelemetry.java | 17 ++++++++- .../device/service/dto/DockDetailDTO.java | 5 +++ .../device/service/impl/BufferDeviceImpl.java | 37 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ruoyi/device/domain/model/thingsboard/constants/DeviceTelemetry.java b/src/main/java/com/ruoyi/device/domain/model/thingsboard/constants/DeviceTelemetry.java index 274d4f7..42c7e3d 100644 --- a/src/main/java/com/ruoyi/device/domain/model/thingsboard/constants/DeviceTelemetry.java +++ b/src/main/java/com/ruoyi/device/domain/model/thingsboard/constants/DeviceTelemetry.java @@ -240,6 +240,20 @@ public class DeviceTelemetry { } ); + /** + * 电量百分比 + */ + public static final TelemetryKey Drone_Charge_State_Capacity_Percent = TelemetryKey.of( + "drone_charge_state.capacity_percent", + Integer.class, + value -> { + if (value == null) return null; + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return Integer.parseInt(value.toString()); + } + ); /** * @@ -499,7 +513,8 @@ public class DeviceTelemetry { Alternate_land_point_Longitude, Environment_Temperature, Network_State_Rate, - Cover_State + Cover_State, + Drone_Charge_State_Capacity_Percent ); } diff --git a/src/main/java/com/ruoyi/device/service/dto/DockDetailDTO.java b/src/main/java/com/ruoyi/device/service/dto/DockDetailDTO.java index a4e127d..e36a7fd 100644 --- a/src/main/java/com/ruoyi/device/service/dto/DockDetailDTO.java +++ b/src/main/java/com/ruoyi/device/service/dto/DockDetailDTO.java @@ -125,6 +125,11 @@ public class DockDetailDTO implements Serializable /** 充放电状态 */ private String chargingStatus; + /** + * 电量百分比 + */ + private Integer capacity_percent; + /** 舱内温度 */ private Double cabinTemperature; diff --git a/src/main/java/com/ruoyi/device/service/impl/BufferDeviceImpl.java b/src/main/java/com/ruoyi/device/service/impl/BufferDeviceImpl.java index 555914f..7799275 100644 --- a/src/main/java/com/ruoyi/device/service/impl/BufferDeviceImpl.java +++ b/src/main/java/com/ruoyi/device/service/impl/BufferDeviceImpl.java @@ -340,6 +340,23 @@ public class BufferDeviceImpl implements IBufferDeviceService () -> log.warn("未获取到经度数据,dockerDeviceIotId: {}", dockerDeviceIotId) ); + // 无人机充电状态(枚举值:0-空闲,1-充电中) + telemetryMap.get(DeviceTelemetry.Drone_Charge_State_State) + .ifPresent(telemetryValue -> { + Integer chargeState = telemetryValue.getValue(); + if (chargeState != null) { + String chargingStatus = mapChargeStateToStatus(chargeState); + dto.setChargingStatus(chargingStatus); + } + }); + + // 电量百分比 + telemetryMap.get(DeviceTelemetry.Drone_Charge_State_Capacity_Percent) + .ifPresent(telemetryValue -> { + Integer capacityPercent = telemetryValue.getValue(); + dto.setCapacity_percent(capacityPercent); + }); + return dto; } @@ -385,6 +402,26 @@ public class BufferDeviceImpl implements IBufferDeviceService } } + /** + * 将充电状态代码映射到充电状态字符串 + * @param chargeState 充电状态代码(0-空闲,1-充电中) + * @return 充电状态字符串 + */ + private String mapChargeStateToStatus(Integer chargeState) { + if (chargeState == null) { + return "未知"; + } + + switch (chargeState) { + case 0: + return "FREE"; + case 1: + return "CHARGING"; + default: + return "UNKNOWN"; + } + } + /** * 将空调状态代码映射到 AirConditionerStatusEnum * @param stateCode 空调状态代码 From 8a418c063d4674a7c63fe1215d024c6923b39fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 15:57:01 +0800 Subject: [PATCH 16/17] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=85=85=E6=94=BE?= =?UTF-8?q?=E7=94=B5=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constants/DeviceTelemetry.java | 19 ++++++++++++++++++- .../device/service/dto/AircraftDetailDTO.java | 3 +++ .../device/service/impl/BufferDeviceImpl.java | 8 ++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/ruoyi/device/domain/model/thingsboard/constants/DeviceTelemetry.java b/src/main/java/com/ruoyi/device/domain/model/thingsboard/constants/DeviceTelemetry.java index 42c7e3d..061ca09 100644 --- a/src/main/java/com/ruoyi/device/domain/model/thingsboard/constants/DeviceTelemetry.java +++ b/src/main/java/com/ruoyi/device/domain/model/thingsboard/constants/DeviceTelemetry.java @@ -287,6 +287,22 @@ public class DeviceTelemetry { } ); + + /** + * 无人机 position_state.gps_number GPS 搜星数量 + */ + + public static final TelemetryKey Position_State_GPS_Number = TelemetryKey.of( + "position_state.gps_number", + Integer.class, + value -> { + if (value == null) return null; + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return Integer.parseInt(value.toString()); + } + ); /** * 风速 */ @@ -514,7 +530,8 @@ public class DeviceTelemetry { Environment_Temperature, Network_State_Rate, Cover_State, - Drone_Charge_State_Capacity_Percent + Drone_Charge_State_Capacity_Percent, + Position_State_GPS_Number ); } diff --git a/src/main/java/com/ruoyi/device/service/dto/AircraftDetailDTO.java b/src/main/java/com/ruoyi/device/service/dto/AircraftDetailDTO.java index b5017eb..7e703c4 100644 --- a/src/main/java/com/ruoyi/device/service/dto/AircraftDetailDTO.java +++ b/src/main/java/com/ruoyi/device/service/dto/AircraftDetailDTO.java @@ -70,6 +70,9 @@ public class AircraftDetailDTO implements Serializable /** RTK信号 */ private Integer rtkSignal; + /** GPS信号 */ + private Integer gpsSignal; + /** 限高 */ private Integer maxAltitude; diff --git a/src/main/java/com/ruoyi/device/service/impl/BufferDeviceImpl.java b/src/main/java/com/ruoyi/device/service/impl/BufferDeviceImpl.java index 7799275..80e1bb7 100644 --- a/src/main/java/com/ruoyi/device/service/impl/BufferDeviceImpl.java +++ b/src/main/java/com/ruoyi/device/service/impl/BufferDeviceImpl.java @@ -502,6 +502,14 @@ public class BufferDeviceImpl implements IBufferDeviceService } }); + telemetryMap.get(DeviceTelemetry.Position_State_GPS_Number) + .ifPresent(telemetryValue -> { + Integer gpsNumber = telemetryValue.getValue(); + if (gpsNumber != null) { + dto.setGpsSignal(gpsNumber); + } + }); + // 限高 telemetryMap.get(DeviceTelemetry.Height_Limit) .ifPresent(telemetryValue -> dto.setMaxAltitude(telemetryValue.getValue())); From 8c50ade9371d6afdeaaea442b1ac9baebd320886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Wed, 28 Jan 2026 16:21:09 +0800 Subject: [PATCH 17/17] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=85=85=E6=94=BE?= =?UTF-8?q?=E7=94=B5=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ruoyi/device/domain/model/Dock.java | 13 +++++++++++++ .../ruoyi/device/mapper/entity/DockEntity.java | 16 ++++++++++++++++ .../com/ruoyi/device/service/dto/DockDTO.java | 3 +++ .../ruoyi/device/service/dto/DockDetailDTO.java | 4 ++++ .../V6__Add_last_active_time_to_dock.sql | 5 +++++ src/main/resources/mapper/device/DockMapper.xml | 6 +++++- 6 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V6__Add_last_active_time_to_dock.sql diff --git a/src/main/java/com/ruoyi/device/domain/model/Dock.java b/src/main/java/com/ruoyi/device/domain/model/Dock.java index 1b9f8a1..8ca0477 100644 --- a/src/main/java/com/ruoyi/device/domain/model/Dock.java +++ b/src/main/java/com/ruoyi/device/domain/model/Dock.java @@ -41,6 +41,9 @@ public class Dock implements Serializable /** 备注 */ private String remark; + /** 最后活跃时间 */ + private Date lastActiveTime; + public Long getDockId() { return dockId; @@ -130,4 +133,14 @@ public class Dock implements Serializable { this.remark = remark; } + + public Date getLastActiveTime() + { + return lastActiveTime; + } + + public void setLastActiveTime(Date lastActiveTime) + { + this.lastActiveTime = lastActiveTime; + } } \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/mapper/entity/DockEntity.java b/src/main/java/com/ruoyi/device/mapper/entity/DockEntity.java index b898012..ae8e93f 100644 --- a/src/main/java/com/ruoyi/device/mapper/entity/DockEntity.java +++ b/src/main/java/com/ruoyi/device/mapper/entity/DockEntity.java @@ -2,6 +2,8 @@ package com.ruoyi.device.mapper.entity; import com.ruoyi.common.core.web.domain.BaseEntity; +import java.util.Date; + /** * 机场表实体对象 device_dock * Mapper 层实体,对应数据库表 @@ -25,6 +27,9 @@ public class DockEntity extends BaseEntity /** 设备表主键 */ private Long deviceId; + /** 最后活跃时间 */ + private Date lastActiveTime; + public Long getDockId() { return dockId; @@ -65,6 +70,16 @@ public class DockEntity extends BaseEntity this.deviceId = deviceId; } + public Date getLastActiveTime() + { + return lastActiveTime; + } + + public void setLastActiveTime(Date lastActiveTime) + { + this.lastActiveTime = lastActiveTime; + } + @Override public String toString() { @@ -73,6 +88,7 @@ public class DockEntity extends BaseEntity ", dockName='" + dockName + '\'' + ", dockLocation='" + dockLocation + '\'' + ", deviceId=" + deviceId + + ", lastActiveTime=" + lastActiveTime + '}'; } } \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/service/dto/DockDTO.java b/src/main/java/com/ruoyi/device/service/dto/DockDTO.java index a131120..e22da02 100644 --- a/src/main/java/com/ruoyi/device/service/dto/DockDTO.java +++ b/src/main/java/com/ruoyi/device/service/dto/DockDTO.java @@ -44,5 +44,8 @@ public class DockDTO implements Serializable /** 备注 */ private String remark; + /** 最后活跃时间 */ + private Date lastActiveTime; + } \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/service/dto/DockDetailDTO.java b/src/main/java/com/ruoyi/device/service/dto/DockDetailDTO.java index e36a7fd..4e07fc5 100644 --- a/src/main/java/com/ruoyi/device/service/dto/DockDetailDTO.java +++ b/src/main/java/com/ruoyi/device/service/dto/DockDetailDTO.java @@ -5,6 +5,7 @@ import com.ruoyi.device.api.domain.PayloadVO; import lombok.Data; import java.io.Serializable; +import java.util.Date; import java.util.List; /** @@ -154,5 +155,8 @@ public class DockDetailDTO implements Serializable */ private Double longitude; + /** 最后活跃时间 */ + private Date lastActiveTime; + } diff --git a/src/main/resources/db/migration/V6__Add_last_active_time_to_dock.sql b/src/main/resources/db/migration/V6__Add_last_active_time_to_dock.sql new file mode 100644 index 0000000..155986c --- /dev/null +++ b/src/main/resources/db/migration/V6__Add_last_active_time_to_dock.sql @@ -0,0 +1,5 @@ +-- 添加 last_active_time 字段到 dock 表 +ALTER TABLE dock ADD COLUMN last_active_time DATETIME COMMENT '最后活跃时间'; + +-- 为 last_active_time 字段创建索引以提高查询性能 +CREATE INDEX idx_dock_last_active_time ON dock(last_active_time); diff --git a/src/main/resources/mapper/device/DockMapper.xml b/src/main/resources/mapper/device/DockMapper.xml index 1234e2d..e56befc 100644 --- a/src/main/resources/mapper/device/DockMapper.xml +++ b/src/main/resources/mapper/device/DockMapper.xml @@ -14,11 +14,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + select dock_id, dock_name, dock_location, device_id, - create_by, create_time, update_by, update_time, remark + create_by, create_time, update_by, update_time, remark, last_active_time from device_dock @@ -61,6 +62,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" device_id, create_by, remark, + last_active_time, create_time @@ -69,6 +71,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{deviceId}, #{createBy}, #{remark}, + #{lastActiveTime}, now() @@ -81,6 +84,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" device_id = #{deviceId}, update_by = #{updateBy}, remark = #{remark}, + last_active_time = #{lastActiveTime}, update_time = now() where dock_id = #{dockId}