mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
422 lines
12 KiB
Go
422 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"math"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/pion/webrtc/v4"
|
|
"github.com/x0gp/server/internal/catalog"
|
|
"github.com/x0gp/server/internal/control"
|
|
"github.com/x0gp/server/internal/lobby"
|
|
"github.com/x0gp/server/internal/transport"
|
|
)
|
|
|
|
type driverSession struct {
|
|
driverID string
|
|
deviceID uint8
|
|
pc *webrtc.PeerConnection
|
|
videoDC *webrtc.DataChannel
|
|
dataDC *webrtc.DataChannel
|
|
lastCommand string
|
|
cancelCtx context.CancelFunc
|
|
}
|
|
|
|
type WebRTCService struct {
|
|
logger *slog.Logger
|
|
engine *control.Engine
|
|
lobbySvc *lobby.Service
|
|
videoSvc *UDPVideoService
|
|
catalogSvc *catalog.Service
|
|
sessions map[string]*driverSession
|
|
sessionsMu sync.RWMutex
|
|
}
|
|
|
|
func NewWebRTCService(logger *slog.Logger, e *control.Engine, lobbySvc *lobby.Service, videoSvc *UDPVideoService, catalogSvc *catalog.Service) *WebRTCService {
|
|
return &WebRTCService{
|
|
logger: logger,
|
|
engine: e,
|
|
lobbySvc: lobbySvc,
|
|
videoSvc: videoSvc,
|
|
catalogSvc: catalogSvc,
|
|
sessions: make(map[string]*driverSession),
|
|
}
|
|
}
|
|
|
|
func (s *WebRTCService) Start(ctx context.Context, wg *sync.WaitGroup) {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
s.CloseAll()
|
|
return
|
|
case <-ticker.C:
|
|
s.broadcastTelemetry()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// ConnectHandler godoc
|
|
// @Summary Establish WebRTC connection for driver-car 1-to-1 proxying
|
|
// @Description Accepts SDP Offer, negotiates peer connection, and returns SDP Answer. Routes video and control data via DataChannels.
|
|
// @Tags webrtc
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body transport.WebRTCConnectRequest true "SDP Offer details"
|
|
// @Success 200 {object} transport.WebRTCConnectResponse "SDP Answer negotiation success"
|
|
// @Router /api/webrtc/connect [post]
|
|
func (s *WebRTCService) ConnectHandler() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed")
|
|
return
|
|
}
|
|
|
|
var req transport.WebRTCConnectRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request JSON")
|
|
return
|
|
}
|
|
|
|
if req.DriverID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "driver_id is required")
|
|
return
|
|
}
|
|
|
|
// Resolve device ID for this driver
|
|
deviceID := uint8(1) // default fallback
|
|
if driver, err := s.lobbySvc.GetDriver(req.DriverID); err == nil && driver.CarID != nil && *driver.CarID != "" {
|
|
if car, ok := s.catalogSvc.GetCar(*driver.CarID); ok && car.DeviceID != nil {
|
|
deviceID = uint8(*car.DeviceID)
|
|
} else {
|
|
s.logger.Warn("webrtc client connected but car has no physical device_id assigned in catalog. Defaulting to device 1", "driver_id", req.DriverID, "car_id", *driver.CarID)
|
|
}
|
|
} else {
|
|
s.logger.Warn("webrtc client connected but no car_id assigned in lobbySvc. Defaulting to device 1", "driver_id", req.DriverID)
|
|
}
|
|
|
|
// Create PeerConnection
|
|
config := webrtc.Configuration{
|
|
ICEServers: []webrtc.ICEServer{
|
|
{
|
|
URLs: []string{"stun:stun.l.google.com:19302"},
|
|
},
|
|
},
|
|
}
|
|
|
|
pc, err := webrtc.NewPeerConnection(config)
|
|
if err != nil {
|
|
s.logger.Error("failed to create webrtc peer connection", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
|
return
|
|
}
|
|
|
|
// Prepare context for background video pumping
|
|
connCtx, connCancel := context.WithCancel(context.Background())
|
|
|
|
session := &driverSession{
|
|
driverID: req.DriverID,
|
|
deviceID: deviceID,
|
|
pc: pc,
|
|
cancelCtx: connCancel,
|
|
}
|
|
|
|
// Handle Data Channels
|
|
pc.OnDataChannel(func(d *webrtc.DataChannel) {
|
|
s.logger.Info("received remote data channel", "label", d.Label(), "driver_id", req.DriverID)
|
|
s.sessionsMu.Lock()
|
|
if d.Label() == "video" {
|
|
session.videoDC = d
|
|
} else if d.Label() == "data" {
|
|
session.dataDC = d
|
|
}
|
|
s.sessionsMu.Unlock()
|
|
|
|
d.OnOpen(func() {
|
|
s.logger.Info("data channel opened", "label", d.Label(), "driver_id", req.DriverID)
|
|
if d.Label() == "video" {
|
|
// Start pumping video frames from UDPVideoService to the data channel
|
|
go s.pumpVideo(connCtx, session)
|
|
}
|
|
})
|
|
|
|
d.OnMessage(func(msg webrtc.DataChannelMessage) {
|
|
if d.Label() == "data" {
|
|
s.handleDataChannelMessage(session, msg.Data)
|
|
}
|
|
})
|
|
})
|
|
|
|
pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
|
|
s.logger.Info("webrtc connection state changed", "state", state.String(), "driver_id", req.DriverID)
|
|
if state == webrtc.PeerConnectionStateClosed || state == webrtc.PeerConnectionStateFailed {
|
|
s.CloseSession(req.DriverID)
|
|
}
|
|
})
|
|
|
|
// Set the remote SessionDescription
|
|
err = pc.SetRemoteDescription(webrtc.SessionDescription{
|
|
Type: webrtc.SDPTypeOffer,
|
|
SDP: req.SDP,
|
|
})
|
|
if err != nil {
|
|
pc.Close()
|
|
connCancel()
|
|
s.logger.Error("failed to set remote description", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
|
return
|
|
}
|
|
|
|
// Create Answer
|
|
answer, err := pc.CreateAnswer(nil)
|
|
if err != nil {
|
|
pc.Close()
|
|
connCancel()
|
|
s.logger.Error("failed to create answer", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
|
return
|
|
}
|
|
|
|
// Set Local Description
|
|
err = pc.SetLocalDescription(answer)
|
|
if err != nil {
|
|
pc.Close()
|
|
connCancel()
|
|
s.logger.Error("failed to set local description", "err", err)
|
|
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
|
return
|
|
}
|
|
|
|
// Wait for ICE Gathering to complete (Vanilla ICE)
|
|
gatherComplete := webrtc.GatheringCompletePromise(pc)
|
|
<-gatherComplete
|
|
|
|
// Save Session
|
|
s.sessionsMu.Lock()
|
|
// Clean up existing session if any
|
|
if existing, ok := s.sessions[req.DriverID]; ok {
|
|
existing.pc.Close()
|
|
existing.cancelCtx()
|
|
}
|
|
s.sessions[req.DriverID] = session
|
|
s.sessionsMu.Unlock()
|
|
|
|
localDesc := pc.LocalDescription()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(transport.WebRTCConnectResponse{
|
|
SDP: localDesc.SDP,
|
|
Type: "answer",
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *WebRTCService) pumpVideo(ctx context.Context, session *driverSession) {
|
|
s.logger.Info("starting WebRTC video pump for driver", "driver_id", session.driverID, "device_id", session.deviceID)
|
|
|
|
frameCh := make(chan []byte, 10)
|
|
s.videoSvc.registerClient(session.deviceID, frameCh)
|
|
defer func() {
|
|
s.videoSvc.unregisterClient(session.deviceID, frameCh)
|
|
s.logger.Info("stopping WebRTC video pump for driver", "driver_id", session.driverID)
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case frame, ok := <-frameCh:
|
|
if !ok {
|
|
return
|
|
}
|
|
s.sessionsMu.RLock()
|
|
dc := session.videoDC
|
|
s.sessionsMu.RUnlock()
|
|
|
|
if dc != nil && dc.ReadyState() == webrtc.DataChannelStateOpen {
|
|
if err := dc.Send(frame); err != nil {
|
|
s.logger.Debug("failed to send video frame over datachannel", "driver_id", session.driverID, "err", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *WebRTCService) handleDataChannelMessage(session *driverSession, data []byte) {
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(data, &payload); err != nil {
|
|
s.logger.Warn("received invalid JSON on data channel", "driver_id", session.driverID, "err", err)
|
|
return
|
|
}
|
|
|
|
// 1. Steering & Throttle inputs
|
|
in := transport.InputState{}
|
|
hasInput := false
|
|
if v, ok := payload["throttle"].(float64); ok {
|
|
in.Throttle = v
|
|
hasInput = true
|
|
}
|
|
if v, ok := payload["steering"].(float64); ok {
|
|
in.Steering = v
|
|
hasInput = true
|
|
}
|
|
if v, ok := payload["brake"].(float64); ok {
|
|
in.Brake = v
|
|
hasInput = true
|
|
}
|
|
if v, ok := payload["gear"].(float64); ok {
|
|
in.Gear = int(v)
|
|
hasInput = true
|
|
}
|
|
if v, ok := payload["buttons"].(float64); ok {
|
|
in.Buttons = uint32(v)
|
|
hasInput = true
|
|
}
|
|
|
|
if hasInput {
|
|
s.logger.Debug("received inputs on webrtc data channel", "driver_id", session.driverID, "throttle", in.Throttle, "steering", in.Steering)
|
|
car := s.engine.GetCarByDriver(session.driverID)
|
|
if car != nil {
|
|
car.ApplyInput(in, 1.0/60.0)
|
|
}
|
|
|
|
// Resolve destination device
|
|
var destDeviceID *uint8
|
|
if car != nil && car.DeviceID != nil {
|
|
val := uint8(*car.DeviceID)
|
|
destDeviceID = &val
|
|
} else if session.deviceID != 0 {
|
|
// Fallback to connection-time device
|
|
destDeviceID = &session.deviceID
|
|
}
|
|
|
|
if destDeviceID != nil {
|
|
var packet [5]byte
|
|
|
|
// 1. Steer: mapping float64 [-1.0, 1.0] to [0..200] (100 is center)
|
|
steerVal := int(math.Round((in.Steering + 1.0) * 100.0))
|
|
if steerVal < 0 { steerVal = 0 }
|
|
if steerVal > 200 { steerVal = 200 }
|
|
packet[0] = uint8(steerVal)
|
|
|
|
// 2. Throttle: mapping float64 [0.0, 1.0] to [0..100]
|
|
throttleVal := int(math.Round(in.Throttle * 100.0))
|
|
if throttleVal < 0 { throttleVal = 0 }
|
|
if throttleVal > 100 { throttleVal = 100 }
|
|
packet[1] = uint8(throttleVal)
|
|
|
|
// 3. Brake: mapping float64 [0.0, 1.0] to [0..100]
|
|
brakeVal := int(math.Round(in.Brake * 100.0))
|
|
if brakeVal < 0 { brakeVal = 0 }
|
|
if brakeVal > 100 { brakeVal = 100 }
|
|
packet[2] = uint8(brakeVal)
|
|
|
|
// 4. Gear: map -1 to 0 (R), 0 to 1 (N), >=1 to 2 (D)
|
|
gearVal := 1 // Neutral by default
|
|
if in.Gear < 0 {
|
|
gearVal = 0 // R
|
|
} else if in.Gear > 0 {
|
|
gearVal = 2 // D
|
|
}
|
|
packet[3] = uint8(gearVal)
|
|
|
|
// 5. Flags: bit 0 (DRS), bit 1 (Pit Limiter)
|
|
packet[4] = uint8(in.Buttons & 0x03)
|
|
|
|
hexCmd := hex.EncodeToString(packet[:])
|
|
if hexCmd != session.lastCommand {
|
|
s.logger.Info("webrtc control state transition", "driver_id", session.driverID, "old_hex", session.lastCommand, "new_hex", hexCmd)
|
|
session.lastCommand = hexCmd
|
|
err := s.videoSvc.SendControlPacket(*destDeviceID, packet)
|
|
if err != nil {
|
|
s.logger.Error("failed to forward control packet to ESP32", "device_id", *destDeviceID, "packet", packet, "err", err)
|
|
} else {
|
|
s.logger.Info("forwarded control packet to ESP32 via UDP", "device_id", *destDeviceID, "packet", packet)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Echo back message / send ACK
|
|
s.sessionsMu.RLock()
|
|
dc := session.dataDC
|
|
s.sessionsMu.RUnlock()
|
|
if dc != nil && dc.ReadyState() == webrtc.DataChannelStateOpen {
|
|
ack, _ := json.Marshal(map[string]any{
|
|
"type": "ack",
|
|
"applied": true,
|
|
"timestamp": time.Now().UnixMilli(),
|
|
})
|
|
_ = dc.SendText(string(ack))
|
|
}
|
|
}
|
|
|
|
func (s *WebRTCService) broadcastTelemetry() {
|
|
s.sessionsMu.RLock()
|
|
defer s.sessionsMu.RUnlock()
|
|
|
|
for _, session := range s.sessions {
|
|
dc := session.dataDC
|
|
if dc != nil && dc.ReadyState() == webrtc.DataChannelStateOpen {
|
|
// Pull some stats/telemetry
|
|
car := s.engine.GetCarByDriver(session.driverID)
|
|
var speed float64
|
|
var x, y float64
|
|
var carID string
|
|
if car != nil {
|
|
speed = car.Speed
|
|
x = car.X
|
|
y = car.Y
|
|
carID = car.ID
|
|
}
|
|
|
|
telemetry := map[string]any{
|
|
"type": "telemetry",
|
|
"car_id": carID,
|
|
"driver_id": session.driverID,
|
|
"speed": speed,
|
|
"x": x,
|
|
"y": y,
|
|
"timestamp": time.Now().UnixMilli(),
|
|
}
|
|
|
|
payload, _ := json.Marshal(telemetry)
|
|
_ = dc.SendText(string(payload))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *WebRTCService) CloseSession(driverID string) {
|
|
s.sessionsMu.Lock()
|
|
defer s.sessionsMu.Unlock()
|
|
|
|
if session, ok := s.sessions[driverID]; ok {
|
|
s.logger.Info("closing WebRTC session for driver", "driver_id", driverID)
|
|
session.pc.Close()
|
|
session.cancelCtx()
|
|
delete(s.sessions, driverID)
|
|
}
|
|
}
|
|
|
|
func (s *WebRTCService) CloseAll() {
|
|
s.sessionsMu.Lock()
|
|
defer s.sessionsMu.Unlock()
|
|
|
|
for driverID, session := range s.sessions {
|
|
s.logger.Info("closing WebRTC session (shutdown)", "driver_id", driverID)
|
|
session.pc.Close()
|
|
session.cancelCtx()
|
|
}
|
|
s.sessions = make(map[string]*driverSession)
|
|
}
|