feat: implement WebRTC signaling and UDP-based video streaming and control service

This commit is contained in:
2026-07-16 14:55:14 +04:00
parent 53288ea670
commit 41eaf49bb2
11 changed files with 614 additions and 108 deletions
+41 -14
View File
@@ -27,11 +27,13 @@ package main
import (
"bufio"
"context"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"math"
"net"
"net/http"
"os"
@@ -685,6 +687,9 @@ func handleInput(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service,
if v, ok := payload["gear"].(float64); ok {
in.Gear = int(v)
}
if v, ok := payload["buttons"].(float64); ok {
in.Buttons = uint32(v)
}
car := e.GetCarByDriver(c.ID)
if car == nil {
return
@@ -693,21 +698,43 @@ func handleInput(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service,
// 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"
} else {
cmd = "stop"
}
if in.Steering < -0.2 {
cmd = "left"
} else if in.Steering > 0.2 {
cmd = "right"
}
var packet [5]byte
if cmd != "" && cmd != c.LastUDPCommand {
c.LastUDPCommand = cmd
_ = videoSvc.SendCommand(uint8(*car.DeviceID), cmd)
// 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)
// Deduplicate UDP transmissions
hexCmd := hex.EncodeToString(packet[:])
if hexCmd != c.LastUDPCommand {
c.LastUDPCommand = hexCmd
_ = videoSvc.SendControlPacket(uint8(*car.DeviceID), packet)
}
}
+138 -18
View File
@@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"net"
@@ -172,7 +173,6 @@ func (s *UDPVideoService) Start(ctx context.Context, wg *sync.WaitGroup) {
}
s.mu.RUnlock()
}
if !hasDevice {
s.logger.Warn("cannot send command: no ESP32 devices discovered yet")
continue
@@ -188,13 +188,12 @@ func (s *UDPVideoService) Start(ctx context.Context, wg *sync.WaitGroup) {
}()
}
// SendCommand sends a command string to the ESP32 representing deviceID.
func (s *UDPVideoService) SendCommand(deviceID uint8, cmd string) error {
// SendControlPacket sends a 5-byte binary control packet to the ESP32 representing deviceID.
func (s *UDPVideoService) SendControlPacket(deviceID uint8, packet [5]byte) error {
s.mu.RLock()
dev, ok := s.devices[deviceID]
conn := s.conn
s.mu.RUnlock()
if !ok || dev.addr == nil {
return fmt.Errorf("ESP32 device %d address unknown (no video packets received yet)", deviceID)
}
@@ -207,10 +206,35 @@ func (s *UDPVideoService) SendCommand(deviceID uint8, cmd string) error {
Port: s.espCmdPort,
}
_, err := conn.WriteToUDP([]byte(cmd), cmdAddr)
_, err := conn.WriteToUDP(packet[:], cmdAddr)
return err
}
// SendCommand sends a command string to the ESP32 representing deviceID by mapping it to a 5-byte packet.
func (s *UDPVideoService) SendCommand(deviceID uint8, cmd string) error {
var packet [5]byte
packet[0] = 100 // Steer: center
packet[1] = 0 // Throttle: 0%
packet[2] = 0 // Brake: 0%
packet[3] = 1 // Gear: Neutral
packet[4] = 0 // Flags: 0
switch cmd {
case "start":
packet[1] = 100 // Throttle: 100%
packet[3] = 2 // Gear: Drive
case "stop":
packet[2] = 100 // Brake: 100%
packet[3] = 1 // Gear: Neutral
case "left":
packet[0] = 0 // Steer: full left
case "right":
packet[0] = 200 // Steer: full right
}
return s.SendControlPacket(deviceID, packet)
}
func (s *UDPVideoService) broadcastFrame(deviceID uint8, frame []byte) {
// dev.latestFrame is updated inside the caller's lock (Start loop) to avoid races
if dev, ok := s.devices[deviceID]; ok {
@@ -328,13 +352,14 @@ func (s *UDPVideoService) StreamHandler() http.HandlerFunc {
// ControlHandler godoc
// @Summary Send command to ESP32
// @Description Sends a text command (e.g. start, stop, left, right) to the ESP32 command port (UDP 8888).
// @Description Sends a text command or a JSON control packet to the ESP32 command port (UDP 8888).
// @Tags video
// @Param id query int false "Device ID (0..127)"
// @Param cmd query string true "Command text"
// @Param cmd query string false "Command text (legacy)"
// @Param body body map[string]int false "Control packet JSON (steer, throttle, brake, gear, flags)"
// @Produce json
// @Success 200 {object} map[string]interface{} "Command sent"
// @Failure 400 {string} string "Missing cmd parameter"
// @Failure 400 {string} string "Missing parameter or invalid body"
// @Failure 500 {string} string "Internal error"
// @Router /api/video/control [post]
func (s *UDPVideoService) ControlHandler() http.HandlerFunc {
@@ -343,14 +368,6 @@ func (s *UDPVideoService) ControlHandler() http.HandlerFunc {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed")
return
}
cmd := r.URL.Query().Get("cmd")
if cmd == "" {
cmd = r.FormValue("cmd")
}
if cmd == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Missing 'cmd' parameter")
return
}
idVal := r.URL.Query().Get("id")
if idVal == "" {
@@ -366,7 +383,109 @@ func (s *UDPVideoService) ControlHandler() http.HandlerFunc {
}
}
err := s.SendCommand(deviceID, cmd)
var packet [5]byte
packet[0] = 100 // Steer: center
packet[1] = 0 // Throttle: 0%
packet[2] = 0 // Brake: 0%
packet[3] = 1 // Gear: Neutral
packet[4] = 0 // Flags: 0
isJSON := strings.Contains(r.Header.Get("Content-Type"), "application/json")
var cmdUsed string
if isJSON {
var req struct {
Steer *int `json:"steer"`
Throttle *int `json:"throttle"`
Brake *int `json:"brake"`
Gear *int `json:"gear"`
Flags *int `json:"flags"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON payload")
return
}
if req.Steer != nil {
val := *req.Steer
if val < 0 {
val = 0
}
if val > 200 {
val = 200
}
packet[0] = uint8(val)
}
if req.Throttle != nil {
val := *req.Throttle
if val < 0 {
val = 0
}
if val > 100 {
val = 100
}
packet[1] = uint8(val)
}
if req.Brake != nil {
val := *req.Brake
if val < 0 {
val = 0
}
if val > 100 {
val = 100
}
packet[2] = uint8(val)
}
if req.Gear != nil {
val := *req.Gear
if val < 0 {
val = 0
}
if val > 2 {
val = 2
}
packet[3] = uint8(val)
}
if req.Flags != nil {
val := *req.Flags
if val < 0 {
val = 0
}
if val > 255 {
val = 255
}
packet[4] = uint8(val)
}
cmdUsed = fmt.Sprintf("json(steer=%d,throttle=%d,brake=%d,gear=%d,flags=%d)", packet[0], packet[1], packet[2], packet[3], packet[4])
} else {
cmd := r.URL.Query().Get("cmd")
if cmd == "" {
cmd = r.FormValue("cmd")
}
if cmd == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Missing 'cmd' parameter")
return
}
cmdUsed = cmd
switch cmd {
case "start":
packet[1] = 100
packet[3] = 2
case "stop":
packet[2] = 100
packet[3] = 1
case "left":
packet[0] = 0
case "right":
packet[0] = 200
default:
writeError(w, http.StatusBadRequest, "bad_request", "Unknown command: "+cmd)
return
}
}
err := s.SendControlPacket(deviceID, packet)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
return
@@ -375,7 +494,8 @@ func (s *UDPVideoService) ControlHandler() http.HandlerFunc {
writeJSON(w, http.StatusOK, map[string]any{
"status": "success",
"device_id": deviceID,
"command": cmd,
"command": cmdUsed,
"packet": packet[:],
})
}
}
+49 -21
View File
@@ -2,8 +2,10 @@ package main
import (
"context"
"encoding/hex"
"encoding/json"
"log/slog"
"math"
"net/http"
"sync"
"time"
@@ -269,6 +271,10 @@ func (s *WebRTCService) handleDataChannelMessage(session *driverSession, data []
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)
@@ -277,19 +283,6 @@ func (s *WebRTCService) handleDataChannelMessage(session *driverSession, data []
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 {
@@ -300,14 +293,49 @@ func (s *WebRTCService) handleDataChannelMessage(session *driverSession, data []
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)
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)
}
}
}
}