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