@ -1,9 +1,12 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"strings"
"sync"
"time"
@ -51,6 +54,9 @@ type droneTelemetry struct {
// MockDock 模拟机巢:通过 MQTT 上云并直接上报机巢、无人机状态
type MockDock struct {
spec dockSpec
clientIDPrefix string
videoFile string
bootID string
client paho . Client
@ -65,12 +71,32 @@ type MockDock struct {
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
}
func newMockDock ( spec dockSpec ) * MockDock {
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" ,
@ -96,25 +122,63 @@ func newMockDock(spec dockSpec) *MockDock {
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 ( "mock-dock-" + d . spec . DockID ) .
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 )
}
@ -146,7 +210,7 @@ func (d *MockDock) publishVideoState(payload any, version int64) {
}
b , err := json . Marshal ( map [ string ] any {
"requestId" : nil ,
"eventId" : fmt . Sprintf ( "video-%s-%d" , d . spec . DockID , version ) ,
"eventId" : fmt . Sprintf ( "video-%s-%s-% d" , d . spec . DockID , d . boot ID , version ) ,
"version" : version ,
"dockId" : d . spec . DockID ,
"droneSn" : nil ,
@ -186,8 +250,11 @@ func (d *MockDock) publishLoop() {
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 :
@ -215,6 +282,9 @@ func (d *MockDock) onCommand(_ paho.Client, msg paho.Message) {
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." ) :
@ -226,7 +296,7 @@ func (d *MockDock) onCommand(_ paho.Client, msg paho.Message) {
case strings . HasPrefix ( cmd . Type , "video." ) :
d . handleVideoCommand ( cmd )
default :
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , false , "UNSUPPORTED_COMMAND" )
d . ackCommand ( cmd , false , "UNSUPPORTED_COMMAND" )
}
}
@ -243,11 +313,11 @@ func (d *MockDock) handleDockCommand(cmd commandMsg) {
"dock.centering_loose" , "dock.centering_tight" , "dock.clear_alarm" , "dock.emergency_stop" :
// 无状态变化,仅确认
default :
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , false , "UNSUPPORTED_COMMAND" )
d . ackCommand ( cmd , false , "UNSUPPORTED_COMMAND" )
return
}
d . publishStateDock ( )
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , true , "OK" )
d . ackCommand ( cmd , true , "OK" )
}
func ( d * MockDock ) handleDroneCommand ( cmd commandMsg ) {
@ -263,7 +333,7 @@ func (d *MockDock) handleDroneCommand(cmd commandMsg) {
case "drone.land" , "drone.return" :
if d . cancelMission ( ) {
// 任务协程收到取消后会自行返航落地
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , true , "OK" )
d . ackCommand ( cmd , true , "OK" )
return
}
d . snapHomeLanded ( )
@ -278,14 +348,14 @@ func (d *MockDock) handleDroneCommand(cmd commandMsg) {
wps := append ( [ ] missionWaypoint ( nil ) , d . uploadedRoute ... )
d . mu . Unlock ( )
if len ( wps ) == 0 {
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , false , "NO_ROUTE" )
d . ackCommand ( cmd , false , "NO_ROUTE" )
return
}
if ! d . beginMission ( cmd . CommandID ) {
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , false , "MISSION_IN_PROGRESS" )
d . ackCommand ( cmd , false , "MISSION_IN_PROGRESS" )
return
}
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , true , "OK" )
d . ackCommand ( cmd , true , "OK" )
go d . flyUploadedMission ( wps )
return
case "drone.mission_pause" :
@ -295,12 +365,12 @@ func (d *MockDock) handleDroneCommand(cmd commandMsg) {
case "drone.mission_cancel" :
d . cancelMission ( )
default :
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , false , "UNSUPPORTED_COMMAND" )
d . ackCommand ( cmd , false , "UNSUPPORTED_COMMAND" )
return
}
d . publishStateDrone ( )
d . publishStateDock ( )
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , true , "OK" )
d . ackCommand ( cmd , true , "OK" )
}
func ( d * MockDock ) handleVideoCommand ( cmd commandMsg ) {
@ -308,35 +378,179 @@ func (d *MockDock) handleVideoCommand(cmd commandMsg) {
switch cmd . Type {
case "video.start_stream" :
if streamSessionID == "" {
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , false , "SESSION_ID_REQUIRED" )
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 . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , false , "STREAM_IN_PROGRESS" )
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 . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , true , "OK" )
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 . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , false , "SESSION_NOT_FOUND" )
d . ackCommand ( cmd , false , "SESSION_NOT_FOUND" )
return
}
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , true , "OK" )
d . stopLivePublisher ( )
d . ackCommand ( cmd , true , "OK" )
d . publishStateVideo ( )
default :
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , false , "UNSUPPORTED_COMMAND" )
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 ""
}
}
@ -344,25 +558,25 @@ 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" )
d . ackCommand ( cmd , 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" )
d . ackCommand ( cmd , true , "OK" )
return
}
if d . isRepeatCommand ( cmd . CommandID ) {
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , true , "OK" )
d . ackCommand ( cmd , true , "OK" )
return
}
if ! d . beginMission ( cmd . CommandID ) {
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , false , "MISSION_IN_PROGRESS" )
d . ackCommand ( cmd , false , "MISSION_IN_PROGRESS" )
return
}
d . ack ( cmd . RequestID , cmd . DroneSN , cmd . CommandID , true , "OK" )
d . ackCommand ( cmd , true , "OK" )
switch cmd . Type {
case "workflow.start_task" :
@ -390,17 +604,67 @@ func (d *MockDock) ack(requestID, droneSN, commandID string, accepted bool, resu
} )
}
// 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" : "online" ,
"bootId" : "boot-" + d . spec . DockID ,
"status" : status ,
"bootId" : d . boot ID,
"dockIdSource" : "dmi_product_serial" ,
"softwareVersion" : "1.3.0" ,
"protocolVersion" : "1.0" ,
"uptimeSec" : 86400 ,
"timeSynced" : true ,
"mqttConnected" : true ,
"modbusConnected" : true ,
"mqttConnected" : onlin e,
"modbusConnected" : onlin e,
"mavlinkConnected" : false ,
"updating" : false ,
"name" : d . spec . Name ,
@ -542,6 +806,11 @@ 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 ( )
@ -550,28 +819,36 @@ func (d *MockDock) publishStateVideo() {
if streaming {
phase = "streaming"
}
provider := "fake"
if provider == "" {
provider = "fake"
}
if protocol == "" {
protocol = "srt"
}
d . publishVideoState ( map [ string ] any {
"provider" : provider ,
"phase" : phase ,
"inputOnline" : streaming ,
"streaming" : streaming ,
"inputCodec" : "h264" ,
"uplinkProtocol" : "srt" ,
"uplinkProtocol" : protocol ,
"streamSessionId" : nilIfEmpty ( streamSessionID ) ,
"width" : nil ,
"height" : nil ,
"frameRate" : nil ,
"bitrateBps" : liveBitrate ( streaming ) ,
"bitrateBps" : liveBitrate ( streaming , maxBitrate ) ,
"retryCount" : 0 ,
"stopReason" : nil ,
"errorCode" : nil ,
"stopReason" : nilIfEmpty ( stopReason ) ,
"errorCode" : nilIfEmpty ( errorCode ) ,
"updatedAt" : time . Now ( ) . UnixMilli ( ) ,
} , version )
}
func liveBitrate ( streaming bool ) int {
func liveBitrate ( streaming bool , maxBitrateBps int64 ) int64 {
if streaming {
if maxBitrateBps > 0 {
return maxBitrateBps
}
return 1500000
}
return 0