You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
638 lines
17 KiB
638 lines
17 KiB
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
paho "github.com/eclipse/paho.mqtt.golang"
|
|
)
|
|
|
|
// downlinkEnvelope 后台下发指令的通用外层(协议文档 §4)
|
|
type downlinkEnvelope struct {
|
|
RequestID string `json:"requestId"`
|
|
DockID string `json:"dockId"`
|
|
DroneSN string `json:"droneSn"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
}
|
|
|
|
// commandMsg 后台下发的指令载荷(协议文档 §5.1,位于 payload 内)
|
|
type commandMsg struct {
|
|
CommandID string `json:"commandId"`
|
|
Type string `json:"type"`
|
|
TTLMs int `json:"ttlMs"`
|
|
Params map[string]any `json:"params"`
|
|
RequestID string // 来自通用外层
|
|
DroneSN string // 来自通用外层
|
|
}
|
|
|
|
// droneTelemetry 是机巢直接生成的无人机模拟状态
|
|
type droneTelemetry struct {
|
|
armed bool
|
|
flightMode string
|
|
latitude float64
|
|
longitude float64
|
|
altitude float64
|
|
groundSpeed float64
|
|
heading float64
|
|
roll float64
|
|
pitch float64
|
|
yaw float64
|
|
batteryPct int
|
|
batteryV float64
|
|
satellites int
|
|
gpsQuality string
|
|
batteryPctDrain int
|
|
}
|
|
|
|
// MockDock 模拟机巢:通过 MQTT 上云并直接上报机巢、无人机状态
|
|
type MockDock struct {
|
|
spec dockSpec
|
|
|
|
client paho.Client
|
|
|
|
mu sync.Mutex
|
|
doorState string
|
|
droneOnline bool
|
|
tele droneTelemetry
|
|
inMission bool
|
|
missionPaused bool
|
|
missionCancel chan struct{}
|
|
missionCmdID string
|
|
uploadedRoute []missionWaypoint
|
|
liveSessionID string
|
|
liveStreaming bool
|
|
videoVersion int64
|
|
}
|
|
|
|
func newMockDock(spec dockSpec) *MockDock {
|
|
d := &MockDock{
|
|
spec: spec,
|
|
doorState: "closed",
|
|
tele: droneTelemetry{
|
|
flightMode: "STANDBY",
|
|
latitude: spec.DroneLat,
|
|
longitude: spec.DroneLon,
|
|
batteryPct: spec.Battery,
|
|
batteryV: 25.2,
|
|
satellites: 22,
|
|
gpsQuality: "RTK_FIX",
|
|
},
|
|
droneOnline: true,
|
|
}
|
|
if spec.Flying {
|
|
d.doorState = "open"
|
|
d.tele.armed = true
|
|
d.tele.flightMode = "AUTO"
|
|
d.tele.altitude = 58
|
|
d.tele.groundSpeed = 4.2
|
|
d.tele.heading = 120
|
|
}
|
|
return d
|
|
}
|
|
|
|
func (d *MockDock) start(broker, username, password string) error {
|
|
// 1. MQTT 连接后台 EMQX
|
|
opts := paho.NewClientOptions().
|
|
AddBroker(broker).
|
|
SetClientID("mock-dock-" + d.spec.DockID).
|
|
SetUsername(username).
|
|
SetPassword(password).
|
|
SetAutoReconnect(true).
|
|
SetCleanSession(true)
|
|
opts.SetOnConnectHandler(func(c paho.Client) { d.onMqttConnect(c) })
|
|
d.client = paho.NewClient(opts)
|
|
if tok := d.client.Connect(); tok.Wait() && tok.Error() != nil {
|
|
return fmt.Errorf("MQTT 连接失败: %w", tok.Error())
|
|
}
|
|
|
|
go d.publishLoop()
|
|
|
|
log.Printf("[%s] 机巢启动(MQTT 直接模拟无人机数据)", d.spec.DockID)
|
|
return nil
|
|
}
|
|
|
|
func (d *MockDock) topic(sub string) string {
|
|
return fmt.Sprintf("dock-edge/v1/dock/%s/%s", d.spec.DockID, sub)
|
|
}
|
|
|
|
// publish 上发一条不含 droneSn 的消息(外层统一包 envelope)
|
|
func (d *MockDock) publish(topic string, qos byte, retained bool, payload any) {
|
|
d.publishEnvelope(topic, qos, retained, "", "", payload)
|
|
}
|
|
|
|
// publishDrone 上发一条携带 droneSn 的消息
|
|
func (d *MockDock) publishDrone(topic string, qos byte, retained bool, droneSN string, payload any) {
|
|
d.publishEnvelope(topic, qos, retained, "", droneSN, payload)
|
|
}
|
|
|
|
func (d *MockDock) publishEnvelope(topic string, qos byte, retained bool, requestID, droneSN string, payload any) {
|
|
if d.client == nil || !d.client.IsConnected() {
|
|
return
|
|
}
|
|
b, err := json.Marshal(d.wrap(requestID, droneSN, payload))
|
|
if err != nil {
|
|
return
|
|
}
|
|
d.client.Publish(topic, qos, retained, b)
|
|
}
|
|
|
|
func (d *MockDock) publishVideoState(payload any, version int64) {
|
|
if d.client == nil || !d.client.IsConnected() {
|
|
return
|
|
}
|
|
b, err := json.Marshal(map[string]any{
|
|
"requestId": nil,
|
|
"eventId": fmt.Sprintf("video-%s-%d", d.spec.DockID, version),
|
|
"version": version,
|
|
"dockId": d.spec.DockID,
|
|
"droneSn": nil,
|
|
"timestamp": time.Now().UnixMilli(),
|
|
"payload": payload,
|
|
})
|
|
if err != nil {
|
|
return
|
|
}
|
|
d.client.Publish(d.topic("state/video"), 1, true, b)
|
|
}
|
|
|
|
// wrap 构造通用外层;requestId / droneSn 为空时序列化为 null
|
|
func (d *MockDock) wrap(requestID, droneSN string, payload any) map[string]any {
|
|
return map[string]any{
|
|
"requestId": nilIfEmpty(requestID),
|
|
"dockId": d.spec.DockID,
|
|
"droneSn": nilIfEmpty(droneSN),
|
|
"timestamp": time.Now().UnixMilli(),
|
|
"payload": payload,
|
|
}
|
|
}
|
|
|
|
func (d *MockDock) onMqttConnect(c paho.Client) {
|
|
log.Printf("[%s] MQTT 已连接", d.spec.DockID)
|
|
if tok := c.Subscribe(d.topic("command"), 1, d.onCommand); tok.Wait() && tok.Error() != nil {
|
|
log.Printf("[%s] 订阅指令失败: %v", d.spec.DockID, tok.Error())
|
|
}
|
|
d.publishStatusOnline()
|
|
d.publishStateDock()
|
|
d.publishStateDrone()
|
|
d.publishStateVideo()
|
|
}
|
|
|
|
func (d *MockDock) publishLoop() {
|
|
tick1 := time.NewTicker(time.Second)
|
|
tick5 := time.NewTicker(5 * time.Second)
|
|
defer tick1.Stop()
|
|
defer tick5.Stop()
|
|
for {
|
|
select {
|
|
case <-tick1.C:
|
|
d.publishTelemetry()
|
|
case <-tick5.C:
|
|
d.publishStateDrone()
|
|
d.publishStateDock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- 指令下发 ----
|
|
|
|
func (d *MockDock) onCommand(_ paho.Client, msg paho.Message) {
|
|
var env downlinkEnvelope
|
|
if err := json.Unmarshal(msg.Payload(), &env); err != nil {
|
|
log.Printf("[%s] 解析指令外层失败: %v", d.spec.DockID, err)
|
|
return
|
|
}
|
|
var cmd commandMsg
|
|
if err := json.Unmarshal(env.Payload, &cmd); err != nil {
|
|
log.Printf("[%s] 解析指令载荷失败: %v", d.spec.DockID, err)
|
|
return
|
|
}
|
|
cmd.RequestID = env.RequestID
|
|
cmd.DroneSN = env.DroneSN
|
|
log.Printf("[%s] 收到指令 %s (commandId=%s)", d.spec.DockID, cmd.Type, cmd.CommandID)
|
|
|
|
switch {
|
|
case strings.HasPrefix(cmd.Type, "dock."):
|
|
d.handleDockCommand(cmd)
|
|
case strings.HasPrefix(cmd.Type, "drone."):
|
|
d.handleDroneCommand(cmd)
|
|
case strings.HasPrefix(cmd.Type, "workflow."):
|
|
d.handleWorkflowCommand(cmd)
|
|
case strings.HasPrefix(cmd.Type, "video."):
|
|
d.handleVideoCommand(cmd)
|
|
default:
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
|
|
}
|
|
}
|
|
|
|
func (d *MockDock) handleDockCommand(cmd commandMsg) {
|
|
switch cmd.Type {
|
|
case "dock.open":
|
|
d.setDoor("open")
|
|
case "dock.close":
|
|
d.setDoor("closed")
|
|
case "dock.reset":
|
|
d.setDoor("closed")
|
|
case "dock.prepare_takeoff", "dock.complete_takeoff", "dock.prepare_landing", "dock.complete_landing",
|
|
"dock.charge_start", "dock.drone_power_on", "dock.drone_power_off",
|
|
"dock.centering_loose", "dock.centering_tight", "dock.clear_alarm", "dock.emergency_stop":
|
|
// 无状态变化,仅确认
|
|
default:
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
|
|
return
|
|
}
|
|
d.publishStateDock()
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
|
|
}
|
|
|
|
func (d *MockDock) handleDroneCommand(cmd commandMsg) {
|
|
switch cmd.Type {
|
|
case "drone.takeoff":
|
|
d.mu.Lock()
|
|
d.tele.armed = true
|
|
d.tele.flightMode = "AUTO"
|
|
d.tele.altitude = 50
|
|
d.tele.groundSpeed = 4.2
|
|
d.doorState = "open"
|
|
d.mu.Unlock()
|
|
case "drone.land", "drone.return":
|
|
if d.cancelMission() {
|
|
// 任务协程收到取消后会自行返航落地
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
|
|
return
|
|
}
|
|
d.snapHomeLanded()
|
|
case "drone.mission_upload":
|
|
wps := parseWaypoints(cmd.Params)
|
|
d.mu.Lock()
|
|
d.uploadedRoute = wps
|
|
d.mu.Unlock()
|
|
log.Printf("[%s] 已缓存航线,航点数=%d", d.spec.DockID, len(wps))
|
|
case "drone.mission_start":
|
|
d.mu.Lock()
|
|
wps := append([]missionWaypoint(nil), d.uploadedRoute...)
|
|
d.mu.Unlock()
|
|
if len(wps) == 0 {
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "NO_ROUTE")
|
|
return
|
|
}
|
|
if !d.beginMission(cmd.CommandID) {
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "MISSION_IN_PROGRESS")
|
|
return
|
|
}
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
|
|
go d.flyUploadedMission(wps)
|
|
return
|
|
case "drone.mission_pause":
|
|
d.setMissionPaused(true)
|
|
case "drone.mission_resume":
|
|
d.setMissionPaused(false)
|
|
case "drone.mission_cancel":
|
|
d.cancelMission()
|
|
default:
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
|
|
return
|
|
}
|
|
d.publishStateDrone()
|
|
d.publishStateDock()
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
|
|
}
|
|
|
|
func (d *MockDock) handleVideoCommand(cmd commandMsg) {
|
|
streamSessionID, _ := cmd.Params["streamSessionId"].(string)
|
|
switch cmd.Type {
|
|
case "video.start_stream":
|
|
if streamSessionID == "" {
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "SESSION_ID_REQUIRED")
|
|
return
|
|
}
|
|
d.mu.Lock()
|
|
if d.liveStreaming && d.liveSessionID != streamSessionID {
|
|
d.mu.Unlock()
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "STREAM_IN_PROGRESS")
|
|
return
|
|
}
|
|
d.liveSessionID = streamSessionID
|
|
d.liveStreaming = true
|
|
d.mu.Unlock()
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
|
|
d.publishStateVideo()
|
|
case "video.stop_stream":
|
|
d.mu.Lock()
|
|
matches := streamSessionID != "" && streamSessionID == d.liveSessionID
|
|
if matches {
|
|
d.liveStreaming = false
|
|
}
|
|
d.mu.Unlock()
|
|
if !matches {
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "SESSION_NOT_FOUND")
|
|
return
|
|
}
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
|
|
d.publishStateVideo()
|
|
default:
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "UNSUPPORTED_COMMAND")
|
|
}
|
|
}
|
|
|
|
func (d *MockDock) handleWorkflowCommand(cmd commandMsg) {
|
|
switch cmd.Type {
|
|
case "workflow.cancel", "workflow.stop_task":
|
|
d.cancelMission()
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
|
|
return
|
|
case "workflow.one_key_return", "workflow.one_key_landing":
|
|
if !d.cancelMission() {
|
|
go d.rtlAndLand()
|
|
}
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
|
|
return
|
|
}
|
|
|
|
if d.isRepeatCommand(cmd.CommandID) {
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
|
|
return
|
|
}
|
|
if !d.beginMission(cmd.CommandID) {
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, false, "MISSION_IN_PROGRESS")
|
|
return
|
|
}
|
|
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, true, "OK")
|
|
|
|
switch cmd.Type {
|
|
case "workflow.start_task":
|
|
go d.runStartTask(cmd)
|
|
case "workflow.one_key_takeoff":
|
|
go d.runOneKeyTakeoff(cmd)
|
|
default:
|
|
go d.runGenericWorkflow(cmd)
|
|
}
|
|
}
|
|
|
|
func (d *MockDock) setDoor(state string) {
|
|
d.mu.Lock()
|
|
d.doorState = state
|
|
d.mu.Unlock()
|
|
}
|
|
|
|
// ---- 上发各类消息 ----
|
|
|
|
func (d *MockDock) ack(requestID, droneSN, commandID string, accepted bool, resultCode string) {
|
|
d.publishEnvelope(d.topic("command/ack"), 1, false, requestID, droneSN, map[string]any{
|
|
"commandId": commandID,
|
|
"accepted": accepted,
|
|
"resultCode": resultCode,
|
|
})
|
|
}
|
|
|
|
func (d *MockDock) publishStatusOnline() {
|
|
d.publish(d.topic("status/online"), 1, true, map[string]any{
|
|
"status": "online",
|
|
"bootId": "boot-" + d.spec.DockID,
|
|
"dockIdSource": "dmi_product_serial",
|
|
"softwareVersion": "1.3.0",
|
|
"protocolVersion": "1.0",
|
|
"uptimeSec": 86400,
|
|
"timeSynced": true,
|
|
"mqttConnected": true,
|
|
"modbusConnected": true,
|
|
"mavlinkConnected": false,
|
|
"updating": false,
|
|
"name": d.spec.Name,
|
|
"location": d.spec.Location,
|
|
"latitude": d.spec.Latitude,
|
|
"longitude": d.spec.Longitude,
|
|
})
|
|
}
|
|
|
|
func (d *MockDock) publishStateDock() {
|
|
d.mu.Lock()
|
|
door := d.doorState
|
|
flying := d.tele.armed || d.tele.altitude > 0
|
|
d.mu.Unlock()
|
|
|
|
chargingState := "charging"
|
|
chargingVoltage := 25.2
|
|
chargingCurrent := 8.4
|
|
dronePresent := true
|
|
if flying {
|
|
chargingState = "idle"
|
|
chargingVoltage = 0
|
|
chargingCurrent = 0
|
|
dronePresent = false
|
|
door = "open"
|
|
}
|
|
|
|
alarms := []string{}
|
|
emergency := false
|
|
if d.spec.Alarm {
|
|
alarms = []string{"DOCK_EMERGENCY_STOP"}
|
|
emergency = true
|
|
}
|
|
|
|
d.publish(d.topic("state/dock"), 1, true, map[string]any{
|
|
"plcConnected": true,
|
|
"controlMode": "auto",
|
|
"door": map[string]any{"left": door, "right": door},
|
|
"centering": map[string]any{"leftRight": "loose", "frontBack": "loose"},
|
|
"chargingState": chargingState,
|
|
"chargingVoltage": chargingVoltage,
|
|
"chargingCurrent": chargingCurrent,
|
|
"emergencyStop": emergency,
|
|
"alarmCodes": alarms,
|
|
"chargeStartComplete": chargingState == "charging",
|
|
"dronePowerOnComplete": dronePresent,
|
|
"dronePowerOffComplete": false,
|
|
"dronePresent": dronePresent,
|
|
"takeoffPreparationComplete": false,
|
|
"hangarActionComplete": false,
|
|
"landingPreparationComplete": false,
|
|
"landingExecutionComplete": false,
|
|
"doorCloseComplete": door == "closed",
|
|
"doorOpenComplete": door == "open",
|
|
"centeringLooseComplete": false,
|
|
"centeringTightComplete": false,
|
|
"resetComplete": true,
|
|
"environment": dockEnvironment(d.spec.DockID),
|
|
})
|
|
}
|
|
|
|
func dockEnvironment(dockID string) map[string]any {
|
|
switch dockID {
|
|
case "dock-2":
|
|
return map[string]any{
|
|
"rain": false, "windSpeed": 3.6, "outsideTemperature": 22.4,
|
|
"outsideHumidity": 61.0, "insideTemperature": 26.1, "insideHumidity": 48.0,
|
|
}
|
|
case "dock-3":
|
|
return map[string]any{
|
|
"rain": true, "windSpeed": 5.1, "outsideTemperature": 19.8,
|
|
"outsideHumidity": 78.0, "insideTemperature": 24.6, "insideHumidity": 62.0,
|
|
}
|
|
default:
|
|
return map[string]any{
|
|
"rain": false, "windSpeed": 1.8, "outsideTemperature": 25.1,
|
|
"outsideHumidity": 55.0, "insideTemperature": 27.3, "insideHumidity": 50.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *MockDock) publishStateDrone() {
|
|
d.mu.Lock()
|
|
t := d.tele
|
|
online := d.droneOnline
|
|
d.mu.Unlock()
|
|
|
|
d.publishDrone(d.topic("state/drone"), 1, true, d.spec.DroneSN, map[string]any{
|
|
"droneSn": d.spec.DroneSN,
|
|
"name": d.spec.DroneName,
|
|
"currentSysId": 1,
|
|
"online": online,
|
|
"armed": t.armed,
|
|
"flightMode": t.flightMode,
|
|
"flightModeCode": 11,
|
|
"latitude": t.latitude,
|
|
"longitude": t.longitude,
|
|
"altitude": t.altitude,
|
|
"groundSpeed": t.groundSpeed,
|
|
"roll": t.roll,
|
|
"pitch": t.pitch,
|
|
"yaw": t.yaw,
|
|
"batteryPercent": float64(t.batteryPct),
|
|
"batteryVoltage": t.batteryV,
|
|
"batteryCurrent": 0.0,
|
|
"satellites": t.satellites,
|
|
"gpsQuality": t.gpsQuality,
|
|
"linkQuality": 98.0,
|
|
"homeSet": true,
|
|
"alarmCodes": []string{},
|
|
})
|
|
}
|
|
|
|
func (d *MockDock) publishTelemetry() {
|
|
d.mu.Lock()
|
|
t := d.tele
|
|
d.mu.Unlock()
|
|
|
|
d.publishDrone(d.topic("telemetry"), 0, false, d.spec.DroneSN, map[string]any{
|
|
"ts": time.Now().UnixMilli(),
|
|
"longitude": t.longitude,
|
|
"latitude": t.latitude,
|
|
"altitude": t.altitude,
|
|
"groundSpeed": t.groundSpeed,
|
|
"roll": t.roll,
|
|
"pitch": t.pitch,
|
|
"yaw": t.yaw,
|
|
"batteryPct": t.batteryPct,
|
|
"batteryV": t.batteryV,
|
|
"satellites": t.satellites,
|
|
"gpsQuality": t.gpsQuality,
|
|
"linkQuality": 96,
|
|
"flightMode": t.flightMode,
|
|
"armed": boolToInt(t.armed),
|
|
})
|
|
}
|
|
|
|
func (d *MockDock) publishStateVideo() {
|
|
d.mu.Lock()
|
|
streamSessionID := d.liveSessionID
|
|
streaming := d.liveStreaming
|
|
d.videoVersion++
|
|
version := d.videoVersion
|
|
d.mu.Unlock()
|
|
|
|
phase := "idle"
|
|
if streaming {
|
|
phase = "streaming"
|
|
}
|
|
provider := "fake"
|
|
d.publishVideoState(map[string]any{
|
|
"provider": provider,
|
|
"phase": phase,
|
|
"inputOnline": streaming,
|
|
"streaming": streaming,
|
|
"inputCodec": "h264",
|
|
"uplinkProtocol": "srt",
|
|
"streamSessionId": nilIfEmpty(streamSessionID),
|
|
"width": nil,
|
|
"height": nil,
|
|
"frameRate": nil,
|
|
"bitrateBps": liveBitrate(streaming),
|
|
"retryCount": 0,
|
|
"stopReason": nil,
|
|
"errorCode": nil,
|
|
"updatedAt": time.Now().UnixMilli(),
|
|
}, version)
|
|
}
|
|
|
|
func liveBitrate(streaming bool) int {
|
|
if streaming {
|
|
return 1500000
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (d *MockDock) publishWorkflow(cmd commandMsg, taskID, missionID, state, step string) {
|
|
resultCode := any(nil)
|
|
switch state {
|
|
case "succeeded":
|
|
resultCode = "OK"
|
|
case "cancelled":
|
|
resultCode = "CANCELLED"
|
|
case "failed":
|
|
resultCode = "TIMEOUT"
|
|
}
|
|
recordOriginalVideo := any(nil)
|
|
if cmd.Type == "workflow.start_task" {
|
|
recordOriginalVideo = asBool(cmd.Params["recordOriginalVideo"])
|
|
}
|
|
d.publishEnvelope(d.topic("state/workflow"), 1, true, cmd.RequestID, "", map[string]any{
|
|
"commandId": cmd.CommandID,
|
|
"type": cmd.Type,
|
|
"taskId": nilIfEmpty(taskID),
|
|
"missionId": nilIfEmpty(missionID),
|
|
"recordOriginalVideo": recordOriginalVideo,
|
|
"state": state,
|
|
"step": step,
|
|
"resultCode": resultCode,
|
|
"updatedAt": time.Now().UnixMilli(),
|
|
})
|
|
}
|
|
|
|
// ---- 工具函数 ----
|
|
|
|
func boolToInt(b bool) int8 {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func nilIfEmpty(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
func flightModeFromBaseMode(base string) string {
|
|
return base
|
|
}
|
|
|
|
func gpsQualityFromFixType(fix int) string {
|
|
switch fix {
|
|
case 6:
|
|
return "RTK_FIX"
|
|
case 5:
|
|
return "RTK_FLOAT"
|
|
case 3:
|
|
return "3D_FIX"
|
|
case 2:
|
|
return "2D_FIX"
|
|
default:
|
|
return "NO_FIX"
|
|
}
|
|
}
|
|
|