添加拓恒机场和无人机状态

This commit is contained in:
孙小云 2026-02-10 15:07:15 +08:00
parent 7a7af0a496
commit 20f11d581b
9 changed files with 423 additions and 8 deletions

View File

@ -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<String> powerOn(
@Parameter(description = "机场SN号", required = true, example = "THJSQ03B2309DN7VQN43")
@PathVariable("sn") String sn)
{
log.info("收到无人机开机请求: sn={}", sn);
try {
// 调用机器命令管理器执行开机命令
CompletableFuture<CommandResult> 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());
}
}
}

View File

@ -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());
};
}

View File

@ -4,12 +4,17 @@ package com.ruoyi.device.domain.impl.machine.state;
*/
public enum AirportState {
/**
* 未知状态服务器重启后的初始状态等待第一次心跳同步,同时也是离线状态
* 未知状态服务器重启后的初始状态等待第一次心跳同步
*/
UNKNOWN,
/**
* 在线
*/
ONLINE
ONLINE,
/**
* 离线心跳超时
*/
OFFLINE
}

View File

@ -6,12 +6,17 @@ package com.ruoyi.device.domain.impl.machine.state;
*/
public enum DroneState {
/**
* 未知状态服务器重启后的初始状态等待第一次心跳同步,同时也是离线状态
* 未知状态服务器重启后的初始状态等待第一次心跳同步
*/
UNKNOWN,
/**
* 在线
* 关机状态
*/
POWER_OFF,
/**
* 在线已开机
*/
ONLINE,

View File

@ -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";
}
}

View File

@ -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<CommandType, Transaction> 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<CommandType> getAvailableCommands(MachineStates currentStates) {
List<CommandType> 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());
}
}

View File

@ -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秒
}
}

View File

@ -12,7 +12,6 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
* @author ruoyi
*/
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class AirportOsdData {
@JsonProperty("working_current")

View File

@ -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<String, Long> 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<String, Long> 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);
}
}
}
}