From 20f11d581b6ec33e20ff86f9977b5137f0dd3c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AD=99=E5=B0=8F=E4=BA=91?= Date: Tue, 10 Feb 2026 15:07:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=8B=93=E6=81=92=E6=9C=BA?= =?UTF-8?q?=E5=9C=BA=E5=92=8C=E6=97=A0=E4=BA=BA=E6=9C=BA=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AircraftFlyController.java | 44 +++++ .../config/MachineFrameworkConfig.java | 9 +- .../impl/machine/state/AirportState.java | 9 +- .../domain/impl/machine/state/DroneState.java | 9 +- .../store/InMemorySnVendorMappingStore.java | 39 ++++- .../vendor/tuoheng/TuohengVendorConfig.java | 95 +++++++++++ .../TuohengPowerOnInstruction.java | 69 ++++++++ .../tuohengmqtt/model/AirportOsdData.java | 1 - .../device/service/impl/TuohengService.java | 156 +++++++++++++++++- 9 files changed, 423 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/tuoheng/TuohengVendorConfig.java create mode 100644 src/main/java/com/ruoyi/device/domain/impl/machine/vendor/tuoheng/instruction/TuohengPowerOnInstruction.java diff --git a/src/main/java/com/ruoyi/device/controller/AircraftFlyController.java b/src/main/java/com/ruoyi/device/controller/AircraftFlyController.java index 68b8320..5377ccf 100644 --- a/src/main/java/com/ruoyi/device/controller/AircraftFlyController.java +++ b/src/main/java/com/ruoyi/device/controller/AircraftFlyController.java @@ -8,22 +8,32 @@ import com.ruoyi.device.api.domain.DroneRealtimeInfoVO; import com.ruoyi.device.api.domain.DroneTakeoffResponseVO; import com.ruoyi.device.api.enums.DroneCurrentStatusEnum; import com.ruoyi.device.api.enums.DroneMissionStatusEnum; +import com.ruoyi.device.domain.impl.machine.MachineCommandManager; +import com.ruoyi.device.domain.impl.machine.command.CommandResult; +import com.ruoyi.device.domain.impl.machine.command.CommandType; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; +import java.util.concurrent.CompletableFuture; + /** * 无人机飞控Controller * * @author ruoyi * @date 2026-02-04 */ +@Slf4j @Tag(name = "无人机飞控管理", description = "无人机飞控相关接口") @RestController @RequestMapping("/drone") public class AircraftFlyController extends BaseController { + @Autowired + private MachineCommandManager machineCommandManager; /** * 无人机飞控命令 * @@ -101,4 +111,38 @@ public class AircraftFlyController extends BaseController vo.setMissionStatus(DroneMissionStatusEnum.TAKING_OFF); return R.ok(vo); } + + /** + * 无人机开机接口 + * + * @param sn 机场SN号 + * @return 开机响应 + */ + @Operation(summary = "无人机开机", description = "控制指定机场的无人机执行开机操作") + @PostMapping("/power-on/{sn}") + public R powerOn( + @Parameter(description = "机场SN号", required = true, example = "THJSQ03B2309DN7VQN43") + @PathVariable("sn") String sn) + { + log.info("收到无人机开机请求: sn={}", sn); + + try { + // 调用机器命令管理器执行开机命令 + CompletableFuture future = machineCommandManager.executeCommand(sn, CommandType.POWER_ON); + + // 等待命令执行完成 + CommandResult result = future.get(); + + if (result.isSuccess()) { + log.info("无人机开机成功: sn={}", sn); + return R.ok("开机命令执行成功"); + } else { + log.error("无人机开机失败: sn={}, reason={}", sn, result.getErrorMessage()); + return R.fail("开机命令执行失败: " + result.getErrorMessage()); + } + } catch (Exception e) { + log.error("无人机开机异常: sn={}", sn, e); + return R.fail("开机命令执行异常: " + e.getMessage()); + } + } } \ 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 index 3817587..00fc4b7 100644 --- 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 @@ -3,6 +3,7 @@ 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 com.ruoyi.device.domain.impl.machine.vendor.tuoheng.TuohengVendorConfig; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; @@ -19,10 +20,16 @@ public class MachineFrameworkConfig { * 自动注册所有厂家配置 */ @Bean - public CommandLineRunner registerVendors(VendorRegistry vendorRegistry, DjiVendorConfig djiVendorConfig) { + public CommandLineRunner registerVendors(VendorRegistry vendorRegistry, + DjiVendorConfig djiVendorConfig, + TuohengVendorConfig tuohengVendorConfig) { return args -> { // 注册大疆厂家配置 vendorRegistry.registerVendor(djiVendorConfig); + + // 注册拓恒厂家配置 + vendorRegistry.registerVendor(tuohengVendorConfig); + log.info("设备框架初始化完成,已注册厂家: {}", vendorRegistry.getAllVendorTypes()); }; } 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 index 7652032..7336d05 100644 --- 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 @@ -4,12 +4,17 @@ package com.ruoyi.device.domain.impl.machine.state; */ public enum AirportState { /** - * 未知状态(服务器重启后的初始状态,等待第一次心跳同步),同时也是离线状态 + * 未知状态(服务器重启后的初始状态,等待第一次心跳同步) */ UNKNOWN, /** * 在线 */ - ONLINE + ONLINE, + + /** + * 离线(心跳超时) + */ + OFFLINE } 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 index 5598ab2..85ddf91 100644 --- 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 @@ -6,12 +6,17 @@ package com.ruoyi.device.domain.impl.machine.state; */ public enum DroneState { /** - * 未知状态(服务器重启后的初始状态,等待第一次心跳同步),同时也是离线状态 + * 未知状态(服务器重启后的初始状态,等待第一次心跳同步) */ UNKNOWN, /** - * 在线 + * 关机状态 + */ + POWER_OFF, + + /** + * 在线(已开机) */ ONLINE, 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 index 3b9c058..f1e9bf3 100644 --- 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 @@ -23,8 +23,25 @@ public class InMemorySnVendorMappingStore implements SnVendorMappingStore { @Override public String getVendorType(String sn) { + // 先从缓存中获取 String vendorType = snToVendorMap.get(sn); - log.debug("从内存获取 SN 映射: sn={}, vendorType={}", sn, vendorType); + + if (vendorType != null) { + log.debug("从内存缓存获取 SN 映射: sn={}, vendorType={}", sn, vendorType); + return vendorType; + } + + // 根据 SN 前缀自动判断厂商类型 + vendorType = detectVendorTypeBySn(sn); + + if (vendorType != null) { + // 缓存判断结果 + snToVendorMap.put(sn, vendorType); + log.debug("根据 SN 前缀自动识别厂商: sn={}, vendorType={}", sn, vendorType); + } else { + log.warn("无法识别 SN 对应的厂商类型: sn={}", sn); + } + return vendorType; } @@ -44,4 +61,24 @@ public class InMemorySnVendorMappingStore implements SnVendorMappingStore { public boolean exists(String sn) { return snToVendorMap.containsKey(sn); } + + /** + * 根据 SN 前缀自动识别厂商类型 + * + * @param sn 设备SN号 + * @return 厂商类型,无法识别返回 null + */ + private String detectVendorTypeBySn(String sn) { + if (sn == null || sn.isEmpty()) { + return null; + } + + // 拓恒设备:SN 以 "TH" 开头 + if (sn.startsWith("TH")) { + return "TUOHENG"; + } + + // 大疆设备:其他情况默认为大疆 + return "DJI"; + } } \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/tuoheng/TuohengVendorConfig.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/tuoheng/TuohengVendorConfig.java new file mode 100644 index 0000000..fbd6ea9 --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/tuoheng/TuohengVendorConfig.java @@ -0,0 +1,95 @@ +package com.ruoyi.device.domain.impl.machine.vendor.tuoheng; + +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 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 TuohengVendorConfig implements VendorConfig { + + private final Map transactionMap = new HashMap<>(); + + public TuohengVendorConfig() { + initTransactions(); + } + + @Override + public String getVendorType() { + return "TUOHENG"; + } + + @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(); + DebugModeState debugModeState = currentStates.getDebugModeState(); + + switch (commandType) { + case POWER_ON: + // 开机前置条件:机场在线、无人机关机 + // 注:拓恒无人机没有调试模式概念 + return airportState == AirportState.ONLINE + && droneState == DroneState.POWER_OFF; + + 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 powerOnTransaction = new Transaction("开机", CommandType.POWER_ON) + .root(new com.ruoyi.device.domain.impl.machine.vendor.tuoheng.instruction.TuohengPowerOnInstruction()) + .setTimeout(60000); + transactionMap.put(powerOnTransaction.getCommandType(), powerOnTransaction); + + log.info("拓恒厂家配置初始化完成,共配置{}个命令", transactionMap.size()); + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/tuoheng/instruction/TuohengPowerOnInstruction.java b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/tuoheng/instruction/TuohengPowerOnInstruction.java new file mode 100644 index 0000000..e100abe --- /dev/null +++ b/src/main/java/com/ruoyi/device/domain/impl/machine/vendor/tuoheng/instruction/TuohengPowerOnInstruction.java @@ -0,0 +1,69 @@ +package com.ruoyi.device.domain.impl.machine.vendor.tuoheng.instruction; + +import com.alibaba.fastjson.JSONObject; +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 TuohengPowerOnInstruction extends AbstractInstruction { + + @Override + public String getName() { + return "TUOHENG_POWER_ON"; + } + + @Override + public void executeRemoteCall(InstructionContext context) throws Exception { + String sn = context.getSn(); + log.info("发送拓恒无人机开机指令: sn={}", sn); + + // 构建MQTT消息 + JSONObject payload = new JSONObject(); + payload.put("messageID", System.currentTimeMillis()); + payload.put("timestamp", System.currentTimeMillis()); + payload.put("code", "DronePower"); + payload.put("value", "1"); // 1=开机, 0=关机 + payload.put("channel", 1); + + String topic = "/topic/v1/airportControl/" + sn; + + context.getMqttClient().sendMessage(topic, payload.toJSONString()); + log.info("拓恒开机指令发送成功: topic={}, payload={}", topic, payload.toJSONString()); + } + + @Override + public CallbackConfig getMethodCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + // 监听机场确认消息 + return CallbackConfig.builder() + .topic("/topic/v1/airportNest/" + sn + "/confirm") + .fieldPath("code") + .expectedValue("DronePower") + .timeoutMs(10000) + .build(); + } + + @Override + public CallbackConfig getStateCallbackConfig(InstructionContext context) { + String sn = context.getSn(); + + // 监听无人机开机状态变化 + return CallbackConfig.builder() + .topic("/topic/v1/airportNest/" + sn + "/realTime/data") + .fieldPath("droneBattery.bPowerON") + .expectedValue("2") // 2表示已开机 + .timeoutMs(60000) + .build(); + } + + @Override + public long getTimeoutMs() { + return 60000; // 总超时时间60秒 + } +} \ No newline at end of file diff --git a/src/main/java/com/ruoyi/device/domain/impl/tuohengmqtt/model/AirportOsdData.java b/src/main/java/com/ruoyi/device/domain/impl/tuohengmqtt/model/AirportOsdData.java index c865479..9acf755 100644 --- a/src/main/java/com/ruoyi/device/domain/impl/tuohengmqtt/model/AirportOsdData.java +++ b/src/main/java/com/ruoyi/device/domain/impl/tuohengmqtt/model/AirportOsdData.java @@ -12,7 +12,6 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; * @author ruoyi */ @Data -@JsonIgnoreProperties(ignoreUnknown = true) public class AirportOsdData { @JsonProperty("working_current") diff --git a/src/main/java/com/ruoyi/device/service/impl/TuohengService.java b/src/main/java/com/ruoyi/device/service/impl/TuohengService.java index 0622b4b..5113887 100644 --- a/src/main/java/com/ruoyi/device/service/impl/TuohengService.java +++ b/src/main/java/com/ruoyi/device/service/impl/TuohengService.java @@ -5,6 +5,10 @@ import com.ruoyi.device.domain.api.IDockAircraftDomain; import com.ruoyi.device.domain.api.IDockDomain; import com.ruoyi.device.domain.api.IAircraftDomain; import com.ruoyi.device.domain.api.IDeviceDomain; +import com.ruoyi.device.domain.impl.machine.state.DroneState; +import com.ruoyi.device.domain.impl.machine.state.AirportState; +import com.ruoyi.device.domain.impl.machine.state.MachineStates; +import com.ruoyi.device.domain.impl.machine.statemachine.MachineStateManager; import com.ruoyi.device.domain.impl.tuohengmqtt.callback.ITuohengEventsCallback; import com.ruoyi.device.domain.impl.tuohengmqtt.callback.ITuohengOsdCallback; import com.ruoyi.device.domain.impl.tuohengmqtt.callback.ITuohengRealTimeDataCallback; @@ -23,6 +27,7 @@ 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.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.util.HashMap; @@ -51,8 +56,21 @@ public class TuohengService { @Autowired private IDeviceDomain deviceDomain; + @Autowired + private MachineStateManager stateManager; + private final ObjectMapper objectMapper = new ObjectMapper(); + /** + * 机场心跳时间戳记录 (deviceSn -> lastHeartbeatTime) + */ + private final Map airportHeartbeatMap = new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * 心跳超时时间:5分钟 + */ + private static final long HEARTBEAT_TIMEOUT = 5 * 60 * 1000; + @EventListener(ApplicationReadyEvent.class) public void onApplicationReady() { TuohengMqttClientConfig config = TuohengMqttClientConfig.builder() @@ -82,8 +100,15 @@ public class TuohengService { log.info("设备SN: {}", deviceSn); try { log.info("数据内容: {}", objectMapper.writeValueAsString(data)); + + // 更新机场心跳时间戳 + updateAirportHeartbeat(deviceSn); + + // 同步无人机开关机状态 + syncDronePowerState(deviceSn, data); + } catch (Exception e) { - log.error("序列化数据失败", e); + log.error("处理实时数据失败", e); } log.info("====================================="); } @@ -96,6 +121,10 @@ public class TuohengService { log.info("设备SN: {}", deviceSn); try { log.info("数据内容: {}", objectMapper.writeValueAsString(data)); + + // 同步飞行状态到 MachineStateManager + syncFlightState(deviceSn, data); + } catch (Exception e) { log.error("序列化数据失败", e); } @@ -157,4 +186,129 @@ public class TuohengService { return mapping; } + + + /** + * 同步飞行状态到 MachineStateManager + * 根据 OSD 数据中的 flighttask_step_code 和 mode_code 判断飞行状态 + */ + private void syncFlightState(String deviceSn, AirportOsdData data) { + try { + if (data == null) { + return; + } + + String flighttaskStepCode = data.getFlighttaskStepCode(); + String modeCode = data.getModeCode(); + + // 同步无人机状态 + DroneState droneState = determineDroneState(flighttaskStepCode, modeCode); + if (droneState != null) { + stateManager.setDroneState(deviceSn, droneState); + log.debug("同步飞行状态: sn={}, flighttaskStepCode={}, modeCode={}, state={}", + deviceSn, flighttaskStepCode, modeCode, droneState); + } + + // 注意:机场在线状态由 IOT 平台的心跳机制判断(5分钟超时) + // 不在这里简单地根据收到数据就判断为在线 + + } catch (Exception e) { + log.error("同步飞行状态失败: sn={}", deviceSn, e); + } + } + + /** + * 根据任务状态码和模式码判断无人机状态 + */ + private DroneState determineDroneState(String flighttaskStepCode, String modeCode) { + // 优先根据 flighttask_step_code 判断 + if (flighttaskStepCode != null) { + switch (flighttaskStepCode) { + case "1": + // 飞行作业中 + return DroneState.FLYING; + case "2": + // 作业后状态恢复,可能是返航或已到达 + return DroneState.RETURNING; + case "5": + // 任务空闲 + return DroneState.ONLINE; + case "255": + // 飞行器异常 + return DroneState.UNKNOWN; + } + } + + // 根据 mode_code 辅助判断 + if (modeCode != null) { + if (modeCode.equals("3") || modeCode.equals("4") || modeCode.equals("5")) { + // 飞行中状态 + return DroneState.FLYING; + } + } + + return null; + } + + /** + * 更新机场心跳时间戳并设置在线状态 + */ + private void updateAirportHeartbeat(String deviceSn) { + long currentTime = System.currentTimeMillis(); + airportHeartbeatMap.put(deviceSn, currentTime); + + // 收到心跳,设置机场为在线 + stateManager.setAirportState(deviceSn, AirportState.ONLINE); + log.debug("更新机场心跳: sn={}, time={}", deviceSn, currentTime); + } + + /** + * 同步无人机开关机状态 + * 注意:只有关机时才更新状态,其他情况保持当前状态不变 + */ + private void syncDronePowerState(String deviceSn, TuohengRealTimeData data) { + try { + if (data == null || data.getDroneBattery() == null || data.getDroneBattery().getData() == null) { + return; + } + + Integer powerOn = data.getDroneBattery().getData().getBPowerON(); + if (powerOn == null) { + return; + } + + // 只有关机时才更新状态为 POWER_OFF + // 其他情况(开机、飞行中等)保持当前状态不变 + if (powerOn == 2) { + stateManager.setDroneState(deviceSn, DroneState.POWER_OFF); + log.debug("同步无人机关机状态: sn={}, powerOn={}", deviceSn, powerOn); + } + + } catch (Exception e) { + log.error("同步无人机开关机状态失败: sn={}", deviceSn, e); + } + } + + /** + * 定时检查机场心跳超时 + * 每分钟执行一次 + */ + @Scheduled(fixedRate = 60000) + public void checkAirportHeartbeatTimeout() { + long currentTime = System.currentTimeMillis(); + + for (Map.Entry entry : airportHeartbeatMap.entrySet()) { + String deviceSn = entry.getKey(); + Long lastHeartbeatTime = entry.getValue(); + + long timeDiff = currentTime - lastHeartbeatTime; + + if (timeDiff > HEARTBEAT_TIMEOUT) { + // 超时,设置为离线 + stateManager.setAirportState(deviceSn, AirportState.OFFLINE); + log.warn("机场心跳超时,设置为离线: sn={}, 超时时长={}秒", + deviceSn, timeDiff / 1000); + } + } + } }