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" } }