低空智控平台 后端go
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.
 
 

640 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
if env.DroneSN != nil {
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"
}
}