parent
0a8313d30e
commit
18c6de38ca
|
|
@ -23,15 +23,15 @@ public class ServicesReplyHandler implements MessageHandler {
|
||||||
log.debug("services reply --> topic: {}, payload: {}", topic, payload);
|
log.debug("services reply --> topic: {}, payload: {}", topic, payload);
|
||||||
JSONObject message = JsonUtils.parseObject(payload, JSONObject.class);
|
JSONObject message = JsonUtils.parseObject(payload, JSONObject.class);
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
|
// 回复仅 bid 与请求一致,tid 为设备新生成的值,不可参与关联匹配,故只按 bid 关联
|
||||||
String bid = message.getStr("bid");
|
String bid = message.getStr("bid");
|
||||||
String tid = message.getStr("tid");
|
if (StrUtil.isNotBlank(bid)) {
|
||||||
if (StrUtil.isNotBlank(bid) && StrUtil.isNotBlank(tid)) {
|
|
||||||
JSONObject data = message.getJSONObject(BusinessConstant.DATA);
|
JSONObject data = message.getJSONObject(BusinessConstant.DATA);
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
data = new JSONObject();
|
data = new JSONObject();
|
||||||
data.set("result", 0);
|
data.set("result", 0);
|
||||||
}
|
}
|
||||||
CacheUtils.set(bid + "_" + tid, data);
|
CacheUtils.set(bid, data);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.debug("services reply --> payload解析失败,解析后为null");
|
log.debug("services reply --> payload解析失败,解析后为null");
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,20 @@ package com.multictrl.modules.business.q20.controller;
|
||||||
|
|
||||||
import com.multictrl.common.annotation.ApiOrder;
|
import com.multictrl.common.annotation.ApiOrder;
|
||||||
import com.multictrl.common.annotation.LogOperation;
|
import com.multictrl.common.annotation.LogOperation;
|
||||||
|
import com.multictrl.common.page.PageData;
|
||||||
import com.multictrl.common.utils.Result;
|
import com.multictrl.common.utils.Result;
|
||||||
|
import com.multictrl.modules.business.dto.RouteDTO;
|
||||||
import com.multictrl.modules.business.q20.dto.*;
|
import com.multictrl.modules.business.q20.dto.*;
|
||||||
import com.multictrl.modules.business.q20.service.Q20RouteService;
|
import com.multictrl.modules.business.q20.service.Q20RouteService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Q20航线任务接口
|
* Q20航线任务接口
|
||||||
* topic: thing/device/{device_sn}/services (下行)
|
* topic: thing/device/{device_sn}/services (下行)
|
||||||
|
|
@ -28,6 +33,15 @@ public class Q20RouteController {
|
||||||
|
|
||||||
private final Q20RouteService q20RouteService;
|
private final Q20RouteService q20RouteService;
|
||||||
|
|
||||||
|
// ==================== 航线库分页查询 ====================
|
||||||
|
|
||||||
|
@GetMapping("/page")
|
||||||
|
@Operation(summary = "分页查询本地Q20航线(含航点),支持按航线名称/航线ID查询")
|
||||||
|
@RequiresPermissions("bus:q20:route")
|
||||||
|
public Result<PageData<RouteDTO>> page(@Parameter(hidden = true) @RequestParam Map<String, Object> params) {
|
||||||
|
return new Result<PageData<RouteDTO>>().ok(q20RouteService.pageQ20Routes(params));
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 下一个航线ID ====================
|
// ==================== 下一个航线ID ====================
|
||||||
|
|
||||||
@GetMapping("/nextWayline")
|
@GetMapping("/nextWayline")
|
||||||
|
|
@ -82,14 +96,14 @@ public class Q20RouteController {
|
||||||
|
|
||||||
// ==================== 获取航线信息(暂时停用,先注释) ====================
|
// ==================== 获取航线信息(暂时停用,先注释) ====================
|
||||||
|
|
||||||
// @GetMapping("/info/{deviceSn}")
|
@GetMapping("/info/{deviceSn}")
|
||||||
// @Operation(summary = "获取航线信息(route_info)")
|
@Operation(summary = "获取航线信息(route_info)")
|
||||||
// @RequiresPermissions("bus:q20:route")
|
@RequiresPermissions("bus:q20:route")
|
||||||
// public Result<Object> routeInfo(@PathVariable String deviceSn,
|
public Result<Object> routeInfo(@PathVariable String deviceSn,
|
||||||
// @RequestParam int mode,
|
@RequestParam int mode,
|
||||||
// @RequestParam(required = false) String value) {
|
@RequestParam(required = false) String value) {
|
||||||
// return new Result<>().ok(q20RouteService.routeInfo(deviceSn, mode, value));
|
return new Result<>().ok(q20RouteService.routeInfo(deviceSn, mode, value));
|
||||||
// }
|
}
|
||||||
|
|
||||||
// ==================== 获取执行进度(暂时停用,先注释) ====================
|
// ==================== 获取执行进度(暂时停用,先注释) ====================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.multictrl.modules.business.q20.dto;
|
package com.multictrl.modules.business.q20.dto;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
@ -18,7 +17,7 @@ import static com.fasterxml.jackson.annotation.JsonProperty.Access.WRITE_ONLY;
|
||||||
public class Q20RouteUploadDTO {
|
public class Q20RouteUploadDTO {
|
||||||
|
|
||||||
@Schema(description = "航线名称(仅本地存储用,不下发给设备)")
|
@Schema(description = "航线名称(仅本地存储用,不下发给设备)")
|
||||||
@JsonIgnore
|
@JsonProperty(value = "routeName", access = WRITE_ONLY)
|
||||||
private String routeName;
|
private String routeName;
|
||||||
|
|
||||||
@Schema(description = "全局飞行高度(m),仅本地存储用,不下发给设备")
|
@Schema(description = "全局飞行高度(m),仅本地存储用,不下发给设备")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Q20电池数据处理(method: battery)
|
* Q20电池数据处理(method: battery)
|
||||||
* 上报智能电池详细状态:id, full_charge_capacity, charge_remaining, voltage, current,
|
* 上报智能电池详细状态:id, full_charge_capacity, charge_remaining, voltage, current,
|
||||||
|
|
@ -34,20 +37,32 @@ public class Q20BatteryHandler implements MessageHandler {
|
||||||
String deviceSn = message.getStr(BusinessConstant.GATEWAY);
|
String deviceSn = message.getStr(BusinessConstant.GATEWAY);
|
||||||
JSONObject data = message.getJSONObject(BusinessConstant.DATA);
|
JSONObject data = message.getJSONObject(BusinessConstant.DATA);
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
// 以电池id区分多块电池,缓存key加上电池id后缀
|
// 以电池id区分多块电池,按id聚合缓存到同一key下,便于在线查询时一次取出全部电池
|
||||||
Integer batteryId = data.getInt("id");
|
Integer batteryId = data.getInt("id");
|
||||||
String cacheKey = Q20Constant.Q20_BATTERY + deviceSn
|
cacheBattery(deviceSn, batteryId, data);
|
||||||
+ (batteryId != null ? "_" + batteryId : "");
|
|
||||||
CacheUtils.set(cacheKey, data);
|
|
||||||
saveToInflux(deviceSn, batteryId, data);
|
saveToInflux(deviceSn, batteryId, data);
|
||||||
log.debug("Q20 battery --> deviceSn: {}, id: {}, charge_remaining: {}%, status: {}",
|
// log.debug("Q20 battery --> deviceSn: {}, id: {}, charge_remaining: {}%, status: {}",
|
||||||
deviceSn, batteryId, data.getFloat("charge_remaining"), data.getInt("status"));
|
// deviceSn, batteryId, data.getFloat("charge_remaining"), data.getInt("status"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.debug("Q20 battery --> payload解析失败,解析后为null");
|
log.debug("Q20 battery --> payload解析失败,解析后为null");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将单块电池按id聚合到 {@code q20_battery_{sn}} 缓存项下(LinkedHashMap:id -> 电池数据)
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private void cacheBattery(String deviceSn, Integer batteryId, JSONObject data) {
|
||||||
|
String cacheKey = Q20Constant.Q20_BATTERY + deviceSn;
|
||||||
|
Object cached = CacheUtils.get(cacheKey);
|
||||||
|
Map<Integer, JSONObject> batteries = cached instanceof Map
|
||||||
|
? (Map<Integer, JSONObject>) cached
|
||||||
|
: new LinkedHashMap<>();
|
||||||
|
batteries.put(batteryId, data);
|
||||||
|
CacheUtils.set(cacheKey, batteries);
|
||||||
|
}
|
||||||
|
|
||||||
private void saveToInflux(String deviceSn, Integer batteryId, JSONObject data) {
|
private void saveToInflux(String deviceSn, Integer batteryId, JSONObject data) {
|
||||||
Q20BatteryReport report = Q20BatteryReport.builder()
|
Q20BatteryReport report = Q20BatteryReport.builder()
|
||||||
.deviceSn(deviceSn)
|
.deviceSn(deviceSn)
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ public class Q20HmsHandler implements MessageHandler {
|
||||||
data.set("result", 0);
|
data.set("result", 0);
|
||||||
message.remove(BusinessConstant.NEED_REPLY);
|
message.remove(BusinessConstant.NEED_REPLY);
|
||||||
mqttPushService.pushMessageByClient2(topic + BusinessConstant._REPLY, message.toString());
|
mqttPushService.pushMessageByClient2(topic + BusinessConstant._REPLY, message.toString());
|
||||||
log.debug("Q20 hms --> 已回复, deviceSn: {}", deviceSn);
|
// log.debug("Q20 hms --> 已回复, deviceSn: {}", deviceSn);
|
||||||
}
|
}
|
||||||
// 重新解析,避免回复时修改了data对象
|
// 重新解析,避免回复时修改了data对象
|
||||||
message = JsonUtils.parseObject(payload, JSONObject.class);
|
message = JsonUtils.parseObject(payload, JSONObject.class);
|
||||||
|
|
@ -49,9 +49,9 @@ public class Q20HmsHandler implements MessageHandler {
|
||||||
data = message.getJSONObject(BusinessConstant.DATA);
|
data = message.getJSONObject(BusinessConstant.DATA);
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
CacheUtils.set(Q20Constant.Q20_HMS + deviceSn, data);
|
CacheUtils.set(Q20Constant.Q20_HMS + deviceSn, data);
|
||||||
log.debug("Q20 hms --> deviceSn: {}, gnss: {}, ins: {}, battery: {}",
|
// log.debug("Q20 hms --> deviceSn: {}, gnss: {}, ins: {}, battery: {}",
|
||||||
deviceSn, data.getJSONArray("gnss"), data.getJSONArray("ins"),
|
// deviceSn, data.getJSONArray("gnss"), data.getJSONArray("ins"),
|
||||||
data.getJSONArray("battery"));
|
// data.getJSONArray("battery"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.debug("Q20 hms --> payload解析失败,解析后为null");
|
log.debug("Q20 hms --> payload解析失败,解析后为null");
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ public class Q20MountHandler implements MessageHandler {
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
CacheUtils.set(Q20Constant.Q20_MOUNT + deviceSn, data);
|
CacheUtils.set(Q20Constant.Q20_MOUNT + deviceSn, data);
|
||||||
saveToInflux(deviceSn, data);
|
saveToInflux(deviceSn, data);
|
||||||
log.debug("Q20 mount --> deviceSn: {}, mount_type: {}, mount_port: {}",
|
// log.debug("Q20 mount --> deviceSn: {}, mount_type: {}, mount_port: {}",
|
||||||
deviceSn, data.getStr("mount_type"), data.getInt("mount_port"));
|
// deviceSn, data.getStr("mount_type"), data.getInt("mount_port"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.debug("Q20 mount --> payload解析失败,解析后为null");
|
log.debug("Q20 mount --> payload解析失败,解析后为null");
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,8 @@ public class Q20ObsHandler implements MessageHandler {
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
CacheUtils.set(Q20Constant.Q20_OBS + deviceSn, data);
|
CacheUtils.set(Q20Constant.Q20_OBS + deviceSn, data);
|
||||||
saveToInflux(deviceSn, data);
|
saveToInflux(deviceSn, data);
|
||||||
log.debug("Q20 obs --> deviceSn: {}, cp_distance: {}m, cp_enable: {}",
|
// log.debug("Q20 obs --> deviceSn: {}, cp_distance: {}m, cp_enable: {}",
|
||||||
deviceSn, data.getFloat("cp_distance"), data.getInt("cp_enable"));
|
// deviceSn, data.getFloat("cp_distance"), data.getInt("cp_enable"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.debug("Q20 obs --> payload解析失败,解析后为null");
|
log.debug("Q20 obs --> payload解析失败,解析后为null");
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,8 @@ public class Q20RcHandler implements MessageHandler {
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
CacheUtils.set(Q20Constant.Q20_RC + deviceSn, data);
|
CacheUtils.set(Q20Constant.Q20_RC + deviceSn, data);
|
||||||
saveToInflux(deviceSn, data);
|
saveToInflux(deviceSn, data);
|
||||||
log.debug("Q20 rc --> deviceSn: {}, priority: {}, rc_state: {}",
|
// log.debug("Q20 rc --> deviceSn: {}, priority: {}, rc_state: {}",
|
||||||
deviceSn, data.getInt("priority"), data.getStr("rc_state"));
|
// deviceSn, data.getInt("priority"), data.getStr("rc_state"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.debug("Q20 rc --> payload解析失败,解析后为null");
|
log.debug("Q20 rc --> payload解析失败,解析后为null");
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ public class Q20RtkHandler implements MessageHandler {
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
CacheUtils.set(Q20Constant.Q20_RTK + deviceSn, data);
|
CacheUtils.set(Q20Constant.Q20_RTK + deviceSn, data);
|
||||||
saveToInflux(deviceSn, data);
|
saveToInflux(deviceSn, data);
|
||||||
log.debug("Q20 rtk --> deviceSn: {}, fix_type: {}, satellites: {}",
|
// log.debug("Q20 rtk --> deviceSn: {}, fix_type: {}, satellites: {}",
|
||||||
deviceSn, data.getInt("fix_type"), data.getInt("satellite_visible"));
|
// deviceSn, data.getInt("fix_type"), data.getInt("satellite_visible"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.debug("Q20 rtk --> payload解析失败,解析后为null");
|
log.debug("Q20 rtk --> payload解析失败,解析后为null");
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ public class Q20StatusHandler implements MessageHandler {
|
||||||
*/
|
*/
|
||||||
private void handleHeartbeat(String topic, JSONObject message, String deviceSn) {
|
private void handleHeartbeat(String topic, JSONObject message, String deviceSn) {
|
||||||
CacheUtils.set(Q20Constant.Q20_ONLINE + deviceSn, true, Q20Constant.ONLINE_TTL_MS);
|
CacheUtils.set(Q20Constant.Q20_ONLINE + deviceSn, true, Q20Constant.ONLINE_TTL_MS);
|
||||||
log.debug("Q20 status --> 心跳, deviceSn: {}", deviceSn);
|
// log.debug("Q20 status --> 心跳, deviceSn: {}", deviceSn);
|
||||||
Integer needReply = message.getInt(BusinessConstant.NEED_REPLY);
|
Integer needReply = message.getInt(BusinessConstant.NEED_REPLY);
|
||||||
if (needReply != null && needReply == 1) {
|
if (needReply != null && needReply == 1) {
|
||||||
long uplink = 0;
|
long uplink = 0;
|
||||||
|
|
@ -92,6 +92,6 @@ public class Q20StatusHandler implements MessageHandler {
|
||||||
message.remove(BusinessConstant.NEED_REPLY);
|
message.remove(BusinessConstant.NEED_REPLY);
|
||||||
String replyTopic = topic + Q20Constant.STATUS_REPLY_SUFFIX;
|
String replyTopic = topic + Q20Constant.STATUS_REPLY_SUFFIX;
|
||||||
mqttPushService.pushMessageByClient1(replyTopic, message.toString());
|
mqttPushService.pushMessageByClient1(replyTopic, message.toString());
|
||||||
log.debug("Q20 status --> 已回复, topic: {}", replyTopic);
|
// log.debug("Q20 status --> 已回复, topic: {}", replyTopic);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
package com.multictrl.modules.business.q20.service;
|
package com.multictrl.modules.business.q20.service;
|
||||||
|
|
||||||
import cn.hutool.json.JSONObject;
|
import cn.hutool.json.JSONObject;
|
||||||
|
import com.multictrl.common.page.PageData;
|
||||||
|
import com.multictrl.modules.business.dto.RouteDTO;
|
||||||
import com.multictrl.modules.business.q20.dto.*;
|
import com.multictrl.modules.business.q20.dto.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Q20航线任务服务接口
|
* Q20航线任务服务接口
|
||||||
* topic: thing/device/{device_sn}/services (下行)
|
* topic: thing/device/{device_sn}/services (下行)
|
||||||
|
|
@ -13,6 +17,15 @@ import com.multictrl.modules.business.q20.dto.*;
|
||||||
*/
|
*/
|
||||||
public interface Q20RouteService {
|
public interface Q20RouteService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询本地已保存的Q20航线(bus_route,关联bus_route_waypoint航点)
|
||||||
|
* 支持按航线名称(routeName)、航线ID(q20RouteId)模糊查询
|
||||||
|
*
|
||||||
|
* @param params 查询参数:page、limit、routeName(可选)、q20RouteId(可选)
|
||||||
|
* @return 分页结果,每条含完整航点列表
|
||||||
|
*/
|
||||||
|
PageData<RouteDTO> pageQ20Routes(Map<String, Object> params);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取下一个航线ID(根据库中最新Q20航线ID自动递增,无记录则返回默认ID)
|
* 获取下一个航线ID(根据库中最新Q20航线ID自动递增,无记录则返回默认ID)
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.CollectionUtils;
|
import org.springframework.util.CollectionUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,6 +32,14 @@ public class Q20DeviceServiceImpl implements Q20DeviceService {
|
||||||
vo.setOnline(online);
|
vo.setOnline(online);
|
||||||
if (online) {
|
if (online) {
|
||||||
vo.setOsd(CacheUtils.get(Q20Constant.Q20_OSD + deviceSn));
|
vo.setOsd(CacheUtils.get(Q20Constant.Q20_OSD + deviceSn));
|
||||||
|
vo.setRtk(CacheUtils.get(Q20Constant.Q20_RTK + deviceSn));
|
||||||
|
vo.setRc(CacheUtils.get(Q20Constant.Q20_RC + deviceSn));
|
||||||
|
vo.setObs(CacheUtils.get(Q20Constant.Q20_OBS + deviceSn));
|
||||||
|
vo.setMount(CacheUtils.get(Q20Constant.Q20_MOUNT + deviceSn));
|
||||||
|
Object batteries = CacheUtils.get(Q20Constant.Q20_BATTERY + deviceSn);
|
||||||
|
if (batteries instanceof Map) {
|
||||||
|
vo.setBatteries(new ArrayList<>(((Map<?, ?>) batteries).values()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,18 @@ import cn.hutool.core.util.StrUtil;
|
||||||
import cn.hutool.json.JSONObject;
|
import cn.hutool.json.JSONObject;
|
||||||
import cn.hutool.json.JSONUtil;
|
import cn.hutool.json.JSONUtil;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.multictrl.common.exception.RenException;
|
import com.multictrl.common.exception.RenException;
|
||||||
|
import com.multictrl.common.page.PageData;
|
||||||
|
import com.multictrl.common.utils.ConvertUtils;
|
||||||
import com.multictrl.modules.business.dao.RouteDao;
|
import com.multictrl.modules.business.dao.RouteDao;
|
||||||
import com.multictrl.modules.business.dao.RouteWaypointDao;
|
import com.multictrl.modules.business.dao.RouteWaypointDao;
|
||||||
import com.multictrl.modules.business.dao.WaypointActionDao;
|
import com.multictrl.modules.business.dao.WaypointActionDao;
|
||||||
|
import com.multictrl.modules.business.dto.RouteDTO;
|
||||||
|
import com.multictrl.modules.business.dto.RouteWaypointDTO;
|
||||||
|
import com.multictrl.modules.business.dto.WaypointActionDTO;
|
||||||
import com.multictrl.modules.business.entity.RouteEntity;
|
import com.multictrl.modules.business.entity.RouteEntity;
|
||||||
import com.multictrl.modules.business.entity.RouteWaypointEntity;
|
import com.multictrl.modules.business.entity.RouteWaypointEntity;
|
||||||
import com.multictrl.modules.business.entity.WaypointActionEntity;
|
import com.multictrl.modules.business.entity.WaypointActionEntity;
|
||||||
|
|
@ -25,7 +32,9 @@ import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
|
@ -113,6 +122,57 @@ public class Q20RouteServiceImpl implements Q20RouteService {
|
||||||
// 接口实现
|
// 接口实现
|
||||||
// ────────────────────────────────────────────────
|
// ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PageData<RouteDTO> pageQ20Routes(Map<String, Object> params) {
|
||||||
|
long curPage = parseLong(params.get("page"), 1);
|
||||||
|
long limit = parseLong(params.get("limit"), 10);
|
||||||
|
String routeName = (String) params.get("routeName");
|
||||||
|
String q20RouteId = (String) params.get("q20RouteId");
|
||||||
|
|
||||||
|
QueryWrapper<RouteEntity> wrapper = new QueryWrapper<>();
|
||||||
|
wrapper.eq("template_type", TEMPLATE_TYPE_Q20);
|
||||||
|
wrapper.like(StrUtil.isNotBlank(routeName), "route_name", routeName);
|
||||||
|
wrapper.like(StrUtil.isNotBlank(q20RouteId), "q20_route_id", q20RouteId);
|
||||||
|
wrapper.orderByDesc("update_date", "create_date");
|
||||||
|
|
||||||
|
IPage<RouteEntity> result = routeDao.selectPage(new Page<>(curPage, limit), wrapper);
|
||||||
|
|
||||||
|
List<RouteDTO> list = new ArrayList<>();
|
||||||
|
for (RouteEntity entity : result.getRecords()) {
|
||||||
|
RouteDTO dto = ConvertUtils.sourceToTarget(entity, RouteDTO.class);
|
||||||
|
// 关联航点(bus_route_waypoint),按航点顺序升序
|
||||||
|
List<RouteWaypointEntity> waypoints = routeWaypointDao.selectList(
|
||||||
|
new QueryWrapper<RouteWaypointEntity>()
|
||||||
|
.eq("route_id", entity.getId())
|
||||||
|
.orderByAsc("waypoint_sort"));
|
||||||
|
List<RouteWaypointDTO> waypointDtos = ConvertUtils.sourceToTarget(waypoints, RouteWaypointDTO.class);
|
||||||
|
// 关联每个航点的动作(bus_waypoint_action),按动作顺序升序
|
||||||
|
for (RouteWaypointDTO wp : waypointDtos) {
|
||||||
|
List<WaypointActionEntity> actions = waypointActionDao.selectList(
|
||||||
|
new QueryWrapper<WaypointActionEntity>()
|
||||||
|
.eq("route_id", entity.getId())
|
||||||
|
.eq("waypoint_id", wp.getId())
|
||||||
|
.orderByAsc("action_sort"));
|
||||||
|
wp.setWaypointActionList(ConvertUtils.sourceToTarget(actions, WaypointActionDTO.class));
|
||||||
|
}
|
||||||
|
dto.setRouteWaypointList(waypointDtos);
|
||||||
|
list.add(dto);
|
||||||
|
}
|
||||||
|
return new PageData<>(list, result.getTotal());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析分页参数(字符串/数字均兼容),无效时返回默认值 */
|
||||||
|
private long parseLong(Object val, long defaultVal) {
|
||||||
|
if (val == null) {
|
||||||
|
return defaultVal;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Long.parseLong(val.toString());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return defaultVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String nextWayline(String type) {
|
public String nextWayline(String type) {
|
||||||
// 按类型区分前缀:一键航线用 q20_auto_,上传航线用 q20_route_
|
// 按类型区分前缀:一键航线用 q20_auto_,上传航线用 q20_route_
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,21 @@ public class Q20DeviceStatusVO {
|
||||||
@Schema(description = "是否在线(35s内有心跳则为true)")
|
@Schema(description = "是否在线(35s内有心跳则为true)")
|
||||||
private boolean online;
|
private boolean online;
|
||||||
|
|
||||||
@Schema(description = "最新OSD遥测数据,仅在线时有值")
|
@Schema(description = "最新OSD基础遥测数据(method=osd),仅在线时有值")
|
||||||
private Object osd;
|
private Object osd;
|
||||||
|
|
||||||
|
@Schema(description = "最新RTK定位数据(method=rtk),仅在线时有值")
|
||||||
|
private Object rtk;
|
||||||
|
|
||||||
|
@Schema(description = "最新遥控链路数据(method=rc),仅在线时有值")
|
||||||
|
private Object rc;
|
||||||
|
|
||||||
|
@Schema(description = "最新避障数据(method=obs),仅在线时有值")
|
||||||
|
private Object obs;
|
||||||
|
|
||||||
|
@Schema(description = "最新负载数据(method=mount),仅在线时有值")
|
||||||
|
private Object mount;
|
||||||
|
|
||||||
|
@Schema(description = "最新电池数据列表(method=battery,按电池id聚合),仅在线时有值")
|
||||||
|
private Object batteries;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ public class DJIBaseServiceImpl implements DJIBaseService {
|
||||||
mqttPushService.pushMessageByClient1(topic, payload.toString());
|
mqttPushService.pushMessageByClient1(topic, payload.toString());
|
||||||
String bid = payload.getStr("bid");
|
String bid = payload.getStr("bid");
|
||||||
String tid = payload.getStr("tid");
|
String tid = payload.getStr("tid");
|
||||||
String cacheKey = bid + "_" + tid;
|
// 设备回复只回传相同的 bid(tid 会重新生成),因此以 bid 作为关联回复的缓存键
|
||||||
|
String cacheKey = bid;
|
||||||
|
|
||||||
// 记录当前等待回复的命令,供前端模拟回复使用
|
// 记录当前等待回复的命令,供前端模拟回复使用
|
||||||
String deviceSn = extractDeviceSn(topic);
|
String deviceSn = extractDeviceSn(topic);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
<script src="https://webapi.amap.com/loader.js"></script>
|
<script src="https://webapi.amap.com/loader.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/mqtt@5.10.1/dist/mqtt.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bs-body-bg: #0d1117;
|
--bs-body-bg: #0d1117;
|
||||||
|
|
@ -97,12 +98,29 @@ body { background: var(--bs-body-bg); color: var(--bs-body-color); font-size: 13
|
||||||
h6.cmd-title { font-size: 12px; color: #e6edf3; font-weight: 600; margin-bottom: 8px; }
|
h6.cmd-title { font-size: 12px; color: #e6edf3; font-weight: 600; margin-bottom: 8px; }
|
||||||
@keyframes cfmIn { from { opacity:0; transform:scale(.93) translateY(-8px); } to { opacity:1; transform:scale(1) translateY(0); } }
|
@keyframes cfmIn { from { opacity:0; transform:scale(.93) translateY(-8px); } to { opacity:1; transform:scale(1) translateY(0); } }
|
||||||
@keyframes toastIn { from { opacity:0; transform:translateX(24px); } to { opacity:1; transform:translateX(0); } }
|
@keyframes toastIn { from { opacity:0; transform:translateX(24px); } to { opacity:1; transform:translateX(0); } }
|
||||||
|
/* 实时遥测 键值网格 */
|
||||||
|
.kv-grid { display:grid; grid-template-columns:max-content 1fr; gap:5px 16px; font-size:12px; align-items:center; }
|
||||||
|
.kv-grid .k { color:var(--muted); white-space:nowrap; }
|
||||||
|
.kv-grid .v { color:#c9d1d9; font-family:monospace; word-break:break-all; }
|
||||||
|
.kv-empty { color:var(--muted); font-size:12px; padding:8px 0; }
|
||||||
|
.st-badge { display:inline-block; padding:1px 8px; border-radius:4px; font-size:11px; font-weight:600; line-height:1.6; }
|
||||||
|
.st-on { background:rgba(63,185,80,.15); color:var(--success); border:1px solid rgba(63,185,80,.35); }
|
||||||
|
.st-off { background:rgba(139,148,158,.12); color:var(--muted); border:1px solid rgba(139,148,158,.3); }
|
||||||
|
.st-warn { background:rgba(248,81,73,.13); color:var(--danger); border:1px solid rgba(248,81,73,.4); }
|
||||||
|
/* 顶部实时遥测 HUD */
|
||||||
|
.hud { background:#0d1117; border-bottom:1px solid var(--border-color); padding:6px 14px; display:flex; flex-wrap:wrap; gap:5px 13px; align-items:center; }
|
||||||
|
.hud-stat { display:flex; flex-direction:column; line-height:1.18; min-width:58px; }
|
||||||
|
.hud-stat .hl { font-size:10px; color:var(--muted); white-space:nowrap; }
|
||||||
|
.hud-stat .hv { font-size:14px; font-family:monospace; color:#e6edf3; font-weight:600; white-space:nowrap; }
|
||||||
|
.hud-sep { align-self:stretch; width:1px; background:var(--border-color); margin:2px 0; }
|
||||||
|
.hud-tag { font-size:10px; font-weight:700; letter-spacing:.5px; color:var(--accent); align-self:center; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<div style="position:sticky;top:0;z-index:200">
|
||||||
<!-- 顶栏 -->
|
<!-- 顶栏 -->
|
||||||
<div class="topbar d-flex align-items-center gap-3 flex-wrap">
|
<div class="topbar d-flex align-items-center gap-3 flex-wrap" style="position:static">
|
||||||
<span style="color:var(--accent);font-weight:700;font-size:14px">
|
<span style="color:var(--accent);font-weight:700;font-size:14px">
|
||||||
<i class="bi bi-send-fill me-1"></i>Q20 Control
|
<i class="bi bi-send-fill me-1"></i>Q20 Control
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -120,6 +138,35 @@ h6.cmd-title { font-size: 12px; color: #e6edf3; font-weight: 600; margin-bottom:
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 顶部实时遥测 HUD(MQTT 实时刷新) -->
|
||||||
|
<div class="hud" id="liveHud">
|
||||||
|
<span class="hud-tag">实时</span>
|
||||||
|
<div class="hud-stat"><span class="hl">飞行状态</span><span class="hv" id="hud_flying">—</span></div>
|
||||||
|
<div class="hud-sep"></div>
|
||||||
|
<div class="hud-stat"><span class="hl">经度</span><span class="hv" id="hud_lng">—</span></div>
|
||||||
|
<div class="hud-stat"><span class="hl">纬度</span><span class="hv" id="hud_lat">—</span></div>
|
||||||
|
<div class="hud-stat"><span class="hl">相对高(m)</span><span class="hv" id="hud_height">—</span></div>
|
||||||
|
<div class="hud-stat"><span class="hl">海拔(m)</span><span class="hv" id="hud_alt">—</span></div>
|
||||||
|
<div class="hud-sep"></div>
|
||||||
|
<div class="hud-stat"><span class="hl">水平速(m/s)</span><span class="hv" id="hud_gs">—</span></div>
|
||||||
|
<div class="hud-stat"><span class="hl">垂直速(m/s)</span><span class="hv" id="hud_vs">—</span></div>
|
||||||
|
<div class="hud-stat"><span class="hl">航向(°)</span><span class="hv" id="hud_heading">—</span></div>
|
||||||
|
<div class="hud-stat"><span class="hl">俯仰(°)</span><span class="hv" id="hud_pitch">—</span></div>
|
||||||
|
<div class="hud-stat"><span class="hl">横滚(°)</span><span class="hv" id="hud_roll">—</span></div>
|
||||||
|
<div class="hud-sep"></div>
|
||||||
|
<div class="hud-stat"><span class="hl">避障</span><span class="hv" id="hud_obs">—</span></div>
|
||||||
|
<div class="hud-sep"></div>
|
||||||
|
<div class="hud-stat"><span class="hl">电池1 (%/V)</span><span class="hv" id="hud_bat1">—</span></div>
|
||||||
|
<div class="hud-stat"><span class="hl">电池2 (%/V)</span><span class="hv" id="hud_bat2">—</span></div>
|
||||||
|
<div class="hud-sep"></div>
|
||||||
|
<span class="hud-tag">RTK</span>
|
||||||
|
<div class="hud-stat"><span class="hl">经度</span><span class="hv" id="hud_rtk_lng">—</span></div>
|
||||||
|
<div class="hud-stat"><span class="hl">纬度</span><span class="hv" id="hud_rtk_lat">—</span></div>
|
||||||
|
<div class="hud-stat"><span class="hl">海拔(m)</span><span class="hv" id="hud_rtk_alt">—</span></div>
|
||||||
|
<div class="hud-stat"><span class="hl">椭球高(m)</span><span class="hv" id="hud_rtk_ell">—</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 标签页 -->
|
<!-- 标签页 -->
|
||||||
<div class="px-3 pt-3">
|
<div class="px-3 pt-3">
|
||||||
<ul class="nav nav-tabs mb-3" id="mainTab">
|
<ul class="nav nav-tabs mb-3" id="mainTab">
|
||||||
|
|
@ -134,6 +181,7 @@ h6.cmd-title { font-size: 12px; color: #e6edf3; font-weight: 600; margin-bottom:
|
||||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-log">日志</a></li>
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-log">日志</a></li>
|
||||||
<!-- OTA 暂时停用:<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-ota">OTA</a></li> -->
|
<!-- OTA 暂时停用:<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-ota">OTA</a></li> -->
|
||||||
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-route">航线任务</a></li>
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-route">航线任务</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#tab-routelib" onclick="rlSearch(1)">航线库</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="field-legend">字段标记:<span class="req-star">*</span> 必填项 <span class="opt-tag">选填</span> 非必填项(依据 doc 协议文档)</div>
|
<div class="field-legend">字段标记:<span class="req-star">*</span> 必填项 <span class="opt-tag">选填</span> 非必填项(依据 doc 协议文档)</div>
|
||||||
|
|
@ -781,7 +829,76 @@ h6.cmd-title { font-size: 12px; color: #e6edf3; font-weight: 600; margin-bottom:
|
||||||
|
|
||||||
<!-- ===== 状态信息 ===== -->
|
<!-- ===== 状态信息 ===== -->
|
||||||
<div class="tab-pane fade" id="tab-status">
|
<div class="tab-pane fade" id="tab-status">
|
||||||
|
|
||||||
|
<!-- 实时遥测状态条(MQTT 自动连接) -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex align-items-center flex-wrap gap-2">
|
||||||
|
<span><i class="bi bi-broadcast-pin me-1"></i>飞机实时遥测</span>
|
||||||
|
<span class="text-muted" style="text-transform:none;font-weight:400">MQTT 实时订阅,自动连接并断线重连</span>
|
||||||
|
<div class="ms-auto d-flex align-items-center gap-3">
|
||||||
|
<span id="osd_online_badge" class="st-badge st-off">未连接</span>
|
||||||
|
<span class="text-muted small" id="osd_update_time" style="text-transform:none"></span>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm py-0" onclick="refreshOnlineStatus()" title="HTTP 兜底拉取一次"><i class="bi bi-arrow-clockwise"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section-grid">
|
<div class="section-grid">
|
||||||
|
|
||||||
|
<!-- 飞机实时 OSD -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-geo me-1"></i>飞机实时 OSD</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="osd_grid" class="kv-grid"></div>
|
||||||
|
<div id="osd_empty" class="kv-empty">暂无数据,等待实时遥测…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 飞机实时状态 state -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-hdd-network me-1"></i>飞机实时状态 state</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="state_grid" class="kv-grid"></div>
|
||||||
|
<div id="state_empty" class="kv-empty">暂无数据,等待实时遥测…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RTK 定位 (method=rtk) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-geo-alt-fill me-1"></i>RTK 定位 (rtk)</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="rtk_grid" class="kv-grid"></div>
|
||||||
|
<div id="rtk_empty" class="kv-empty">暂无 RTK 数据</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 遥控链路 (method=rc) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-reception-4 me-1"></i>遥控链路 (rc)</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="rc_grid" class="kv-grid"></div>
|
||||||
|
<div id="rc_empty" class="kv-empty">暂无遥控链路数据</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 避障 (method=obs) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-bounding-box me-1"></i>避障 (obs)</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="obs_grid" class="kv-grid"></div>
|
||||||
|
<div id="obs_empty" class="kv-empty">暂无避障数据</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 电池 (method=battery) -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-battery-half me-1"></i>电池 (battery)</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="bat_wrap"></div>
|
||||||
|
<div id="bat_empty" class="kv-empty">暂无电池数据</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><i class="bi bi-activity me-1"></i>查询飞行状态</div>
|
<div class="card-header"><i class="bi bi-activity me-1"></i>查询飞行状态</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
@ -1233,6 +1350,55 @@ h6.cmd-title { font-size: 12px; color: #e6edf3; font-weight: 600; margin-bottom:
|
||||||
|
|
||||||
</div><!-- tab-route -->
|
</div><!-- tab-route -->
|
||||||
|
|
||||||
|
<!-- ===== 航线库(本地 bus_route + bus_route_waypoint) ===== -->
|
||||||
|
<div class="tab-pane fade" id="tab-routelib">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><i class="bi bi-collection me-1"></i>Q20 航线库(本地已保存航线,含航点)</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2 align-items-end mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">航线名称</label>
|
||||||
|
<input id="rl_name" class="form-control" placeholder="支持模糊查询" onkeydown="if(event.key==='Enter')rlSearch(1)">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">航线ID (q20_route_id)</label>
|
||||||
|
<input id="rl_wayline" class="form-control" placeholder="支持模糊查询" onkeydown="if(event.key==='Enter')rlSearch(1)">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 d-flex gap-2">
|
||||||
|
<button class="btn btn-primary" onclick="rlSearch(1)"><i class="bi bi-search me-1"></i>查询</button>
|
||||||
|
<button class="btn btn-outline-secondary" onclick="rlReset()"><i class="bi bi-arrow-counterclockwise me-1"></i>重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-dark align-middle mb-2" style="font-size:12px">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:48px">序号</th><th>航线名称</th><th>航线ID</th><th>机库SN</th><th>航点数</th>
|
||||||
|
<th>速度(m/s)</th><th>高度(m)</th><th>返航高(m)</th>
|
||||||
|
<th>结束动作</th><th>更新时间</th><th style="width:64px">航点</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="rl_tbody">
|
||||||
|
<tr><td colspan="11" class="text-center text-muted py-3">暂无数据,点击「查询」加载</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- 分页栏(底部):总数 + 每页 + 页码 -->
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<span class="text-muted small" id="rl_info"></span>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<span class="text-muted small">每页</span>
|
||||||
|
<select id="rl_size" class="form-select form-select-sm" style="width:auto" onchange="rlSearch(1)">
|
||||||
|
<option value="10">10</option><option value="20">20</option><option value="50">50</option>
|
||||||
|
</select>
|
||||||
|
<div id="rl_pager" class="d-flex gap-1 align-items-center"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="rl_resp" class="resp-box err" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><!-- tab-routelib -->
|
||||||
|
|
||||||
</div><!-- tab-content -->
|
</div><!-- tab-content -->
|
||||||
</div><!-- px-3 -->
|
</div><!-- px-3 -->
|
||||||
|
|
||||||
|
|
@ -1362,6 +1528,323 @@ async function request(method, url, body, respId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 飞机实时遥测 (OSD / state) ====================
|
||||||
|
const _fmt = (n, digits) => {
|
||||||
|
if (n === null || n === undefined || n === '') return '—';
|
||||||
|
const x = Number(n);
|
||||||
|
if (isNaN(x)) return String(n);
|
||||||
|
return digits != null ? x.toFixed(digits) : String(x);
|
||||||
|
};
|
||||||
|
// 0/1 状态徽标
|
||||||
|
const _stBadge = (val, onText, offText) => {
|
||||||
|
const on = Number(val) === 1;
|
||||||
|
return '<span class="st-badge ' + (on ? 'st-on' : 'st-off') + '">' + (on ? (onText || '开') : (offText || '关')) + '</span>';
|
||||||
|
};
|
||||||
|
const _arr = a => Array.isArray(a) ? '[' + a.join(', ') + ']' : (a ?? '—');
|
||||||
|
const _kv = (k, val) => '<div class="k">' + k + '</div><div class="v">' + val + '</div>';
|
||||||
|
|
||||||
|
async function refreshOnlineStatus() {
|
||||||
|
let s;
|
||||||
|
try { s = sn(); } catch { return; }
|
||||||
|
const badge = document.getElementById('osd_online_badge');
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_BASE + '/business/q20/online/' + s, { headers: headers() });
|
||||||
|
if (res.status === 401) { doLogout(); return; }
|
||||||
|
const json = await res.json();
|
||||||
|
const d = (json && json.data) || {};
|
||||||
|
const online = !!d.online;
|
||||||
|
badge.textContent = online ? '在线' : '离线';
|
||||||
|
badge.className = 'st-badge ' + (online ? 'st-on' : 'st-off');
|
||||||
|
document.getElementById('osd_update_time').textContent = '更新于 ' + new Date().toLocaleTimeString();
|
||||||
|
renderOsd(d.osd);
|
||||||
|
renderRtk(d.rtk);
|
||||||
|
renderRc(d.rc);
|
||||||
|
renderObs(d.obs);
|
||||||
|
renderBatteries(d.batteries);
|
||||||
|
// 同步顶部 HUD
|
||||||
|
liveOsd = d.osd || null; liveRtk = d.rtk || null; liveObs = d.obs || null;
|
||||||
|
liveBatteries = {};
|
||||||
|
(Array.isArray(d.batteries) ? d.batteries : []).forEach(b => { if (b && b.id != null) liveBatteries[b.id] = b; });
|
||||||
|
renderHud();
|
||||||
|
} catch (e) {
|
||||||
|
badge.textContent = '请求失败';
|
||||||
|
badge.className = 'st-badge st-warn';
|
||||||
|
document.getElementById('osd_update_time').textContent = '';
|
||||||
|
['osd_grid','state_grid','rtk_grid','rc_grid','obs_grid'].forEach(id => document.getElementById(id).innerHTML = '');
|
||||||
|
document.getElementById('bat_wrap').innerHTML = '';
|
||||||
|
['osd_empty','state_empty','rtk_empty','rc_empty','obs_empty','bat_empty'].forEach(id => {
|
||||||
|
const el = document.getElementById(id); el.style.display = ''; el.textContent = '请求失败:' + e.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOsd(osd) {
|
||||||
|
const og = document.getElementById('osd_grid'), sg = document.getElementById('state_grid');
|
||||||
|
const oe = document.getElementById('osd_empty'), se = document.getElementById('state_empty');
|
||||||
|
if (!osd) {
|
||||||
|
og.innerHTML = ''; sg.innerHTML = '';
|
||||||
|
oe.style.display = ''; oe.textContent = '暂无 OSD 数据(飞机离线或未上报遥测)';
|
||||||
|
se.style.display = ''; se.textContent = '暂无状态数据';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
oe.style.display = 'none';
|
||||||
|
const flying = Number(osd.flying) === 1;
|
||||||
|
og.innerHTML =
|
||||||
|
_kv('飞行模式 mode', osd.mode ?? '—') +
|
||||||
|
_kv('飞行状态 flying', flying ? '<span class="st-badge st-on">飞行中</span>' : '<span class="st-badge st-off">地面/待机</span>') +
|
||||||
|
_kv('经度 longitude', _fmt(osd.longitude, 7)) +
|
||||||
|
_kv('纬度 latitude', _fmt(osd.latitude, 7)) +
|
||||||
|
_kv('相对高度 height (m)', _fmt(osd.height, 2)) +
|
||||||
|
_kv('海拔 altitude (m)', _fmt(osd.altitude, 2)) +
|
||||||
|
_kv('水平速度 gs (m/s)', _fmt(osd.gs, 2)) +
|
||||||
|
_kv('垂直速度 vs (m/s)', _fmt(osd.vs, 2)) +
|
||||||
|
_kv('航向 heading (°)', _fmt(osd.heading, 1)) +
|
||||||
|
_kv('俯仰 pitch (°)', _fmt(osd.pitch, 1)) +
|
||||||
|
_kv('横滚 roll (°)', _fmt(osd.roll, 1)) +
|
||||||
|
_kv('电量 battery (%)', _fmt(osd.battery)) +
|
||||||
|
_kv('电压 voltage (V)', _fmt(osd.voltage)) +
|
||||||
|
_kv('飞行时长 fly_time (s)', _fmt(osd.fly_time)) +
|
||||||
|
_kv('飞行距离 fly_distance (m)', _fmt(osd.fly_distance, 1)) +
|
||||||
|
_kv('返航点 home_set', _stBadge(osd.home_set, '已设', '未设')) +
|
||||||
|
_kv('距返航点 home_distance (m)', _fmt(osd.home_distance, 1));
|
||||||
|
|
||||||
|
const st = osd.state;
|
||||||
|
if (!st) { sg.innerHTML = ''; se.style.display = ''; se.textContent = '暂无 state 数据'; return; }
|
||||||
|
se.style.display = 'none';
|
||||||
|
sg.innerHTML =
|
||||||
|
_kv('负载状态 payload_state', _arr(st.payload_state)) +
|
||||||
|
_kv('电池状态 battery_state', _arr(st.battery_state)) +
|
||||||
|
_kv('安全开关 safe_enabled', _stBadge(st.safe_enabled)) +
|
||||||
|
_kv('RTK启用 rtk_enabled', _stBadge(st.rtk_enabled)) +
|
||||||
|
_kv('RTK连接 rtk_connected', _stBadge(st.rtk_connected, '已连接', '未连接')) +
|
||||||
|
_kv('遥控连接 rc_connected', _stBadge(st.rc_connected, '已连接', '未连接')) +
|
||||||
|
_kv('避障 obs_enabled', _stBadge(st.obs_enabled)) +
|
||||||
|
_kv('加速度校准 accel_cal', _stBadge(st.accel_cal, '已校准', '未校准')) +
|
||||||
|
_kv('陀螺仪校准 gyro_cal', _stBadge(st.gyro_cal, '已校准', '未校准')) +
|
||||||
|
_kv('水平校准 hor_cal', _stBadge(st.hor_cal, '已校准', '未校准')) +
|
||||||
|
_kv('磁罗盘校准 mag_cal', _stBadge(st.mag_cal, '已校准', '未校准')) +
|
||||||
|
_kv('FPV直播 fpv_live', _stBadge(st.fpv_live, '推流中', '未推流')) +
|
||||||
|
_kv('图传直播 stream_live', _stBadge(st.stream_live, '推流中', '未推流'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用:渲染一个键值网格,data 为空时显示占位
|
||||||
|
function _renderKv(gridId, emptyId, data, rowsFn, emptyText) {
|
||||||
|
const g = document.getElementById(gridId), e = document.getElementById(emptyId);
|
||||||
|
if (!data) { g.innerHTML = ''; e.style.display = ''; e.textContent = emptyText; return; }
|
||||||
|
e.style.display = 'none';
|
||||||
|
g.innerHTML = rowsFn(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RTK 定位 (method=rtk)
|
||||||
|
const RTK_FIX = { 0:'0 无定位', 1:'1 单点', 2:'2 差分', 4:'4 RTK固定', 5:'5 RTK浮动', 6:'6 估算' };
|
||||||
|
function renderRtk(rtk) {
|
||||||
|
_renderKv('rtk_grid', 'rtk_empty', rtk, d =>
|
||||||
|
_kv('定位类型 fix_type', RTK_FIX[d.fix_type] ?? _fmt(d.fix_type)) +
|
||||||
|
_kv('可见卫星 satellite_visible', _fmt(d.satellite_visible)) +
|
||||||
|
_kv('经度 longitude', _fmt(d.longitude, 7)) +
|
||||||
|
_kv('纬度 latitude', _fmt(d.latitude, 7)) +
|
||||||
|
_kv('海拔 altitude (m)', _fmt(d.altitude, 2)) +
|
||||||
|
_kv('椭球高 ellipsoid_height (m)', _fmt(d.ellipsoid_height, 2)) +
|
||||||
|
_kv('水平精度 eph', _fmt(d.eph, 2)) +
|
||||||
|
_kv('垂直精度 epv', _fmt(d.epv, 2)) +
|
||||||
|
_kv('速度 velocity (m/s)', _fmt(d.velocity, 2)) +
|
||||||
|
_kv('航向 yaw (°)', _fmt(d.yaw, 2)) +
|
||||||
|
_kv('航迹角 cog (°)', _fmt(d.cog, 2)) +
|
||||||
|
_kv('水平精度因子 horizontal_acc', _fmt(d.horizontal_acc, 2)) +
|
||||||
|
_kv('垂直精度因子 vertical_acc', _fmt(d.vertical_acc, 2)) +
|
||||||
|
_kv('速度精度 velocity_acc', _fmt(d.velocity_acc, 2)),
|
||||||
|
'暂无 RTK 数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遥控链路 (method=rc)
|
||||||
|
function renderRc(rc) {
|
||||||
|
_renderKv('rc_grid', 'rc_empty', rc, d => {
|
||||||
|
let rows =
|
||||||
|
_kv('优先级 priority', _fmt(d.priority)) +
|
||||||
|
_kv('遥控状态 rc_state', d.rc_state ?? '—') +
|
||||||
|
_kv('通道状态 rc_channel_state', d.rc_channel_state ?? '—') +
|
||||||
|
_kv('上行丢包率 up_loss_rate', _fmt(d.up_loss_rate, 2)) +
|
||||||
|
_kv('下行丢包率 down_loss_rate', _fmt(d.down_loss_rate, 2));
|
||||||
|
if (d.network) {
|
||||||
|
rows += _kv('网络类型 network.type', _fmt(d.network.type)) +
|
||||||
|
_kv('网络质量 network.quality', _fmt(d.network.quality));
|
||||||
|
}
|
||||||
|
if (Array.isArray(d.link)) {
|
||||||
|
d.link.forEach((lk, i) => {
|
||||||
|
const inuse = Number(lk.inuse) === 1;
|
||||||
|
rows += _kv('链路' + (lk.id ?? i) + (inuse ? ' (使用中)' : ''),
|
||||||
|
'type=' + _fmt(lk.type) + ' conn=' + _fmt(lk.connected) +
|
||||||
|
' sqe=' + _fmt(lk.sqe) + ' band=' + _fmt(lk.band));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, '暂无遥控链路数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 避障 (method=obs),距离 -1 表示无障碍物
|
||||||
|
const _obsDist = x => (Number(x) === -1 ? '<span class="st-badge st-off">无</span>' : _fmt(x, 2) + ' m');
|
||||||
|
const OBS_DIR = ['前','右前','右','右后','后','左后','左','左前'];
|
||||||
|
function renderObs(obs) {
|
||||||
|
_renderKv('obs_grid', 'obs_empty', obs, d => {
|
||||||
|
let rows =
|
||||||
|
_kv('避障开关 cp_enable', _stBadge(d.cp_enable)) +
|
||||||
|
_kv('避障距离 cp_distance (m)', _fmt(d.cp_distance, 2));
|
||||||
|
if (Array.isArray(d.ver_distances)) {
|
||||||
|
rows += _kv('下方障碍 ver[下]', _obsDist(d.ver_distances[0])) +
|
||||||
|
_kv('上方障碍 ver[上]', _obsDist(d.ver_distances[1]));
|
||||||
|
}
|
||||||
|
if (Array.isArray(d.around_distances)) {
|
||||||
|
d.around_distances.forEach((x, i) => {
|
||||||
|
rows += _kv('水平 ' + (OBS_DIR[i] || ('方位' + i)), _obsDist(x));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, '暂无避障数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 电池 (method=battery),可能多块
|
||||||
|
const BAT_STATUS = { 0:'0 异常', 1:'1 开机', 2:'2 充电中', 3:'3 需保养' };
|
||||||
|
function renderBatteries(list) {
|
||||||
|
const wrap = document.getElementById('bat_wrap'), empty = document.getElementById('bat_empty');
|
||||||
|
if (!Array.isArray(list) || list.length === 0) {
|
||||||
|
wrap.innerHTML = ''; empty.style.display = ''; empty.textContent = '暂无电池数据'; return;
|
||||||
|
}
|
||||||
|
empty.style.display = 'none';
|
||||||
|
wrap.innerHTML = list.map(b => {
|
||||||
|
const title = '电池 #' + (b.id ?? '—') + (b.model ? ' · ' + b.model : '');
|
||||||
|
const grid =
|
||||||
|
_kv('剩余电量 charge_remaining (%)', _fmt(b.charge_remaining)) +
|
||||||
|
_kv('状态 status', BAT_STATUS[b.status] ?? _fmt(b.status)) +
|
||||||
|
_kv('电压 voltage (V)', _fmt(b.voltage, 2)) +
|
||||||
|
_kv('电流 current (A)', _fmt(b.current, 2)) +
|
||||||
|
_kv('温度 temperature (℃)', _fmt(b.temperature, 1)) +
|
||||||
|
_kv('健康度 health (%)', _fmt(b.health)) +
|
||||||
|
_kv('循环次数 cycle_index', _fmt(b.cycle_index)) +
|
||||||
|
_kv('剩余飞行时间 time_remaining (s)', _fmt(b.time_remaining)) +
|
||||||
|
_kv('满充容量 full_charge_capacity', _fmt(b.full_charge_capacity)) +
|
||||||
|
_kv('低压告警 low_volt_warn_value', _fmt(b.low_volt_warn_value)) +
|
||||||
|
_kv('UID', b.uid ?? '—') +
|
||||||
|
_kv('固件版本 version', b.version ?? '—');
|
||||||
|
return '<div class="mb-2"><div class="cmd-title mb-1"><i class="bi bi-battery me-1"></i>' + title +
|
||||||
|
'</div><div class="kv-grid">' + grid + '</div></div>';
|
||||||
|
}).join('<hr style="border-color:var(--border-color);margin:8px 0">');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== MQTT 实时订阅 OSD 遥测(自动连接 + 断线重连) ====================
|
||||||
|
const MQTT_URL = 'ws://223.108.157.174:61628/mqtt';
|
||||||
|
const MQTT_USER = 'dock';
|
||||||
|
const MQTT_PASS = 'Dock@2023';
|
||||||
|
|
||||||
|
let mqttClient = null;
|
||||||
|
let mqttSubTopic = null;
|
||||||
|
let liveBatteries = {}; // 客户端按电池id累积(MQTT每次只推一块电池)
|
||||||
|
let liveOsd = null, liveRtk = null, liveObs = null; // 最新各遥测,供顶部 HUD 汇总
|
||||||
|
|
||||||
|
// 顶部 HUD:汇总两块电池电量/电压、飞行/位置/速度/姿态、避障、RTK 定位,实时刷新
|
||||||
|
function renderHud() {
|
||||||
|
const set = (id, val) => { const el = document.getElementById(id); if (el) el.innerHTML = val; };
|
||||||
|
const o = liveOsd;
|
||||||
|
if (o) {
|
||||||
|
const flying = Number(o.flying) === 1;
|
||||||
|
set('hud_flying', flying
|
||||||
|
? '<span style="color:var(--success)">飞行中</span>'
|
||||||
|
: '<span style="color:var(--muted)">地面/待机</span>');
|
||||||
|
set('hud_lng', _fmt(o.longitude, 7));
|
||||||
|
set('hud_lat', _fmt(o.latitude, 7));
|
||||||
|
set('hud_height', _fmt(o.height, 1));
|
||||||
|
set('hud_alt', _fmt(o.altitude, 1));
|
||||||
|
set('hud_gs', _fmt(o.gs, 1));
|
||||||
|
set('hud_vs', _fmt(o.vs, 1));
|
||||||
|
set('hud_heading', _fmt(o.heading, 1));
|
||||||
|
set('hud_pitch', _fmt(o.pitch, 1));
|
||||||
|
set('hud_roll', _fmt(o.roll, 1));
|
||||||
|
}
|
||||||
|
// 避障:优先用 obs 报文(开关+距离),否则回退 osd.state.obs_enabled
|
||||||
|
if (liveObs) {
|
||||||
|
const en = Number(liveObs.cp_enable) === 1;
|
||||||
|
set('hud_obs', (en ? '<span style="color:var(--success)">开</span>' : '<span style="color:var(--muted)">关</span>')
|
||||||
|
+ ' ' + _fmt(liveObs.cp_distance, 1) + 'm');
|
||||||
|
} else if (o && o.state) {
|
||||||
|
set('hud_obs', Number(o.state.obs_enabled) === 1
|
||||||
|
? '<span style="color:var(--success)">开</span>' : '<span style="color:var(--muted)">关</span>');
|
||||||
|
}
|
||||||
|
// 两块电池(按 id 升序取前两块):剩余电量% / 电压V
|
||||||
|
const bats = Object.values(liveBatteries).sort((a, b) => (a.id || 0) - (b.id || 0));
|
||||||
|
const fmtBat = b => b ? (_fmt(b.charge_remaining) + '% / ' + _fmt(b.voltage, 1) + 'V') : '—';
|
||||||
|
set('hud_bat1', fmtBat(bats[0]));
|
||||||
|
set('hud_bat2', fmtBat(bats[1]));
|
||||||
|
// RTK 定位
|
||||||
|
const r = liveRtk;
|
||||||
|
if (r) {
|
||||||
|
set('hud_rtk_lng', _fmt(r.longitude, 7));
|
||||||
|
set('hud_rtk_lat', _fmt(r.latitude, 7));
|
||||||
|
set('hud_rtk_alt', _fmt(r.altitude, 2));
|
||||||
|
set('hud_rtk_ell', _fmt(r.ellipsoid_height, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOsdBadge(text, cls) {
|
||||||
|
const b = document.getElementById('osd_online_badge');
|
||||||
|
if (!b) return;
|
||||||
|
b.textContent = text;
|
||||||
|
b.className = 'st-badge ' + cls;
|
||||||
|
}
|
||||||
|
function stampUpdate() {
|
||||||
|
document.getElementById('osd_update_time').textContent = '更新于 ' + new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动连接 MQTT(断线由 mqtt.js 按 reconnectPeriod 自动重连);已连接则确保订阅当前 SN
|
||||||
|
function mqttAutoConnect() {
|
||||||
|
if (typeof mqtt === 'undefined') { setOsdBadge('MQTT库未加载', 'st-warn'); return; }
|
||||||
|
if (mqttClient) { subscribeCurrentSn(); return; }
|
||||||
|
setOsdBadge('连接中…', 'st-off');
|
||||||
|
liveBatteries = {};
|
||||||
|
mqttClient = mqtt.connect(MQTT_URL, {
|
||||||
|
username: MQTT_USER, password: MQTT_PASS,
|
||||||
|
clientId: 'q20web_' + Math.random().toString(16).slice(2, 10),
|
||||||
|
clean: true, keepalive: 30, connectTimeout: 8000, reconnectPeriod: 4000,
|
||||||
|
});
|
||||||
|
mqttClient.on('connect', () => { setOsdBadge('已连接', 'st-on'); subscribeCurrentSn(); });
|
||||||
|
mqttClient.on('message', onMqttMessage);
|
||||||
|
mqttClient.on('reconnect', () => setOsdBadge('重连中…', 'st-off'));
|
||||||
|
mqttClient.on('error', () => setOsdBadge('连接错误', 'st-warn'));
|
||||||
|
mqttClient.on('close', () => setOsdBadge('已断开(重连中)', 'st-off'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅当前顶栏 SN 的 osd 主题;SN 变化时自动切换订阅
|
||||||
|
function subscribeCurrentSn() {
|
||||||
|
if (!mqttClient || !mqttClient.connected) return;
|
||||||
|
const s = v('deviceSn').trim();
|
||||||
|
if (!s) return;
|
||||||
|
const topic = 'thing/device/' + s + '/osd';
|
||||||
|
if (topic === mqttSubTopic) return;
|
||||||
|
if (mqttSubTopic) mqttClient.unsubscribe(mqttSubTopic);
|
||||||
|
liveBatteries = {}; liveOsd = liveRtk = liveObs = null; renderHud();
|
||||||
|
mqttSubTopic = topic;
|
||||||
|
mqttClient.subscribe(topic, { qos: 0 }, err => { if (err) setOsdBadge('订阅失败', 'st-warn'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMqttMessage(topic, payload) {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(payload.toString()); } catch { return; }
|
||||||
|
const method = msg && msg.method;
|
||||||
|
const data = msg && msg.data;
|
||||||
|
if (!method || !data) return;
|
||||||
|
switch (method) {
|
||||||
|
case 'osd': liveOsd = data; renderOsd(data); setOsdBadge('在线', 'st-on'); break;
|
||||||
|
case 'rtk': liveRtk = data; renderRtk(data); break;
|
||||||
|
case 'rc': renderRc(data); break;
|
||||||
|
case 'obs': liveObs = data; renderObs(data); break;
|
||||||
|
case 'battery':
|
||||||
|
if (data.id != null) liveBatteries[data.id] = data;
|
||||||
|
renderBatteries(Object.values(liveBatteries));
|
||||||
|
break;
|
||||||
|
default: return;
|
||||||
|
}
|
||||||
|
renderHud();
|
||||||
|
stampUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 飞机模拟回复 ====================
|
// ==================== 飞机模拟回复 ====================
|
||||||
function toggleAircraftPanel() {
|
function toggleAircraftPanel() {
|
||||||
const panel = document.getElementById('aircraft-mock-panel');
|
const panel = document.getElementById('aircraft-mock-panel');
|
||||||
|
|
@ -1420,6 +1903,42 @@ function amSendOsd() {
|
||||||
amPublish('osd', { method: 'osd', data }, 'am_osd_resp');
|
amPublish('osd', { method: 'osd', data }, 'am_osd_resp');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 模拟上报其余 OSD 子遥测(rtk / rc / obs / battery),默认值取自协议示例
|
||||||
|
function amSendRtk() {
|
||||||
|
amPublish('osd', { method: 'rtk', source: 'eth', data: {
|
||||||
|
fix_type: 4, longitude: parseFloat(v('am_lng'))||113.1234567, latitude: parseFloat(v('am_lat'))||23.1234567,
|
||||||
|
altitude: parseFloat(v('am_height'))||100, ellipsoid_height: parseFloat(v('am_height'))||100,
|
||||||
|
satellite_visible: 18, eph: 0.8, epv: 1.2, velocity: 0, yaw: parseFloat(v('am_heading'))||0,
|
||||||
|
cog: 0, horizontal_acc: 0.5, vertical_acc: 0.8, velocity_acc: 0.1, hdg_acc: 0
|
||||||
|
} }, 'am_osd_resp');
|
||||||
|
}
|
||||||
|
|
||||||
|
function amSendRc() {
|
||||||
|
amPublish('osd', { method: 'rc', source: 'eth', data: {
|
||||||
|
priority: 0, rc_channel_state: 'normal', rc_state: 'connected',
|
||||||
|
up_loss_rate: 0, down_loss_rate: 0,
|
||||||
|
link: [ { id: 0, type: 0, connected: 1, inuse: 1, sqe: 100, band: 1 } ],
|
||||||
|
network: { type: 1, quality: 5 }
|
||||||
|
} }, 'am_osd_resp');
|
||||||
|
}
|
||||||
|
|
||||||
|
function amSendObs() {
|
||||||
|
amPublish('osd', { method: 'obs', source: 'eth', data: {
|
||||||
|
cp_distance: 5, cp_enable: 1, ver_distances: [40, -1],
|
||||||
|
around_distances: [40, -1, 40, -1, 3.06, -1, 40, -1]
|
||||||
|
} }, 'am_osd_resp');
|
||||||
|
}
|
||||||
|
|
||||||
|
function amSendBattery() {
|
||||||
|
amPublish('osd', { method: 'battery', source: 'eth', data: {
|
||||||
|
id: 20, full_charge_capacity: -1, charge_remaining: parseInt(v('am_battery'))||97,
|
||||||
|
voltage: 49.75, current: 0, low_volt_warn_value: -1, status: 1, temperature: 31,
|
||||||
|
cycle_index: 26, active: -1, using_capacity: -1, time_flying: -1, health: 94,
|
||||||
|
time_remaining: 4200, charge_time_remaining: -1, flags: -1,
|
||||||
|
uid: 'ZABN2410240124', version: 'd1.1.60', model: 'EQ20-N'
|
||||||
|
} }, 'am_osd_resp');
|
||||||
|
}
|
||||||
|
|
||||||
function amSendLiftoff() {
|
function amSendLiftoff() {
|
||||||
const type = document.getElementById('am_liftoff_type').value;
|
const type = document.getElementById('am_liftoff_type').value;
|
||||||
amPublish('events', { method: 'liftoff', data: { type, wayline: '', code: '' }, need_reply: 0 }, 'am_events_resp');
|
amPublish('events', { method: 'liftoff', data: { type, wayline: '', code: '' }, need_reply: 0 }, 'am_events_resp');
|
||||||
|
|
@ -1519,7 +2038,7 @@ const METHOD_TEMPLATES = {
|
||||||
query_status: { result: 0, output: { status: 0, message: "" }, fly_state: "hover", fly_mode: "auto", battery: 80 },
|
query_status: { result: 0, output: { status: 0, message: "" }, fly_state: "hover", fly_mode: "auto", battery: 80 },
|
||||||
get_config: { result: 0, output: { status: 0, message: "" }, config: {} },
|
get_config: { result: 0, output: { status: 0, message: "" }, config: {} },
|
||||||
stereo_image: { result: 0, output: { status: 0, message: "" } },
|
stereo_image: { result: 0, output: { status: 0, message: "" } },
|
||||||
version: { result: 0, output: { status: 0, message: "" }, version: "2.0.0", sn: "", firmware: "1.0.0" },
|
version: { result: 0, offboard_version: "6.1.23", firmware_version: "V1.22.1", protocol_version: "2.0.6", vision_version: "1.8.3", vehicle_type: "Q20N", output: { status: 0, message: "" } },
|
||||||
// ── 日志 ──
|
// ── 日志 ──
|
||||||
log_count: { result: 0, output: { status: 0, message: "" }, count: 10 },
|
log_count: { result: 0, output: { status: 0, message: "" }, count: 10 },
|
||||||
log_list: { result: 0, output: { status: 0, message: "" }, list: [] },
|
log_list: { result: 0, output: { status: 0, message: "" }, list: [] },
|
||||||
|
|
@ -1527,7 +2046,7 @@ const METHOD_TEMPLATES = {
|
||||||
// ── OTA ──
|
// ── OTA ──
|
||||||
ota_upgrade: { result: 0, output: { status: 1, message: "" } },
|
ota_upgrade: { result: 0, output: { status: 1, message: "" } },
|
||||||
// ── 航线(async) ──
|
// ── 航线(async) ──
|
||||||
route_upload: { result: 0, output: { status: 1, message: "" } },
|
route_upload: { result: 0, output: { status: 0, message: "Mission uploaded successfully!" } },
|
||||||
route_execute: { result: 0, output: { status: 1, message: "" } },
|
route_execute: { result: 0, output: { status: 1, message: "" } },
|
||||||
route_auto: { result: 0, output: { status: 1, message: "" } },
|
route_auto: { result: 0, output: { status: 1, message: "" } },
|
||||||
// ── 航线(sync) ──
|
// ── 航线(sync) ──
|
||||||
|
|
@ -1861,6 +2380,143 @@ async function routeJsonCmd(action, textareaId, respId) {
|
||||||
request('POST', API_BASE + '/business/q20/route/' + action + '/' + s, body, respId);
|
request('POST', API_BASE + '/business/q20/route/' + action + '/' + s, body, respId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 航线库(分页查询本地 bus_route + bus_route_waypoint) ====================
|
||||||
|
let rlPage = 1, rlSize = 10, rlTotal = 0;
|
||||||
|
const _esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||||
|
const _fmtTs = ts => { if (!ts) return '—'; const d = new Date(ts); return isNaN(d) ? _esc(ts) : d.toLocaleString(); };
|
||||||
|
|
||||||
|
function rlReset() {
|
||||||
|
document.getElementById('rl_name').value = '';
|
||||||
|
document.getElementById('rl_wayline').value = '';
|
||||||
|
rlSearch(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算页码窗口:首尾页 + 当前页两侧各2页,间断处以 '...' 表示
|
||||||
|
function pageWindow(cur, max) {
|
||||||
|
const set = new Set([1, max]);
|
||||||
|
for (let p = cur - 2; p <= cur + 2; p++) if (p >= 1 && p <= max) set.add(p);
|
||||||
|
const sorted = [...set].sort((a, b) => a - b);
|
||||||
|
const out = [];
|
||||||
|
let prev = 0;
|
||||||
|
for (const p of sorted) {
|
||||||
|
if (prev && p - prev > 1) out.push('...');
|
||||||
|
out.push(p); prev = p;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染底部页码按钮(« 上一页 + 数字页码 + 下一页 »)
|
||||||
|
function buildPager() {
|
||||||
|
const maxPage = Math.max(1, Math.ceil(rlTotal / rlSize));
|
||||||
|
if (rlPage > maxPage) rlPage = maxPage;
|
||||||
|
const btn = (label, page, o) => {
|
||||||
|
o = o || {};
|
||||||
|
if (o.ellipsis) return '<span class="px-1 text-muted">…</span>';
|
||||||
|
if (o.active) return '<button class="btn btn-primary btn-sm" disabled>' + label + '</button>';
|
||||||
|
if (o.disabled) return '<button class="btn btn-outline-secondary btn-sm" disabled>' + label + '</button>';
|
||||||
|
return '<button class="btn btn-outline-secondary btn-sm" onclick="rlSearch(' + page + ')">' + label + '</button>';
|
||||||
|
};
|
||||||
|
let html = btn('«', rlPage - 1, { disabled: rlPage <= 1 });
|
||||||
|
for (const p of pageWindow(rlPage, maxPage)) {
|
||||||
|
html += (p === '...') ? btn('…', 0, { ellipsis: true }) : btn(p, p, { active: p === rlPage });
|
||||||
|
}
|
||||||
|
html += btn('»', rlPage + 1, { disabled: rlPage >= maxPage });
|
||||||
|
document.getElementById('rl_pager').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rlSearch(page) {
|
||||||
|
rlPage = page || 1;
|
||||||
|
rlSize = +v('rl_size') || 10;
|
||||||
|
const params = new URLSearchParams({ page: rlPage, limit: rlSize });
|
||||||
|
const name = v('rl_name').trim(); if (name) params.set('routeName', name);
|
||||||
|
const wl = v('rl_wayline').trim(); if (wl) params.set('q20RouteId', wl);
|
||||||
|
const respEl = document.getElementById('rl_resp');
|
||||||
|
respEl.style.display = 'none';
|
||||||
|
try {
|
||||||
|
const res = await fetch(API_BASE + '/business/q20/route/page?' + params.toString(), { headers: headers() });
|
||||||
|
if (res.status === 401) { doLogout(); return; }
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || (json.code != null && json.code !== 0)) {
|
||||||
|
throw new Error(json.msg || ('HTTP ' + res.status));
|
||||||
|
}
|
||||||
|
const data = json.data || {};
|
||||||
|
rlTotal = data.total || 0;
|
||||||
|
renderRouteLib(data.list || []);
|
||||||
|
} catch (e) {
|
||||||
|
respEl.style.display = 'block';
|
||||||
|
respEl.textContent = '查询失败:' + e.message;
|
||||||
|
document.getElementById('rl_tbody').innerHTML =
|
||||||
|
'<tr><td colspan="11" class="text-center text-danger py-3">查询失败</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRouteLib(list) {
|
||||||
|
const tbody = document.getElementById('rl_tbody');
|
||||||
|
if (!list.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="11" class="text-center text-muted py-3">无符合条件的航线</td></tr>';
|
||||||
|
} else {
|
||||||
|
const base = (rlPage - 1) * rlSize; // 跨页累计序号起点
|
||||||
|
tbody.innerHTML = list.map((r, i) => {
|
||||||
|
const wps = Array.isArray(r.routeWaypointList) ? r.routeWaypointList : [];
|
||||||
|
const detailId = 'rl_wp_' + i;
|
||||||
|
const main =
|
||||||
|
'<tr>' +
|
||||||
|
'<td>' + (base + i + 1) + '</td>' +
|
||||||
|
'<td>' + _esc(r.routeName) + '</td>' +
|
||||||
|
'<td><code>' + _esc(r.q20RouteId || '—') + '</code></td>' +
|
||||||
|
'<td>' + _esc(r.dockSn || '—') + '</td>' +
|
||||||
|
'<td>' + (r.waypointNum != null ? r.waypointNum : wps.length) + '</td>' +
|
||||||
|
'<td>' + _fmt(r.flightSpeed, 1) + '</td>' +
|
||||||
|
'<td>' + _fmt(r.flightHeight, 1) + '</td>' +
|
||||||
|
'<td>' + _fmt(r.globalRthHeight, 1) + '</td>' +
|
||||||
|
'<td>' + _esc(r.finishAction || '—') + '</td>' +
|
||||||
|
'<td>' + _fmtTs(r.updateDate) + '</td>' +
|
||||||
|
'<td><button class="btn btn-outline-secondary btn-sm py-0" onclick="rlToggleWp(\'' + detailId + '\')">' +
|
||||||
|
'<i class="bi bi-list-ol"></i> ' + wps.length + '</button></td>' +
|
||||||
|
'</tr>';
|
||||||
|
const detail =
|
||||||
|
'<tr id="' + detailId + '" style="display:none"><td colspan="11" style="background:#010409">' +
|
||||||
|
renderWaypoints(wps) + '</td></tr>';
|
||||||
|
return main + detail;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
// 分页信息与页码
|
||||||
|
const maxPage = Math.max(1, Math.ceil(rlTotal / rlSize));
|
||||||
|
document.getElementById('rl_info').textContent = '共 ' + rlTotal + ' 条 · 第 ' + rlPage + '/' + maxPage + ' 页';
|
||||||
|
buildPager();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWaypoints(wps) {
|
||||||
|
if (!wps.length) return '<div class="kv-empty">该航线无航点记录</div>';
|
||||||
|
let rows = wps.map(w =>
|
||||||
|
'<tr>' +
|
||||||
|
'<td>' + (w.waypointSort != null ? w.waypointSort : '—') + '</td>' +
|
||||||
|
'<td>' + _fmt(w.longitude, 7) + '</td>' +
|
||||||
|
'<td>' + _fmt(w.latitude, 7) + '</td>' +
|
||||||
|
'<td>' + _fmt(w.flightHeight, 1) + '</td>' +
|
||||||
|
'<td>' + _fmt(w.flightSpeed, 1) + '</td>' +
|
||||||
|
'<td>' + (w.followRouteSpeed ? '是' : '否') + '</td>' +
|
||||||
|
'<td>' + renderActions(w.waypointActionList) + '</td>' +
|
||||||
|
'</tr>').join('');
|
||||||
|
return '<table class="table table-sm table-dark mb-0" style="font-size:11px">' +
|
||||||
|
'<thead><tr><th>序号</th><th>经度</th><th>纬度</th><th>高度(m)</th><th>速度(m/s)</th><th>跟随全局速度</th><th>航点动作</th></tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 航点动作:动作类型 + 可选动作值,按顺序以徽标列出
|
||||||
|
function renderActions(actions) {
|
||||||
|
if (!Array.isArray(actions) || !actions.length) return '<span class="text-muted">无</span>';
|
||||||
|
return actions.map(a => {
|
||||||
|
const val = (a.actionValue != null && a.actionValue !== '') ? '=' + _esc(a.actionValue) : '';
|
||||||
|
return '<span class="st-badge st-off" style="margin:1px">' + _esc(a.actionType || '?') + val + '</span>';
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rlToggleWp(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.style.display = el.style.display === 'none' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// 获取航线信息暂时停用(前后端接口先注释)
|
// 获取航线信息暂时停用(前后端接口先注释)
|
||||||
// function routeInfo() {
|
// function routeInfo() {
|
||||||
// const s = sn();
|
// const s = sn();
|
||||||
|
|
@ -2733,10 +3389,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
// 记住本次输入,下次进入页面自动回填
|
// 记住本次输入,下次进入页面自动回填
|
||||||
if (val) localStorage.setItem('q20_device_sn', val);
|
if (val) localStorage.setItem('q20_device_sn', val);
|
||||||
else localStorage.removeItem('q20_device_sn');
|
else localStorage.removeItem('q20_device_sn');
|
||||||
|
// SN 变化时自动连接/切换订阅到新设备的 osd 主题
|
||||||
|
if (val) mqttAutoConnect();
|
||||||
});
|
});
|
||||||
// 初始渲染:未填写时即显示醒目提示
|
// 初始渲染:未填写时即显示醒目提示
|
||||||
refreshSnState();
|
refreshSnState();
|
||||||
|
|
||||||
|
// 页面加载即自动连接 MQTT,订阅当前 SN 的实时遥测(断线由库自动重连)
|
||||||
|
mqttAutoConnect();
|
||||||
|
|
||||||
document.getElementById('sh_current_gps').addEventListener('change', function () {
|
document.getElementById('sh_current_gps').addEventListener('change', function () {
|
||||||
document.getElementById('sh_manual_fields').style.display = this.checked ? 'none' : '';
|
document.getElementById('sh_manual_fields').style.display = this.checked ? 'none' : '';
|
||||||
});
|
});
|
||||||
|
|
@ -2850,6 +3511,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-sm w-100 mb-1" onclick="amSendOsd()"><i class="bi bi-send me-1"></i>发送 OSD</button>
|
<button class="btn btn-primary btn-sm w-100 mb-1" onclick="amSendOsd()"><i class="bi bi-send me-1"></i>发送 OSD</button>
|
||||||
|
<div class="d-flex gap-1 flex-wrap mb-1">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm flex-fill" onclick="amSendRtk()">发 RTK</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm flex-fill" onclick="amSendRc()">发 RC</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm flex-fill" onclick="amSendObs()">发 OBS</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm flex-fill" onclick="amSendBattery()">发 电池</button>
|
||||||
|
</div>
|
||||||
<div id="am_osd_resp" class="resp-box" style="display:none;max-height:60px"></div>
|
<div id="am_osd_resp" class="resp-box" style="display:none;max-height:60px"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue