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

705 lines
16 KiB

package main
import (
"bytes"
"io"
"log"
"math"
"net/http"
"sort"
"strconv"
"time"
)
const (
missionTick = 200 * time.Millisecond
missionTimeScale = 10.0 // mock 加速倍数,便于在短时间内跑完航线
defaultCruiseSpeed = 8.0 // m/s
minLegDuration = 2 * time.Second
maxLegDuration = 12 * time.Second
maxHoldSec = 8
defaultTakeoffAlt = 50.0
)
// missionWaypoint 与后台 workflow.start_task params.waypoints 字段对齐
type missionWaypoint struct {
Seq int
Longitude float64
Latitude float64
Altitude float64
Speed float64
Yaw float64
HoldSec int
}
func (d *MockDock) beginMission(commandID string) bool {
d.mu.Lock()
defer d.mu.Unlock()
if d.inMission {
return false
}
d.inMission = true
d.missionPaused = false
d.missionCmdID = commandID
d.missionCancel = make(chan struct{})
return true
}
func (d *MockDock) endMission() {
d.mu.Lock()
defer d.mu.Unlock()
d.inMission = false
d.missionPaused = false
d.missionCmdID = ""
d.missionCancel = nil
}
func (d *MockDock) isRepeatCommand(commandID string) bool {
d.mu.Lock()
defer d.mu.Unlock()
return commandID != "" && d.inMission && d.missionCmdID == commandID
}
// cancelMission 通知当前任务中止,返回是否有正在执行的任务
func (d *MockDock) cancelMission() bool {
d.mu.Lock()
defer d.mu.Unlock()
if !d.inMission || d.missionCancel == nil {
return false
}
select {
case <-d.missionCancel:
default:
close(d.missionCancel)
}
d.missionPaused = false
return true
}
func (d *MockDock) setMissionPaused(paused bool) {
d.mu.Lock()
defer d.mu.Unlock()
if d.inMission {
d.missionPaused = paused
}
}
func (d *MockDock) currentCancel() <-chan struct{} {
d.mu.Lock()
defer d.mu.Unlock()
return d.missionCancel
}
func (d *MockDock) currentLLA() (lat, lon, alt float64) {
d.mu.Lock()
defer d.mu.Unlock()
return d.tele.latitude, d.tele.longitude, d.tele.altitude
}
func (d *MockDock) setPose(lat, lon, alt, speed, heading float64) {
d.mu.Lock()
d.tele.latitude = lat
d.tele.longitude = lon
d.tele.altitude = alt
d.tele.groundSpeed = speed
d.tele.heading = heading
d.tele.yaw = heading
if d.tele.batteryPct > 20 && speed > 0.5 {
d.tele.batteryPctDrain++
if d.tele.batteryPctDrain >= 20 {
d.tele.batteryPctDrain = 0
d.tele.batteryPct--
d.tele.batteryV = 22.0 + float64(d.tele.batteryPct)*0.037
}
}
d.mu.Unlock()
}
func (d *MockDock) setArmedFlying(alt float64) {
d.mu.Lock()
d.tele.armed = true
d.tele.flightMode = "AUTO"
d.tele.altitude = alt
d.doorState = "open"
d.mu.Unlock()
}
func (d *MockDock) snapHomeLanded() {
d.mu.Lock()
d.tele.armed = false
d.tele.flightMode = "STANDBY"
d.tele.groundSpeed = 0
d.tele.altitude = 0
d.tele.latitude = d.spec.DroneLat
d.tele.longitude = d.spec.DroneLon
d.tele.heading = 0
d.tele.yaw = 0
d.doorState = "closed"
d.mu.Unlock()
}
// runStartTask 执行 workflow.start_task:按航点飞行并持续改写遥测坐标
func (d *MockDock) runStartTask(cmd commandMsg) {
defer d.endMission()
taskID, missionID := extractTaskIDs(cmd)
waypoints := parseWaypoints(cmd.Params)
recordOriginalVideo := asBool(cmd.Params["recordOriginalVideo"])
originalVideo := parseOriginalVideo(cmd.Params["originalVideo"])
d.mu.Lock()
d.uploadedRoute = waypoints
d.mu.Unlock()
log.Printf("[%s] 开始执行任务 taskId=%s 航点数=%d", d.spec.DockID, taskID, len(waypoints))
for _, wp := range waypoints {
log.Printf("[%s] 航点 seq=%d lon=%.6f lat=%.6f alt=%.1f speed=%.1f hold=%ds",
d.spec.DockID, wp.Seq, wp.Longitude, wp.Latitude, wp.Altitude, wp.Speed, wp.HoldSec)
}
d.setDoor("open")
d.publishStateDock()
prep := []string{
"preparing_dock_takeoff",
"waiting_drone_online",
"checking_drone",
"route_uploading",
"route_ready",
}
for _, step := range prep {
d.publishWorkflow(cmd, taskID, missionID, "running", step)
if !d.sleepOrAbort(400 * time.Millisecond) {
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
}
takeoffAlt := defaultTakeoffAlt
if len(waypoints) > 0 && waypoints[0].Altitude > 0 {
takeoffAlt = waypoints[0].Altitude
}
d.publishWorkflow(cmd, taskID, missionID, "running", "taking_off")
d.setArmedFlying(0)
d.publishStateDrone()
if !d.climbTo(takeoffAlt) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.publishStateDrone()
d.publishWorkflow(cmd, taskID, missionID, "running", "finishing_takeoff")
if !d.sleepOrAbort(400 * time.Millisecond) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.publishWorkflow(cmd, taskID, missionID, "running", "starting_mission")
if !d.sleepOrAbort(300 * time.Millisecond) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.publishWorkflow(cmd, taskID, missionID, "running", "mission_running")
if !d.flyWaypoints(waypoints) {
d.publishWorkflow(cmd, taskID, missionID, "running", "returning_to_home")
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.publishWorkflow(cmd, taskID, missionID, "running", "returning_to_home")
if !d.returnHomeAndLand(false) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.finishWorkflow(cmd, taskID, missionID, "succeeded")
if recordOriginalVideo {
d.uploadOriginalVideo(cmd, taskID, originalVideo)
}
log.Printf("[%s] 任务执行完成 taskId=%s", d.spec.DockID, taskID)
}
func (d *MockDock) runOneKeyTakeoff(cmd commandMsg) {
defer d.endMission()
taskID, missionID := extractTaskIDs(cmd)
d.setDoor("open")
d.publishStateDock()
d.publishWorkflow(cmd, taskID, missionID, "running", "taking_off")
d.setArmedFlying(0)
if !d.climbTo(defaultTakeoffAlt) {
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
d.publishStateDrone()
d.publishWorkflow(cmd, taskID, missionID, "succeeded", "idle")
}
func (d *MockDock) runGenericWorkflow(cmd commandMsg) {
defer d.endMission()
taskID, missionID := extractTaskIDs(cmd)
d.setDoor("open")
d.publishStateDock()
steps := []string{
"preparing_dock_takeoff",
"waiting_drone_online",
"checking_drone",
"taking_off",
"finishing_takeoff",
"returning_to_home",
"preparing_dock_landing",
"returning",
"finishing_landing",
}
for _, step := range steps {
d.publishWorkflow(cmd, taskID, missionID, "running", step)
if step == "taking_off" {
d.setArmedFlying(0)
if !d.climbTo(defaultTakeoffAlt) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
}
if !d.sleepOrAbort(time.Second) {
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "cancelled")
return
}
}
d.returnHomeAndLand(true)
d.finishWorkflow(cmd, taskID, missionID, "succeeded")
}
func (d *MockDock) flyUploadedMission(waypoints []missionWaypoint) {
defer d.endMission()
log.Printf("[%s] 执行已上传航线,航点数=%d", d.spec.DockID, len(waypoints))
d.setArmedFlying(math.Max(d.currentAlt(), defaultTakeoffAlt))
if !d.flyWaypoints(waypoints) {
d.returnHomeAndLand(true)
return
}
d.returnHomeAndLand(true)
}
func (d *MockDock) flyWaypoints(waypoints []missionWaypoint) bool {
for i, wp := range waypoints {
alt := wp.Altitude
if alt <= 0 {
_, _, alt = d.currentLLA()
if alt <= 0 {
alt = defaultTakeoffAlt
}
}
speed := wp.Speed
if speed <= 0 {
speed = defaultCruiseSpeed
}
log.Printf("[%s] 飞向航点 %d/%d (seq=%d)", d.spec.DockID, i+1, len(waypoints), wp.Seq)
if !d.flyTo(wp.Latitude, wp.Longitude, alt, speed, false) {
return false
}
if wp.HoldSec > 0 {
if !d.holdAt(wp.HoldSec, wp.Yaw) {
return false
}
} else if wp.Yaw != 0 {
d.mu.Lock()
d.tele.heading = wp.Yaw
d.tele.yaw = wp.Yaw
d.mu.Unlock()
}
d.publishStateDrone()
}
return true
}
func (d *MockDock) climbTo(alt float64) bool {
lat, lon, cur := d.currentLLA()
if cur < 1 {
d.setPose(lat, lon, 1, 0, d.currentHeading())
}
return d.flyTo(lat, lon, alt, 5, false)
}
func (d *MockDock) currentAlt() float64 {
_, _, alt := d.currentLLA()
return alt
}
func (d *MockDock) currentHeading() float64 {
d.mu.Lock()
defer d.mu.Unlock()
return d.tele.heading
}
func (d *MockDock) returnHomeAndLand(forced bool) bool {
homeAlt := math.Max(d.currentAlt(), 20)
if !d.flyTo(d.spec.DroneLat, d.spec.DroneLon, homeAlt, defaultCruiseSpeed, forced) && !forced {
return false
}
if !d.flyTo(d.spec.DroneLat, d.spec.DroneLon, 0, 3, forced) && !forced {
return false
}
d.snapHomeLanded()
d.publishStateDrone()
d.publishStateDock()
return true
}
func (d *MockDock) rtlAndLand() {
if !d.beginMission("rtl") {
return
}
defer d.endMission()
d.returnHomeAndLand(true)
}
func (d *MockDock) finishWorkflow(cmd commandMsg, taskID, missionID, state string) {
if state == "cancelled" {
d.setDoor("closed")
d.publishStateDock()
}
d.publishWorkflow(cmd, taskID, missionID, state, "idle")
}
// flyTo 按球面插值飞向目标点;forced 时忽略暂停/取消(返航落地用)
func (d *MockDock) uploadOriginalVideo(cmd commandMsg, taskID string, video originalVideoUpload) {
if video.VideoID == 0 || video.ExecutionID == 0 || video.UploadURL == "" {
d.publishOriginalVideo(cmd, taskID, video, "failed", "UPLOAD_CONFIG_INVALID", 0)
return
}
if video.UploadExpireAt > 0 && time.Now().UnixMilli() >= video.UploadExpireAt {
d.publishOriginalVideo(cmd, taskID, video, "failed", "UPLOAD_URL_EXPIRED", 0)
return
}
body := []byte("mock original video fixture\n")
req, err := http.NewRequest(http.MethodPut, video.UploadURL, bytes.NewReader(body))
if err != nil {
d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0)
return
}
req.Header.Set("Content-Type", "video/mp4")
resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
if err != nil {
d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0)
return
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
d.publishOriginalVideo(cmd, taskID, video, "failed", "OSS_PUT_FAILED", 0)
return
}
d.publishOriginalVideo(cmd, taskID, video, "completed", "", int64(len(body)))
}
func (d *MockDock) publishOriginalVideo(cmd commandMsg, taskID string, video originalVideoUpload, eventType, errorCode string, fileSize int64) {
d.publishDrone(d.topic("internal/original-video"), 1, false, d.spec.DroneSN, map[string]any{
"extension": "laic.mock.original-video.v1",
"eventId": "mock-original-video-" + strconv.FormatInt(video.VideoID, 10),
"eventType": eventType,
"videoId": video.VideoID,
"executionId": video.ExecutionID,
"taskId": taskID,
"commandId": cmd.CommandID,
"fileSize": fileSize,
"duration": 1,
"errorCode": nilIfEmpty(errorCode),
"uploadedAt": time.Now().UnixMilli(),
})
}
type originalVideoUpload struct {
VideoID int64
ExecutionID int64
UploadURL string
UploadExpireAt int64
}
func parseOriginalVideo(value any) originalVideoUpload {
data, ok := value.(map[string]any)
if !ok {
return originalVideoUpload{}
}
return originalVideoUpload{
VideoID: asInt64(data["videoId"]),
ExecutionID: asInt64(data["executionId"]),
UploadURL: asString(data["uploadUrl"]),
UploadExpireAt: asInt64(data["uploadExpireAt"]),
}
}
func asString(value any) string {
text, _ := value.(string)
return text
}
func asBool(value any) bool {
valueBool, _ := value.(bool)
return valueBool
}
func asInt64(value any) int64 {
switch v := value.(type) {
case float64:
return int64(v)
case string:
parsed, _ := strconv.ParseInt(v, 10, 64)
return parsed
default:
return 0
}
}
func (d *MockDock) flyTo(lat, lon, alt, speed float64, forced bool) bool {
if speed <= 0 {
speed = defaultCruiseSpeed
}
fromLat, fromLon, fromAlt := d.currentLLA()
dist := haversineM(fromLat, fromLon, lat, lon)
dAlt := math.Abs(alt - fromAlt)
heading := bearingDeg(fromLat, fromLon, lat, lon)
if dist < 0.5 {
heading = d.currentHeading()
}
horizSec := dist / speed / missionTimeScale
vertSec := dAlt / 5.0 / missionTimeScale
sec := math.Max(horizSec, vertSec)
dur := time.Duration(sec * float64(time.Second))
if (dist > 1 || dAlt > 1) && dur < minLegDuration {
dur = minLegDuration
}
if dur > maxLegDuration {
dur = maxLegDuration
}
if dur < missionTick {
dur = missionTick
}
steps := int(dur / missionTick)
if steps < 1 {
steps = 1
}
for i := 1; i <= steps; i++ {
if !forced && !d.waitMissionTick() {
return false
}
if forced {
time.Sleep(missionTick)
}
t := float64(i) / float64(steps)
d.setPose(
lerp(fromLat, lat, t),
lerp(fromLon, lon, t),
lerp(fromAlt, alt, t),
speed,
heading,
)
}
d.setPose(lat, lon, alt, 0, heading)
return true
}
func (d *MockDock) holdAt(seconds int, yaw float64) bool {
if seconds > maxHoldSec {
seconds = maxHoldSec
}
if yaw != 0 {
d.mu.Lock()
d.tele.heading = yaw
d.tele.yaw = yaw
d.tele.groundSpeed = 0
d.mu.Unlock()
} else {
d.setPoseKeepLLA(0)
}
ticks := seconds * int(time.Second/missionTick)
for i := 0; i < ticks; i++ {
if !d.waitMissionTick() {
return false
}
}
return true
}
func (d *MockDock) setPoseKeepLLA(speed float64) {
d.mu.Lock()
d.tele.groundSpeed = speed
d.mu.Unlock()
}
func (d *MockDock) waitMissionTick() bool {
cancel := d.currentCancel()
for {
if isClosed(cancel) {
return false
}
d.mu.Lock()
paused := d.missionPaused
d.mu.Unlock()
if !paused {
if cancel == nil {
time.Sleep(missionTick)
return true
}
select {
case <-cancel:
return false
case <-time.After(missionTick):
return true
}
}
if cancel == nil {
time.Sleep(100 * time.Millisecond)
continue
}
select {
case <-cancel:
return false
case <-time.After(100 * time.Millisecond):
}
}
}
func (d *MockDock) sleepOrAbort(wait time.Duration) bool {
cancel := d.currentCancel()
if cancel == nil {
time.Sleep(wait)
return true
}
t := time.NewTimer(wait)
defer t.Stop()
select {
case <-cancel:
return false
case <-t.C:
return true
}
}
func extractTaskIDs(cmd commandMsg) (taskID, missionID string) {
if cmd.Params == nil {
return "", ""
}
if v, ok := cmd.Params["taskId"].(string); ok {
taskID = v
}
if v, ok := cmd.Params["missionId"].(string); ok {
missionID = v
}
return taskID, missionID
}
func parseWaypoints(params map[string]any) []missionWaypoint {
if params == nil {
return nil
}
raw, ok := params["waypoints"]
if !ok || raw == nil {
return nil
}
arr, ok := raw.([]any)
if !ok {
return nil
}
out := make([]missionWaypoint, 0, len(arr))
for i, item := range arr {
m, ok := item.(map[string]any)
if !ok {
continue
}
wp := missionWaypoint{
Seq: asInt(m["seq"]),
Longitude: asFloat(m["longitude"]),
Latitude: asFloat(m["latitude"]),
Altitude: asFloat(m["altitude"]),
Speed: asFloat(m["speed"]),
Yaw: asFloat(m["yaw"]),
HoldSec: asInt(m["holdSec"]),
}
if wp.Seq == 0 {
wp.Seq = i
}
if wp.Longitude == 0 && wp.Latitude == 0 {
continue
}
out = append(out, wp)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Seq < out[j].Seq })
return out
}
func asFloat(v any) float64 {
switch n := v.(type) {
case float64:
return n
case float32:
return float64(n)
case int:
return float64(n)
case int32:
return float64(n)
case int64:
return float64(n)
case uint64:
return float64(n)
default:
return 0
}
}
func asInt(v any) int {
return int(asFloat(v))
}
func isClosed(ch <-chan struct{}) bool {
if ch == nil {
return false
}
select {
case <-ch:
return true
default:
return false
}
}
func lerp(a, b, t float64) float64 {
return a + (b-a)*t
}
func haversineM(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadiusM = 6371000.0
phi1 := lat1 * math.Pi / 180
phi2 := lat2 * math.Pi / 180
dPhi := (lat2 - lat1) * math.Pi / 180
dLambda := (lon2 - lon1) * math.Pi / 180
a := math.Sin(dPhi/2)*math.Sin(dPhi/2) +
math.Cos(phi1)*math.Cos(phi2)*math.Sin(dLambda/2)*math.Sin(dLambda/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadiusM * c
}
func bearingDeg(lat1, lon1, lat2, lon2 float64) float64 {
phi1 := lat1 * math.Pi / 180
phi2 := lat2 * math.Pi / 180
dLambda := (lon2 - lon1) * math.Pi / 180
y := math.Sin(dLambda) * math.Cos(phi2)
x := math.Cos(phi1)*math.Sin(phi2) - math.Sin(phi1)*math.Cos(phi2)*math.Cos(dLambda)
deg := math.Atan2(y, x) * 180 / math.Pi
if deg < 0 {
deg += 360
}
return deg
}