mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
Add WebRTC support and remove deviceId from apis
This commit is contained in:
@@ -196,6 +196,9 @@ func main() {
|
||||
clansSvc := clans.NewService(clans.NewPgStore(pool))
|
||||
driversSvc := drivers.NewService(drivers.NewPgStore(pool))
|
||||
|
||||
// WebRTC 1-to-1 driver-car proxy service
|
||||
webrtcSvc := NewWebRTCService(logger, engine, lobbySvc, videoSvc)
|
||||
|
||||
// Persist finished races so the /api/races list and keyset pagination
|
||||
// can serve historical data across restarts. The snapshot is best-
|
||||
// effort: errors are logged but never block the lobby.
|
||||
@@ -280,6 +283,9 @@ func main() {
|
||||
// UDP video and command receiver.
|
||||
videoSvc.Start(ctx, &wg)
|
||||
|
||||
// Start WebRTC service
|
||||
webrtcSvc.Start(ctx, &wg)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", healthHandler(hub, engine))
|
||||
mux.HandleFunc("/api/version", versionHandler())
|
||||
@@ -301,8 +307,12 @@ func main() {
|
||||
mux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc))
|
||||
mux.HandleFunc("/api/video/stream", videoSvc.StreamHandler())
|
||||
mux.HandleFunc("/api/video/control", videoSvc.ControlHandler())
|
||||
mux.HandleFunc("/api/webrtc/connect", webrtcSvc.ConnectHandler())
|
||||
mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, lobbySvc, driversSvc, videoSvc, logger))
|
||||
|
||||
// Static client files (resolving TODO)
|
||||
mux.Handle("/", http.FileServer(http.Dir("./web")))
|
||||
|
||||
// Swagger UI + OpenAPI spec.
|
||||
// UI: GET /swagger/index.html
|
||||
// Spec: GET /swagger/doc.json
|
||||
@@ -561,7 +571,7 @@ func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *c
|
||||
case transport.TypeClientHello:
|
||||
handleClientHello(c, e, cfg, lobbySvc, driversSvc, env)
|
||||
case transport.TypeJoinRace:
|
||||
handleJoinRace(c, e, env)
|
||||
handleJoinRace(c, e, lobbySvc, env)
|
||||
case transport.TypeLeaveRace:
|
||||
handleLeaveRace(c, e, env)
|
||||
case transport.TypeInputState:
|
||||
@@ -619,7 +629,7 @@ func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config
|
||||
c.Send <- mustEncode(transport.TypeServerHello, hello)
|
||||
}
|
||||
|
||||
func handleJoinRace(c *realtime.Client, e *control.Engine, env *transport.Envelope) {
|
||||
func handleJoinRace(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service, env *transport.Envelope) {
|
||||
payload, _ := env.Payload.(map[string]any)
|
||||
slot := 0
|
||||
name := "anon"
|
||||
@@ -631,7 +641,18 @@ func handleJoinRace(c *realtime.Client, e *control.Engine, env *transport.Envelo
|
||||
name = v
|
||||
}
|
||||
}
|
||||
car, err := e.AddCar(c.ID, name, slot)
|
||||
|
||||
// Resolve DeviceID for this driver internally
|
||||
driverID := c.DriverID
|
||||
if driverID == "" {
|
||||
driverID = c.ID
|
||||
}
|
||||
var deviceID *int
|
||||
if d, err := lobbySvc.GetDriver(driverID); err == nil {
|
||||
deviceID = d.DeviceID
|
||||
}
|
||||
|
||||
car, err := e.AddCar(c.ID, name, slot, deviceID)
|
||||
if err != nil {
|
||||
sendError(c, "join_failed", err.Error())
|
||||
return
|
||||
@@ -666,12 +687,8 @@ func handleInput(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service,
|
||||
}
|
||||
car.ApplyInput(in, 1.0/60.0)
|
||||
|
||||
// Forward command transitions to ESP32 physical car if driver is assigned one
|
||||
driverID := c.DriverID
|
||||
if driverID == "" {
|
||||
driverID = c.ID
|
||||
}
|
||||
if d, err := lobbySvc.GetDriver(driverID); err == nil && d.DeviceID != nil {
|
||||
// Forward command transitions to ESP32 physical car if car has a device_id assigned
|
||||
if car.DeviceID != nil {
|
||||
var cmd string
|
||||
if in.Throttle > 0.1 {
|
||||
cmd = "start"
|
||||
@@ -686,7 +703,7 @@ func handleInput(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service,
|
||||
|
||||
if cmd != "" && cmd != c.LastUDPCommand {
|
||||
c.LastUDPCommand = cmd
|
||||
_ = videoSvc.SendCommand(uint8(*d.DeviceID), cmd)
|
||||
_ = videoSvc.SendCommand(uint8(*car.DeviceID), cmd)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"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
|
||||
sessions map[string]*driverSession
|
||||
sessionsMu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewWebRTCService(logger *slog.Logger, e *control.Engine, lobbySvc *lobby.Service, videoSvc *UDPVideoService) *WebRTCService {
|
||||
return &WebRTCService{
|
||||
logger: logger,
|
||||
engine: e,
|
||||
lobbySvc: lobbySvc,
|
||||
videoSvc: videoSvc,
|
||||
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.DeviceID != nil {
|
||||
deviceID = uint8(*driver.DeviceID)
|
||||
} else {
|
||||
s.logger.Warn("webrtc client connected but no physical device_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 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)
|
||||
}
|
||||
|
||||
// Send commands to physical car
|
||||
var cmd string
|
||||
if in.Throttle > 0.1 {
|
||||
cmd = "start"
|
||||
} else {
|
||||
cmd = "stop"
|
||||
}
|
||||
if in.Steering < -0.2 {
|
||||
cmd = "left"
|
||||
} else if in.Steering > 0.2 {
|
||||
cmd = "right"
|
||||
}
|
||||
|
||||
// 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 && cmd != "" && cmd != session.lastCommand {
|
||||
s.logger.Info("webrtc control state transition", "driver_id", session.driverID, "old_cmd", session.lastCommand, "new_cmd", cmd)
|
||||
session.lastCommand = cmd
|
||||
err := s.videoSvc.SendCommand(*destDeviceID, cmd)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to forward command to ESP32", "device_id", *destDeviceID, "cmd", cmd, "err", err)
|
||||
} else {
|
||||
s.logger.Info("forwarded command to ESP32 via UDP", "device_id", *destDeviceID, "cmd", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user