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))
|
clansSvc := clans.NewService(clans.NewPgStore(pool))
|
||||||
driversSvc := drivers.NewService(drivers.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
|
// Persist finished races so the /api/races list and keyset pagination
|
||||||
// can serve historical data across restarts. The snapshot is best-
|
// can serve historical data across restarts. The snapshot is best-
|
||||||
// effort: errors are logged but never block the lobby.
|
// effort: errors are logged but never block the lobby.
|
||||||
@@ -280,6 +283,9 @@ func main() {
|
|||||||
// UDP video and command receiver.
|
// UDP video and command receiver.
|
||||||
videoSvc.Start(ctx, &wg)
|
videoSvc.Start(ctx, &wg)
|
||||||
|
|
||||||
|
// Start WebRTC service
|
||||||
|
webrtcSvc.Start(ctx, &wg)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/health", healthHandler(hub, engine))
|
mux.HandleFunc("/health", healthHandler(hub, engine))
|
||||||
mux.HandleFunc("/api/version", versionHandler())
|
mux.HandleFunc("/api/version", versionHandler())
|
||||||
@@ -301,8 +307,12 @@ func main() {
|
|||||||
mux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc))
|
mux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc))
|
||||||
mux.HandleFunc("/api/video/stream", videoSvc.StreamHandler())
|
mux.HandleFunc("/api/video/stream", videoSvc.StreamHandler())
|
||||||
mux.HandleFunc("/api/video/control", videoSvc.ControlHandler())
|
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))
|
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.
|
// Swagger UI + OpenAPI spec.
|
||||||
// UI: GET /swagger/index.html
|
// UI: GET /swagger/index.html
|
||||||
// Spec: GET /swagger/doc.json
|
// 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:
|
case transport.TypeClientHello:
|
||||||
handleClientHello(c, e, cfg, lobbySvc, driversSvc, env)
|
handleClientHello(c, e, cfg, lobbySvc, driversSvc, env)
|
||||||
case transport.TypeJoinRace:
|
case transport.TypeJoinRace:
|
||||||
handleJoinRace(c, e, env)
|
handleJoinRace(c, e, lobbySvc, env)
|
||||||
case transport.TypeLeaveRace:
|
case transport.TypeLeaveRace:
|
||||||
handleLeaveRace(c, e, env)
|
handleLeaveRace(c, e, env)
|
||||||
case transport.TypeInputState:
|
case transport.TypeInputState:
|
||||||
@@ -619,7 +629,7 @@ func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config
|
|||||||
c.Send <- mustEncode(transport.TypeServerHello, hello)
|
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)
|
payload, _ := env.Payload.(map[string]any)
|
||||||
slot := 0
|
slot := 0
|
||||||
name := "anon"
|
name := "anon"
|
||||||
@@ -631,7 +641,18 @@ func handleJoinRace(c *realtime.Client, e *control.Engine, env *transport.Envelo
|
|||||||
name = v
|
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 {
|
if err != nil {
|
||||||
sendError(c, "join_failed", err.Error())
|
sendError(c, "join_failed", err.Error())
|
||||||
return
|
return
|
||||||
@@ -666,12 +687,8 @@ func handleInput(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service,
|
|||||||
}
|
}
|
||||||
car.ApplyInput(in, 1.0/60.0)
|
car.ApplyInput(in, 1.0/60.0)
|
||||||
|
|
||||||
// Forward command transitions to ESP32 physical car if driver is assigned one
|
// Forward command transitions to ESP32 physical car if car has a device_id assigned
|
||||||
driverID := c.DriverID
|
if car.DeviceID != nil {
|
||||||
if driverID == "" {
|
|
||||||
driverID = c.ID
|
|
||||||
}
|
|
||||||
if d, err := lobbySvc.GetDriver(driverID); err == nil && d.DeviceID != nil {
|
|
||||||
var cmd string
|
var cmd string
|
||||||
if in.Throttle > 0.1 {
|
if in.Throttle > 0.1 {
|
||||||
cmd = "start"
|
cmd = "start"
|
||||||
@@ -686,7 +703,7 @@ func handleInput(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service,
|
|||||||
|
|
||||||
if cmd != "" && cmd != c.LastUDPCommand {
|
if cmd != "" && cmd != c.LastUDPCommand {
|
||||||
c.LastUDPCommand = cmd
|
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)
|
||||||
|
}
|
||||||
@@ -1846,6 +1846,40 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/webrtc/connect": {
|
||||||
|
"post": {
|
||||||
|
"description": "Accepts SDP Offer, negotiates peer connection, and returns SDP Answer. Routes video and control data via DataChannels.",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"webrtc"
|
||||||
|
],
|
||||||
|
"summary": "Establish WebRTC connection for driver-car 1-to-1 proxying",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "SDP Offer details",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.WebRTCConnectRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "SDP Answer negotiation success",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.WebRTCConnectResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/health": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.",
|
"description": "Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.",
|
||||||
@@ -2891,6 +2925,34 @@ const docTemplate = `{
|
|||||||
"example": "0.1.0-poc"
|
"example": "0.1.0-poc"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"transport.WebRTCConnectRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"driver_id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "driver-alice"
|
||||||
|
},
|
||||||
|
"sdp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "offer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transport.WebRTCConnectResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sdp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "answer"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
|
|||||||
@@ -1844,6 +1844,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/webrtc/connect": {
|
||||||
|
"post": {
|
||||||
|
"description": "Accepts SDP Offer, negotiates peer connection, and returns SDP Answer. Routes video and control data via DataChannels.",
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"webrtc"
|
||||||
|
],
|
||||||
|
"summary": "Establish WebRTC connection for driver-car 1-to-1 proxying",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "SDP Offer details",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.WebRTCConnectRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "SDP Answer negotiation success",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/transport.WebRTCConnectResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/health": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.",
|
"description": "Returns 200 OK with current engine phase, WS connection count and total snapshot drops. Intended for load-balancer health checks.",
|
||||||
@@ -2889,6 +2923,34 @@
|
|||||||
"example": "0.1.0-poc"
|
"example": "0.1.0-poc"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"transport.WebRTCConnectRequest": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"driver_id": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "driver-alice"
|
||||||
|
},
|
||||||
|
"sdp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "offer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"transport.WebRTCConnectResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sdp": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "answer"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,6 +691,25 @@ definitions:
|
|||||||
example: 0.1.0-poc
|
example: 0.1.0-poc
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
transport.WebRTCConnectRequest:
|
||||||
|
properties:
|
||||||
|
driver_id:
|
||||||
|
example: driver-alice
|
||||||
|
type: string
|
||||||
|
sdp:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
example: offer
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
transport.WebRTCConnectResponse:
|
||||||
|
properties:
|
||||||
|
sdp:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
example: answer
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
host: localhost:8080
|
host: localhost:8080
|
||||||
info:
|
info:
|
||||||
contact:
|
contact:
|
||||||
@@ -1973,6 +1992,29 @@ paths:
|
|||||||
summary: Get MJPEG video stream from ESP32
|
summary: Get MJPEG video stream from ESP32
|
||||||
tags:
|
tags:
|
||||||
- video
|
- video
|
||||||
|
/api/webrtc/connect:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
description: Accepts SDP Offer, negotiates peer connection, and returns SDP
|
||||||
|
Answer. Routes video and control data via DataChannels.
|
||||||
|
parameters:
|
||||||
|
- description: SDP Offer details
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/transport.WebRTCConnectRequest'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: SDP Answer negotiation success
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/transport.WebRTCConnectResponse'
|
||||||
|
summary: Establish WebRTC connection for driver-car 1-to-1 proxying
|
||||||
|
tags:
|
||||||
|
- webrtc
|
||||||
/health:
|
/health:
|
||||||
get:
|
get:
|
||||||
description: Returns 200 OK with current engine phase, WS connection count and
|
description: Returns 200 OK with current engine phase, WS connection count and
|
||||||
|
|||||||
+24
-4
@@ -20,12 +20,32 @@ require (
|
|||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/mailru/easyjson v0.7.6 // indirect
|
github.com/mailru/easyjson v0.7.6 // indirect
|
||||||
|
github.com/pion/datachannel v1.6.2 // indirect
|
||||||
|
github.com/pion/dtls/v3 v3.1.4 // indirect
|
||||||
|
github.com/pion/ice/v4 v4.2.7 // indirect
|
||||||
|
github.com/pion/interceptor v0.1.45 // indirect
|
||||||
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
|
github.com/pion/mdns/v2 v2.1.0 // indirect
|
||||||
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
|
github.com/pion/rtcp v1.2.16 // indirect
|
||||||
|
github.com/pion/rtp v1.10.2 // indirect
|
||||||
|
github.com/pion/sctp v1.10.3 // indirect
|
||||||
|
github.com/pion/sdp/v3 v3.0.19 // indirect
|
||||||
|
github.com/pion/srtp/v3 v3.0.12 // indirect
|
||||||
|
github.com/pion/stun/v3 v3.1.6 // indirect
|
||||||
|
github.com/pion/transport/v4 v4.0.2 // indirect
|
||||||
|
github.com/pion/turn/v5 v5.0.10 // indirect
|
||||||
|
github.com/pion/webrtc/v4 v4.2.16 // indirect
|
||||||
github.com/swaggo/files v1.0.1 // indirect
|
github.com/swaggo/files v1.0.1 // indirect
|
||||||
github.com/swaggo/http-swagger v1.3.4 // indirect
|
github.com/swaggo/http-swagger v1.3.4 // indirect
|
||||||
github.com/swaggo/swag v1.16.4 // indirect
|
github.com/swaggo/swag v1.16.4 // indirect
|
||||||
golang.org/x/net v0.43.0 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/text v0.29.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/tools v0.36.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
golang.org/x/time v0.14.0 // indirect
|
||||||
|
golang.org/x/tools v0.41.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,6 +39,38 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
|
|||||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc=
|
||||||
|
github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E=
|
||||||
|
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
|
||||||
|
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
|
||||||
|
github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
|
||||||
|
github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
|
||||||
|
github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo=
|
||||||
|
github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w=
|
||||||
|
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||||
|
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||||
|
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
|
||||||
|
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
|
||||||
|
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||||
|
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||||
|
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
|
||||||
|
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
|
||||||
|
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
|
||||||
|
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
|
||||||
|
github.com/pion/sctp v1.10.3 h1:1gBtLMA9lmwNuJkZSZJCdD5/Hz4yJs+7dAqi6ZY97QI=
|
||||||
|
github.com/pion/sctp v1.10.3/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0=
|
||||||
|
github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
|
||||||
|
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
|
||||||
|
github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc=
|
||||||
|
github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
|
||||||
|
github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
|
||||||
|
github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
|
||||||
|
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||||
|
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||||
|
github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ=
|
||||||
|
github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E=
|
||||||
|
github.com/pion/webrtc/v4 v4.2.16 h1:oK1GAg0TWJtZWYB8J/BgTgGWPoV2148gQWocH12vr3Q=
|
||||||
|
github.com/pion/webrtc/v4 v4.2.16/go.mod h1:y4HjLAkX90LH+C/qPqGOUgz8RA8CbDj3Iar3d+2hdKQ=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@@ -53,9 +85,13 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
|
|||||||
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
||||||
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
|
||||||
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
|
||||||
|
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
@@ -63,16 +99,22 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@@ -82,11 +124,17 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
|
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type Car struct {
|
|||||||
ID string
|
ID string
|
||||||
DriverID string // session id
|
DriverID string // session id
|
||||||
DriverName string
|
DriverName string
|
||||||
|
DeviceID *int // internal mapping to physical ESP32 device ID
|
||||||
X, Y float64
|
X, Y float64
|
||||||
Heading float64 // radians
|
Heading float64 // radians
|
||||||
Speed float64 // m/s
|
Speed float64 // m/s
|
||||||
@@ -169,7 +170,7 @@ func (e *Engine) SetPhase(p RacePhase) { e.phase.Store(int32(p)) }
|
|||||||
|
|
||||||
// AddCar registers a car and returns it. Returns an error if the driver
|
// AddCar registers a car and returns it. Returns an error if the driver
|
||||||
// already has a car or the max car count is reached.
|
// already has a car or the max car count is reached.
|
||||||
func (e *Engine) AddCar(driverID, driverName string, slot int) (*Car, error) {
|
func (e *Engine) AddCar(driverID, driverName string, slot int, deviceID *int) (*Car, error) {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
defer e.mu.Unlock()
|
defer e.mu.Unlock()
|
||||||
|
|
||||||
@@ -185,6 +186,7 @@ func (e *Engine) AddCar(driverID, driverName string, slot int) (*Car, error) {
|
|||||||
ID: id,
|
ID: id,
|
||||||
DriverID: driverID,
|
DriverID: driverID,
|
||||||
DriverName: driverName,
|
DriverName: driverName,
|
||||||
|
DeviceID: deviceID,
|
||||||
// Spread cars along start grid (x = 0, y = slot * 0.4 m).
|
// Spread cars along start grid (x = 0, y = slot * 0.4 m).
|
||||||
Y: float64(slot) * 0.4,
|
Y: float64(slot) * 0.4,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ type DriverMeta struct {
|
|||||||
LastSeenMs int64 `json:"last_seen_ms"`
|
LastSeenMs int64 `json:"last_seen_ms"`
|
||||||
Country string `json:"country,omitempty"` // optional, for UI
|
Country string `json:"country,omitempty"` // optional, for UI
|
||||||
AvatarHue int `json:"avatar_hue,omitempty"` // 0..360, deterministic per id
|
AvatarHue int `json:"avatar_hue,omitempty"` // 0..360, deterministic per id
|
||||||
DeviceID *int `json:"device_id,omitempty"`
|
DeviceID *int `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot is the immutable view broadcast to clients.
|
// Snapshot is the immutable view broadcast to clients.
|
||||||
|
|||||||
@@ -239,6 +239,19 @@ type VersionResponse struct {
|
|||||||
Version string `json:"version" example:"0.1.0-poc"`
|
Version string `json:"version" example:"0.1.0-poc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebRTCConnectRequest is the body of the WebRTC connection API.
|
||||||
|
type WebRTCConnectRequest struct {
|
||||||
|
SDP string `json:"sdp"`
|
||||||
|
Type string `json:"type" example:"offer"`
|
||||||
|
DriverID string `json:"driver_id" example:"driver-alice"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebRTCConnectResponse is the response to WebRTCConnectRequest.
|
||||||
|
type WebRTCConnectResponse struct {
|
||||||
|
SDP string `json:"sdp"`
|
||||||
|
Type string `json:"type" example:"answer"`
|
||||||
|
}
|
||||||
|
|
||||||
// LobbyDriver is the wire form of lobby.DriverMeta.
|
// LobbyDriver is the wire form of lobby.DriverMeta.
|
||||||
type LobbyDriver struct {
|
type LobbyDriver struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -249,7 +262,6 @@ type LobbyDriver struct {
|
|||||||
LastSeenMs int64 `json:"last_seen_ms"`
|
LastSeenMs int64 `json:"last_seen_ms"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
AvatarHue int `json:"avatar_hue,omitempty"`
|
AvatarHue int `json:"avatar_hue,omitempty"`
|
||||||
DeviceID *int `json:"device_id,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// LobbySnapshot is the full lobby state, broadcast on change.
|
// LobbySnapshot is the full lobby state, broadcast on change.
|
||||||
|
|||||||
@@ -0,0 +1,760 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>x0gp · WebRTC Cockpit</title>
|
||||||
|
<!-- Modern typography -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #0b0f19;
|
||||||
|
--panel-bg: rgba(17, 24, 39, 0.7);
|
||||||
|
--border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
--primary: #10b981; /* Emerald */
|
||||||
|
--primary-hover: #059669;
|
||||||
|
--accent: #3b82f6; /* Blue */
|
||||||
|
--text-main: #f3f4f6;
|
||||||
|
--text-muted: #9ca3af;
|
||||||
|
--danger: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 10% 20%, rgba(16, 185, 129, 0.05) 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 90% 80%, rgba(59, 130, 246, 0.05) 0%, transparent 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: -0.05em;
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--accent));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--danger);
|
||||||
|
box-shadow: 0 0 8px var(--danger);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connected {
|
||||||
|
background-color: var(--primary);
|
||||||
|
box-shadow: 0 0 8px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connecting {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
box-shadow: 0 0 8px #f59e0b;
|
||||||
|
animation: pulse 1s infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
from { opacity: 0.5; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
main {
|
||||||
|
grid-template-columns: 1.6fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
color: white;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.disconnect {
|
||||||
|
background: linear-gradient(135deg, var(--danger), #dc2626);
|
||||||
|
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
background: #020617;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
box-shadow: inset 0 0 40px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-feed {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-placeholder svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
stroke: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
animation: bounce 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-card {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.telemetry-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-viz {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steering-wheel-container {
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steering-wheel {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.1s ease-out;
|
||||||
|
transform-origin: center center;
|
||||||
|
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 45px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: all 0.1s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-btn.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #0b0f19;
|
||||||
|
box-shadow: 0 0 12px var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-btn.empty {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #10b981;
|
||||||
|
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-entry {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-time {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-in {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.console-out {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"/></svg>
|
||||||
|
x0gp <span style="font-weight: 300;">Cockpit</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-badge">
|
||||||
|
<div id="statusDot" class="status-dot"></div>
|
||||||
|
<span id="statusText">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Left: Video Feed Panel -->
|
||||||
|
<div class="glass-panel" style="display: flex; flex-direction: column;">
|
||||||
|
<h2>
|
||||||
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||||
|
Real-Time Video Feed
|
||||||
|
</h2>
|
||||||
|
<div class="video-container">
|
||||||
|
<img id="videoFeed" class="video-feed" alt="WebRTC Stream">
|
||||||
|
<div id="videoPlaceholder" class="video-placeholder">
|
||||||
|
<svg fill="none" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.182 16.318A4.486 4.486 0 0012.016 15a4.486 4.486 0 00-3.198 1.318M21 12a9 9 0 11-18 0 9 9 0 0118 0zM9.75 9.75c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75zm-.375 0h.008v.015h-.008V9.75zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75zm-.375 0h.008v.015h-.008V9.75z"/></svg>
|
||||||
|
<p>Connect the cockpit to receive real-time video</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="controller-viz">
|
||||||
|
<div class="steering-wheel-container">
|
||||||
|
<!-- Steering wheel SVG -->
|
||||||
|
<svg id="steeringWheel" class="steering-wheel" viewBox="0 0 100 100" fill="none">
|
||||||
|
<circle cx="50" cy="50" r="45" stroke="#f3f4f6" stroke-width="6"/>
|
||||||
|
<circle cx="50" cy="50" r="38" stroke="var(--primary)" stroke-width="2" stroke-dasharray="5 5"/>
|
||||||
|
<rect x="15" y="47" width="70" height="6" rx="3" fill="#f3f4f6"/>
|
||||||
|
<rect x="47" y="50" width="6" height="35" rx="3" fill="#f3f4f6"/>
|
||||||
|
<circle cx="50" cy="50" r="10" fill="#0b0f19" stroke="#f3f4f6" stroke-width="3"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="keys-grid">
|
||||||
|
<div class="key-btn empty"></div>
|
||||||
|
<div id="keyW" class="key-btn">W</div>
|
||||||
|
<div class="key-btn empty"></div>
|
||||||
|
<div id="keyA" class="key-btn">A</div>
|
||||||
|
<div id="keyS" class="key-btn">S</div>
|
||||||
|
<div id="keyD" class="key-btn">D</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Telemetry & Controls Panel -->
|
||||||
|
<div class="glass-panel" style="display: flex; flex-direction: column; gap: 1.5rem;">
|
||||||
|
<div>
|
||||||
|
<h2>
|
||||||
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Session Control
|
||||||
|
</h2>
|
||||||
|
<div class="auth-bar">
|
||||||
|
<input type="text" id="driverIdInput" placeholder="Enter Driver ID (e.g. ACE)" value="ACE">
|
||||||
|
<button id="connectBtn">Connect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>
|
||||||
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 002 2h2a2 2 0 002-2z"/></svg>
|
||||||
|
Real-time Telemetry
|
||||||
|
</h2>
|
||||||
|
<div class="telemetry-grid">
|
||||||
|
<div class="telemetry-card">
|
||||||
|
<div class="telemetry-label">Speed</div>
|
||||||
|
<div id="telSpeed" class="telemetry-value">0.00 <span style="font-size: 0.8rem; font-weight: normal;">m/s</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="telemetry-card">
|
||||||
|
<div class="telemetry-label">Ping Latency</div>
|
||||||
|
<div id="telPing" class="telemetry-value">0 <span style="font-size: 0.8rem; font-weight: normal;">ms</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="telemetry-card">
|
||||||
|
<div class="telemetry-label">Car ID</div>
|
||||||
|
<div id="telDevice" class="telemetry-value">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="telemetry-card">
|
||||||
|
<div class="telemetry-label">Position (X, Y)</div>
|
||||||
|
<div id="telPos" class="telemetry-value">0.0, 0.0</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex: 1; display: flex; flex-direction: column;">
|
||||||
|
<h2>
|
||||||
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||||
|
Console Log
|
||||||
|
</h2>
|
||||||
|
<div id="consoleLog" class="console">
|
||||||
|
<div class="console-entry"><span class="console-time">[18:58:00]</span> Welcome to x0gp Cockpit. Enter Driver ID and click Connect to initialize WebRTC.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
x0gp · Pure Go WebRTC 1-to-1 Proxy System
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const statusDot = document.getElementById('statusDot');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
const driverIdInput = document.getElementById('driverIdInput');
|
||||||
|
const connectBtn = document.getElementById('connectBtn');
|
||||||
|
const videoFeed = document.getElementById('videoFeed');
|
||||||
|
const videoPlaceholder = document.getElementById('videoPlaceholder');
|
||||||
|
const consoleLog = document.getElementById('consoleLog');
|
||||||
|
const steeringWheel = document.getElementById('steeringWheel');
|
||||||
|
|
||||||
|
// Telemetry DOM
|
||||||
|
const telSpeed = document.getElementById('telSpeed');
|
||||||
|
const telPing = document.getElementById('telPing');
|
||||||
|
const telDevice = document.getElementById('telDevice');
|
||||||
|
const telPos = document.getElementById('telPos');
|
||||||
|
|
||||||
|
let pc = null;
|
||||||
|
let dataChannel = null;
|
||||||
|
let videoChannel = null;
|
||||||
|
let sendControlsInterval = null;
|
||||||
|
let activeKeys = { w: false, a: false, s: false, d: false };
|
||||||
|
let currentSteer = 0; // -1 to 1
|
||||||
|
let currentThrottle = 0; // 0 to 1
|
||||||
|
let currentBrake = 0; // 0 to 1
|
||||||
|
let pingsReceived = 0;
|
||||||
|
let latencyStart = 0;
|
||||||
|
|
||||||
|
function log(message, type = 'system') {
|
||||||
|
const time = new Date().toTimeString().split(' ')[0];
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'console-entry';
|
||||||
|
|
||||||
|
let prefix = '';
|
||||||
|
if (type === 'in') prefix = ' <span class="console-in"><-</span> ';
|
||||||
|
if (type === 'out') prefix = ' <span class="console-out">-></span> ';
|
||||||
|
|
||||||
|
div.innerHTML = `<span class="console-time">[${time}]</span>${prefix}${message}`;
|
||||||
|
consoleLog.appendChild(div);
|
||||||
|
consoleLog.scrollTop = consoleLog.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard event listeners
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (activeKeys.hasOwnProperty(key)) {
|
||||||
|
activeKeys[key] = true;
|
||||||
|
updateKeyVisuals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keyup', (e) => {
|
||||||
|
const key = e.key.toLowerCase();
|
||||||
|
if (activeKeys.hasOwnProperty(key)) {
|
||||||
|
activeKeys[key] = false;
|
||||||
|
updateKeyVisuals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateKeyVisuals() {
|
||||||
|
document.getElementById('keyW').classList.toggle('active', activeKeys.w);
|
||||||
|
document.getElementById('keyA').classList.toggle('active', activeKeys.a);
|
||||||
|
document.getElementById('keyS').classList.toggle('active', activeKeys.s);
|
||||||
|
document.getElementById('keyD').classList.toggle('active', activeKeys.d);
|
||||||
|
|
||||||
|
// Calculate wheel rotation based on keys
|
||||||
|
let targetRotation = 0;
|
||||||
|
if (activeKeys.a) targetRotation = -45;
|
||||||
|
if (activeKeys.d) targetRotation = 45;
|
||||||
|
steeringWheel.style.transform = `rotate(${targetRotation}deg)`;
|
||||||
|
|
||||||
|
// Compute values
|
||||||
|
currentThrottle = activeKeys.w ? 1.0 : 0.0;
|
||||||
|
currentBrake = activeKeys.s ? 1.0 : 0.0;
|
||||||
|
currentSteer = 0.0;
|
||||||
|
if (activeKeys.a) currentSteer = -1.0;
|
||||||
|
if (activeKeys.d) currentSteer = 1.0;
|
||||||
|
|
||||||
|
// Log control changes to UI console
|
||||||
|
log(`Controls updated: throttle=${currentThrottle}, steering=${currentSteer}, brake=${currentBrake}`, 'out');
|
||||||
|
}
|
||||||
|
|
||||||
|
connectBtn.addEventListener('click', () => {
|
||||||
|
if (pc) {
|
||||||
|
disconnectCockpit();
|
||||||
|
} else {
|
||||||
|
connectCockpit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function connectCockpit() {
|
||||||
|
const driverId = driverIdInput.value.trim().toUpperCase();
|
||||||
|
if (!driverId) {
|
||||||
|
alert('Please enter a valid Driver ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Initializing WebRTC Connection for Driver: ${driverId}...`);
|
||||||
|
updateStatus('connecting', 'Connecting...');
|
||||||
|
|
||||||
|
connectBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Create PeerConnection
|
||||||
|
pc = new RTCPeerConnection({
|
||||||
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Create Data Channels (Client Initiated)
|
||||||
|
dataChannel = pc.createDataChannel('data', { ordered: true });
|
||||||
|
videoChannel = pc.createDataChannel('video', { ordered: false });
|
||||||
|
|
||||||
|
setupDataChannel(dataChannel);
|
||||||
|
setupVideoChannel(videoChannel);
|
||||||
|
|
||||||
|
// 3. Create SDP Offer
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
// 4. Wait for ICE gathering to complete (Vanilla ICE)
|
||||||
|
log('Gathering ICE Candidates...');
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
if (pc.iceGatheringState === 'complete') {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
function checkState() {
|
||||||
|
if (pc.iceGatheringState === 'complete') {
|
||||||
|
pc.removeEventListener('icegatheringstatechange', checkState);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pc.addEventListener('icegatheringstatechange', checkState);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Post SDP Offer to Server
|
||||||
|
const signalingUrl = `${window.location.protocol}//localhost:8080/api/webrtc/connect`;
|
||||||
|
log(`Sending SDP Offer to ${signalingUrl}...`);
|
||||||
|
|
||||||
|
const response = await fetch(signalingUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sdp: pc.localDescription.sdp,
|
||||||
|
type: 'offer',
|
||||||
|
driver_id: driverId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await response.json();
|
||||||
|
log('SDP Answer received from server. Establishing WebRTC channel...');
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
log(`WebRTC connection failed: ${err.message}`, 'system');
|
||||||
|
updateStatus('disconnected', 'Failed');
|
||||||
|
disconnectCockpit();
|
||||||
|
} finally {
|
||||||
|
connectBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectCockpit() {
|
||||||
|
log('Disconnecting WebRTC Cockpit...');
|
||||||
|
|
||||||
|
if (sendControlsInterval) {
|
||||||
|
clearInterval(sendControlsInterval);
|
||||||
|
sendControlsInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataChannel) {
|
||||||
|
dataChannel.close();
|
||||||
|
dataChannel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoChannel) {
|
||||||
|
videoChannel.close();
|
||||||
|
videoChannel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pc) {
|
||||||
|
pc.close();
|
||||||
|
pc = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
videoFeed.style.display = 'none';
|
||||||
|
videoPlaceholder.style.display = 'flex';
|
||||||
|
updateStatus('disconnected', 'Disconnected');
|
||||||
|
connectBtn.innerText = 'Connect';
|
||||||
|
connectBtn.classList.remove('disconnect');
|
||||||
|
|
||||||
|
// Reset Telemetry
|
||||||
|
telSpeed.innerHTML = '0.00 <span style="font-size: 0.8rem; font-weight: normal;">m/s</span>';
|
||||||
|
telPing.innerHTML = '0 <span style="font-size: 0.8rem; font-weight: normal;">ms</span>';
|
||||||
|
telDevice.innerHTML = '-';
|
||||||
|
telPos.innerHTML = '0.0, 0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDataChannel(dc) {
|
||||||
|
dc.onopen = () => {
|
||||||
|
log(`Bidirectional DataChannel ('${dc.label}') is OPEN`, 'system');
|
||||||
|
updateStatus('connected', 'Connected');
|
||||||
|
connectBtn.innerText = 'Disconnect';
|
||||||
|
connectBtn.classList.add('disconnect');
|
||||||
|
|
||||||
|
// Start sending steering controls at 30Hz
|
||||||
|
sendControlsInterval = setInterval(sendControls, 33);
|
||||||
|
};
|
||||||
|
|
||||||
|
dc.onclose = () => {
|
||||||
|
log(`DataChannel ('${dc.label}') is CLOSED`, 'system');
|
||||||
|
disconnectCockpit();
|
||||||
|
};
|
||||||
|
|
||||||
|
dc.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (msg.type === 'telemetry') {
|
||||||
|
// Update Telemetry Panel
|
||||||
|
telSpeed.innerHTML = `${msg.speed.toFixed(2)} <span style="font-size: 0.8rem; font-weight: normal;">m/s</span>`;
|
||||||
|
telDevice.innerHTML = msg.car_id || '-';
|
||||||
|
telPos.innerHTML = `${msg.x.toFixed(1)}, ${msg.y.toFixed(1)}`;
|
||||||
|
|
||||||
|
// Measure RTT
|
||||||
|
const currentMs = Date.now();
|
||||||
|
const latency = currentMs - msg.timestamp;
|
||||||
|
telPing.innerHTML = `${latency} <span style="font-size: 0.8rem; font-weight: normal;">ms</span>`;
|
||||||
|
|
||||||
|
if (pingsReceived % 5 === 0) {
|
||||||
|
log(`Telemetry RX: Speed=${msg.speed.toFixed(2)}m/s Car=${msg.car_id || '-'}`, 'in');
|
||||||
|
}
|
||||||
|
pingsReceived++;
|
||||||
|
} else if (msg.type === 'ack') {
|
||||||
|
// Log ACK periodically (every 30 frames) to verify loopback
|
||||||
|
if (pingsReceived % 30 === 0) {
|
||||||
|
log(`ACK RX from server: applied=${msg.applied}`, 'in');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`DataChannel Error: ${err.message}`, 'system');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupVideoChannel(dc) {
|
||||||
|
dc.binaryType = 'arraybuffer';
|
||||||
|
dc.onopen = () => {
|
||||||
|
log(`Video DataChannel ('${dc.label}') is OPEN`, 'system');
|
||||||
|
videoPlaceholder.style.display = 'none';
|
||||||
|
videoFeed.style.display = 'block';
|
||||||
|
};
|
||||||
|
|
||||||
|
dc.onclose = () => {
|
||||||
|
log(`Video DataChannel ('${dc.label}') is CLOSED`, 'system');
|
||||||
|
};
|
||||||
|
|
||||||
|
dc.onmessage = (event) => {
|
||||||
|
// Binary JPEG frames received
|
||||||
|
const blob = new Blob([event.data], { type: 'image/jpeg' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
videoFeed.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
videoFeed.src = url;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendControls() {
|
||||||
|
if (dataChannel && dataChannel.readyState === 'open') {
|
||||||
|
const payload = {
|
||||||
|
throttle: currentThrottle,
|
||||||
|
steering: currentSteer,
|
||||||
|
brake: currentBrake,
|
||||||
|
gear: 1,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
dataChannel.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(state, label) {
|
||||||
|
statusText.innerText = label;
|
||||||
|
statusDot.className = 'status-dot';
|
||||||
|
if (state === 'connected') statusDot.classList.add('connected');
|
||||||
|
if (state === 'connecting') statusDot.classList.add('connecting');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user