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

917 lines
24 KiB

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"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
clientIDPrefix string
videoFile string
bootID string
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
liveProvider string
liveProtocol string
liveMaxBitrateBps int64
liveError string
liveStopReason string
liveCancel context.CancelFunc
liveProcess *exec.Cmd
commandResults map[string]commandResult
commandPending map[string]chan struct{}
publishStop chan struct{}
publishDone chan struct{}
stopOnce sync.Once
videoVersion int64
}
type commandResult struct {
accepted bool
resultCode string
}
func newMockDock(spec dockSpec, clientIDPrefix, videoFile string) *MockDock {
d := &MockDock{
spec: spec,
clientIDPrefix: clientIDPrefix,
videoFile: videoFile,
bootID: fmt.Sprintf("boot-%s-%d", spec.DockID, time.Now().UnixNano()),
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
offline, err := json.Marshal(d.wrap("", "", map[string]any{
"status": "offline",
"bootId": d.bootID,
"dockIdSource": "dmi_product_serial",
"softwareVersion": "1.3.0",
"protocolVersion": "1.0",
"timeSynced": true,
"mqttConnected": false,
"modbusConnected": false,
"mavlinkConnected": false,
"updating": false,
}))
if err != nil {
return err
}
opts := paho.NewClientOptions().
AddBroker(broker).
SetClientID(d.clientIDPrefix + d.spec.DockID).
SetUsername(username).
SetPassword(password).
SetAutoReconnect(true).
SetCleanSession(true)
opts.SetWill(d.topic("status/online"), string(offline), 1, 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())
}
d.publishStop = make(chan struct{})
d.publishDone = make(chan struct{})
go d.publishLoop()
log.Printf("[%s] 机巢启动(MQTT 直接模拟无人机数据)", d.spec.DockID)
return nil
}
// stop publishes an orderly offline status, stops any media publisher, and disconnects MQTT.
func (d *MockDock) stop() {
d.stopOnce.Do(func() {
if d.publishStop != nil {
close(d.publishStop)
}
})
d.publishStatus("offline")
d.stopLivePublisher()
if d.publishDone != nil {
select {
case <-d.publishDone:
case <-time.After(time.Second):
}
}
if d.client != nil && d.client.IsConnected() {
d.client.Disconnect(250)
}
}
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-%s-%d", d.spec.DockID, d.bootID, 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()
defer close(d.publishDone)
for {
select {
case <-d.publishStop:
return
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)
if d.replayCommand(cmd) {
return
}
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.ackCommand(cmd, 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.ackCommand(cmd, false, "UNSUPPORTED_COMMAND")
return
}
d.publishStateDock()
d.ackCommand(cmd, 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.ackCommand(cmd, 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.ackCommand(cmd, false, "NO_ROUTE")
return
}
if !d.beginMission(cmd.CommandID) {
d.ackCommand(cmd, false, "MISSION_IN_PROGRESS")
return
}
d.ackCommand(cmd, 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.ackCommand(cmd, false, "UNSUPPORTED_COMMAND")
return
}
d.publishStateDrone()
d.publishStateDock()
d.ackCommand(cmd, true, "OK")
}
func (d *MockDock) handleVideoCommand(cmd commandMsg) {
streamSessionID, _ := cmd.Params["streamSessionId"].(string)
switch cmd.Type {
case "video.start_stream":
if streamSessionID == "" {
d.ackCommand(cmd, false, "SESSION_ID_REQUIRED")
return
}
pushURL, _ := cmd.Params["pushUrl"].(string)
provider, _ := cmd.Params["provider"].(string)
maxBitrate := asInt64(cmd.Params["maxBitrateBps"])
if maxBitrate <= 0 {
maxBitrate = 1500000
}
d.mu.Lock()
if d.liveStreaming && d.liveSessionID != streamSessionID {
d.mu.Unlock()
d.ackCommand(cmd, false, "STREAM_IN_PROGRESS")
return
}
d.liveSessionID = streamSessionID
d.liveStreaming = true
d.liveProvider = provider
d.liveProtocol = pushProtocol(pushURL)
d.liveMaxBitrateBps = maxBitrate
d.liveStopReason = ""
d.liveError = ""
d.mu.Unlock()
if err := d.startLivePublisher(pushURL); err != nil {
d.mu.Lock()
d.liveStreaming = false
d.liveError = "PUBLISHER_START_FAILED"
d.mu.Unlock()
d.ackCommand(cmd, false, "PUBLISHER_START_FAILED")
d.publishStateVideo()
return
}
d.ackCommand(cmd, true, "OK")
d.publishStateVideo()
case "video.stop_stream":
d.mu.Lock()
matches := streamSessionID != "" && streamSessionID == d.liveSessionID
if matches {
d.liveStreaming = false
d.liveStopReason, _ = cmd.Params["reason"].(string)
if d.liveStopReason == "" {
d.liveStopReason = "device_request"
}
}
d.mu.Unlock()
if !matches {
d.ackCommand(cmd, false, "SESSION_NOT_FOUND")
return
}
d.stopLivePublisher()
d.ackCommand(cmd, true, "OK")
d.publishStateVideo()
default:
d.ackCommand(cmd, false, "UNSUPPORTED_COMMAND")
}
}
// startLivePublisher starts ffmpeg for real SRT/RTMP(S) push URLs. Fake URLs
// intentionally keep the control-plane simulation without requiring ffmpeg.
func (d *MockDock) startLivePublisher(pushURL string) error {
if strings.HasPrefix(strings.ToLower(pushURL), "fake://") || pushURL == "" {
return nil
}
if !isSupportedPushURL(pushURL) {
return fmt.Errorf("unsupported push URL scheme: %s", pushURL)
}
if d.videoFile == "" {
return fmt.Errorf("video file is not configured")
}
if _, err := os.Stat(d.videoFile); err != nil {
return fmt.Errorf("video file unavailable: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
ffmpeg := envOr("MOCK_FFMPEG_BIN", "ffmpeg")
d.mu.Lock()
maxBitrate := d.liveMaxBitrateBps
if d.liveProcess != nil {
d.mu.Unlock()
cancel()
return nil
}
d.mu.Unlock()
cmd := exec.CommandContext(ctx, ffmpeg, ffmpegArgs(d.videoFile, pushURL, maxBitrate)...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
d.mu.Lock()
if d.liveProcess != nil {
d.mu.Unlock()
cancel()
return nil
}
d.liveCancel = cancel
d.liveProcess = cmd
d.mu.Unlock()
if err := cmd.Start(); err != nil {
cancel()
d.mu.Lock()
d.liveCancel = nil
d.liveProcess = nil
d.mu.Unlock()
return err
}
go d.waitLivePublisher(cmd)
return nil
}
func (d *MockDock) waitLivePublisher(cmd *exec.Cmd) {
err := cmd.Wait()
d.mu.Lock()
if d.liveProcess != cmd {
d.mu.Unlock()
return
}
d.liveProcess = nil
d.liveCancel = nil
wasStreaming := d.liveStreaming
d.liveStreaming = false
if err != nil {
d.liveError = "PUBLISHER_EXITED"
}
d.mu.Unlock()
if wasStreaming {
d.publishStateVideo()
}
}
func (d *MockDock) stopLivePublisher() {
d.mu.Lock()
cancel := d.liveCancel
d.liveCancel = nil
d.liveProcess = nil
d.mu.Unlock()
if cancel != nil {
cancel()
}
}
func isSupportedPushURL(pushURL string) bool {
url := strings.ToLower(pushURL)
return strings.HasPrefix(url, "srt://") || strings.HasPrefix(url, "rtmp://") || strings.HasPrefix(url, "rtmps://")
}
func ffmpegArgs(videoFile, pushURL string, maxBitrateBps int64) []string {
if maxBitrateBps <= 0 {
maxBitrateBps = 1500000
}
bitrate := fmt.Sprintf("%d", maxBitrateBps)
bufsize := fmt.Sprintf("%d", maxBitrateBps*2)
args := []string{"-hide_banner", "-loglevel", "warning", "-re", "-stream_loop", "-1", "-i", videoFile,
"-map", "0:v:0", "-map", "0:a:0?",
"-c:v", "libx264", "-preset", "veryfast", "-tune", "zerolatency", "-pix_fmt", "yuv420p",
"-b:v", bitrate, "-maxrate", bitrate, "-bufsize", bufsize, "-g", "60",
"-c:a", "aac", "-b:a", "128k",
}
if strings.HasPrefix(strings.ToLower(pushURL), "srt://") {
return append(args, "-f", "mpegts", pushURL)
}
return append(args, "-f", "flv", pushURL)
}
func pushProtocol(pushURL string) string {
url := strings.ToLower(pushURL)
switch {
case strings.HasPrefix(url, "srt://"):
return "srt"
case strings.HasPrefix(url, "rtmp://"):
return "rtmp"
case strings.HasPrefix(url, "rtmps://"):
return "rtmps"
case strings.HasPrefix(url, "fake://"):
return "fake"
default:
return ""
}
}
func (d *MockDock) handleWorkflowCommand(cmd commandMsg) {
switch cmd.Type {
case "workflow.cancel", "workflow.stop_task":
d.cancelMission()
d.ackCommand(cmd, true, "OK")
return
case "workflow.one_key_return", "workflow.one_key_landing":
if !d.cancelMission() {
go d.rtlAndLand()
}
d.ackCommand(cmd, true, "OK")
return
}
if d.isRepeatCommand(cmd.CommandID) {
d.ackCommand(cmd, true, "OK")
return
}
if !d.beginMission(cmd.CommandID) {
d.ackCommand(cmd, false, "MISSION_IN_PROGRESS")
return
}
d.ackCommand(cmd, 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,
})
}
// replayCommand implements the commandId idempotency expected from an edge device.
// Retries carry a new requestId, so the original result is acknowledged with the
// current requestId without executing the command a second time. Concurrent
// duplicates wait for the first execution to publish its result.
func (d *MockDock) replayCommand(cmd commandMsg) bool {
if cmd.CommandID == "" {
return false
}
d.mu.Lock()
result, ok := d.commandResults[cmd.CommandID]
if !ok {
if pending, exists := d.commandPending[cmd.CommandID]; exists {
d.mu.Unlock()
<-pending
return d.replayCommand(cmd)
}
if d.commandPending == nil {
d.commandPending = make(map[string]chan struct{})
}
d.commandPending[cmd.CommandID] = make(chan struct{})
}
d.mu.Unlock()
if !ok {
return false
}
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, result.accepted, result.resultCode)
return true
}
func (d *MockDock) ackCommand(cmd commandMsg, accepted bool, resultCode string) {
if cmd.CommandID != "" {
d.mu.Lock()
if d.commandResults == nil {
d.commandResults = make(map[string]commandResult)
}
d.commandResults[cmd.CommandID] = commandResult{accepted: accepted, resultCode: resultCode}
if pending, ok := d.commandPending[cmd.CommandID]; ok {
delete(d.commandPending, cmd.CommandID)
close(pending)
}
d.mu.Unlock()
}
d.ack(cmd.RequestID, cmd.DroneSN, cmd.CommandID, accepted, resultCode)
}
func (d *MockDock) publishStatusOnline() {
d.publishStatus("online")
}
func (d *MockDock) publishStatus(status string) {
online := status == "online"
d.publish(d.topic("status/online"), 1, true, map[string]any{
"status": status,
"bootId": d.bootID,
"dockIdSource": "dmi_product_serial",
"softwareVersion": "1.3.0",
"protocolVersion": "1.0",
"uptimeSec": 86400,
"timeSynced": true,
"mqttConnected": online,
"modbusConnected": online,
"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
provider := d.liveProvider
protocol := d.liveProtocol
maxBitrate := d.liveMaxBitrateBps
errorCode := d.liveError
stopReason := d.liveStopReason
d.videoVersion++
version := d.videoVersion
d.mu.Unlock()
phase := "idle"
if streaming {
phase = "streaming"
}
if provider == "" {
provider = "fake"
}
if protocol == "" {
protocol = "srt"
}
d.publishVideoState(map[string]any{
"provider": provider,
"phase": phase,
"inputOnline": streaming,
"streaming": streaming,
"inputCodec": "h264",
"uplinkProtocol": protocol,
"streamSessionId": nilIfEmpty(streamSessionID),
"width": nil,
"height": nil,
"frameRate": nil,
"bitrateBps": liveBitrate(streaming, maxBitrate),
"retryCount": 0,
"stopReason": nilIfEmpty(stopReason),
"errorCode": nilIfEmpty(errorCode),
"updatedAt": time.Now().UnixMilli(),
}, version)
}
func liveBitrate(streaming bool, maxBitrateBps int64) int64 {
if streaming {
if maxBitrateBps > 0 {
return maxBitrateBps
}
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"
}
}