1.q20去除心跳等debug日志

2.q20修改航线提交参数
3.其他Q20修改
This commit is contained in:
938693313@qq.com 2026-06-04 17:49:42 +08:00
parent 0a8313d30e
commit 18c6de38ca
16 changed files with 832 additions and 38 deletions

View File

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

View File

@ -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));
// } }
// ==================== 获取执行进度暂时停用先注释 ==================== // ==================== 获取执行进度暂时停用先注释 ====================

View File

@ -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),仅本地存储用,不下发给设备")

View File

@ -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}} 缓存项下LinkedHashMapid -> 电池数据
*/
@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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 查询参数pagelimitrouteName(可选)q20RouteId(可选)
* @return 分页结果每条含完整航点列表
*/
PageData<RouteDTO> pageQ20Routes(Map<String, Object> params);
/** /**
* 获取下一个航线ID根据库中最新Q20航线ID自动递增无记录则返回默认ID * 获取下一个航线ID根据库中最新Q20航线ID自动递增无记录则返回默认ID
* *

View File

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

View File

@ -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_

View File

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

View File

@ -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; // 设备回复只回传相同的 bidtid 会重新生成因此以 bid 作为关联回复的缓存键
String cacheKey = bid;
// 记录当前等待回复的命令供前端模拟回复使用 // 记录当前等待回复的命令供前端模拟回复使用
String deviceSn = extractDeviceSn(topic); String deviceSn = extractDeviceSn(topic);

View File

@ -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>
<!-- 顶部实时遥测 HUDMQTT 实时刷新) -->
<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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>