feat(server): integrate UDP video service and normalize database schema

- Implement UDPVideoService to stream MJPEG video frames from ESP32s via UDP and send control commands.
- Normalize race database schema:
  - Remove redundant driver_ids array from races table (migration 011).
  - Move per-driver outcomes (total_time_ms, best_lap_ms, position) to race_drivers table (migration 012).
  - Add device_id column to lobby_drivers to link active physical devices (migration 013).
- Update WebSocket handler to pass lobbySvc, driversSvc, and videoSvc for driver identity tracking, syncing in-memory driver metadata, and forwarding WS controls to physical cars.
- Add CORS middleware to HTTP routes.
- Regenerate Swagger documentation.
This commit is contained in:
x0gp
2026-07-06 12:38:34 +04:00
parent 978d36c505
commit 49351b4928
19 changed files with 1406 additions and 132 deletions
+77 -13
View File
@@ -58,8 +58,8 @@ import (
"github.com/x0gp/server/internal/races"
"github.com/x0gp/server/internal/races/seed"
"github.com/x0gp/server/internal/realtime"
"github.com/x0gp/server/internal/transport"
"github.com/x0gp/server/internal/storage/postgres"
"github.com/x0gp/server/internal/transport"
)
const serverVersion = "0.1.0-poc"
@@ -72,10 +72,27 @@ var (
}
)
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Разрешаем доступ с любых доменов. Для безопасности можно указать конкретный домен вместо *
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Важно: обрабатываем предварительный запрос (Preflight)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
var (
addr = flag.String("addr", "", "override HTTP listen address")
devMode = flag.Bool("dev", true, "enable development mode")
addr = flag.String("addr", "", "override HTTP listen address")
devMode = flag.Bool("dev", true, "enable development mode")
seedRaces = flag.Bool("seed-races", false, "seed mock race data (finished/live/plans) on startup")
seedReset = flag.Bool("reset", false, "with --seed-races, wipe seed-managed data before inserting")
)
@@ -147,6 +164,9 @@ func main() {
defer cat.Stop()
cb := newCatalogBroadcaster(cat, hub, logger)
// Initialize UDP video and command service.
videoSvc := NewUDPVideoService(logger, cfg.UDPVideoAddr, cfg.ESPCmdPort)
// Lobby + races: live races live in lobby.Service, finished races get
// snapshotted into Postgres via the lifecycle hook.
lobbySvc := lobby.NewService()
@@ -251,6 +271,9 @@ func main() {
snapshotLoop(ctx, engine, hub, logger)
}()
// UDP video and command receiver.
videoSvc.Start(ctx, &wg)
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler(hub, engine))
mux.HandleFunc("/stats", statsHandler(hub, engine))
@@ -268,8 +291,10 @@ func main() {
mux.HandleFunc("/api/clans", clansRouter(clansSvc))
mux.HandleFunc("/api/clans/", clansByIDHandler(clansSvc))
mux.HandleFunc("/api/drivers", driversRouter(driversSvc))
mux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc))
mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, logger))
mux.HandleFunc("/api/drivers/", driversByIDHandler(driversSvc, lobbySvc))
mux.HandleFunc("/api/video/stream", videoSvc.StreamHandler())
mux.HandleFunc("/api/video/control", videoSvc.ControlHandler())
mux.HandleFunc("/ws", wsHandler(cfg, engine, hub, cat, lobbySvc, driversSvc, videoSvc, logger))
// Swagger UI + OpenAPI spec.
// UI: GET /swagger/index.html
@@ -278,7 +303,7 @@ func main() {
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: withLogging(mux, logger),
Handler: withCORS(withLogging(mux, logger)),
ReadHeaderTimeout: 5 * time.Second,
}
@@ -418,7 +443,7 @@ func statsHandler(h *Hub, e *control.Engine) http.HandlerFunc {
// @Tags websocket
// @Success 101 {object} transport.Envelope "Switching protocols. Subsequent frames follow the envelope schema."
// @Router /ws [get]
func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Service, logger *slog.Logger) http.HandlerFunc {
func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, videoSvc *UDPVideoService, logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
@@ -436,7 +461,7 @@ func wsHandler(cfg *config.Config, e *control.Engine, h *Hub, cat *catalog.Servi
"remote", r.RemoteAddr,
)
go writePump(client, conn, logger)
go readPump(cfg, client, conn, e, h, cat, logger)
go readPump(cfg, client, conn, e, h, cat, lobbySvc, driversSvc, videoSvc, logger)
}
}
@@ -478,7 +503,7 @@ func writePump(c *realtime.Client, conn *websocket.Conn, logger *slog.Logger) {
}
}
func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *control.Engine, h *Hub, cat *catalog.Service, logger *slog.Logger) {
func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *control.Engine, h *Hub, cat *catalog.Service, lobbySvc *lobby.Service, driversSvc *drivers.Service, videoSvc *UDPVideoService, logger *slog.Logger) {
defer func() {
h.Unregister(c)
logger.Info("client disconnected", "client_id", c.ID)
@@ -510,13 +535,13 @@ func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *c
switch env.Type {
case transport.TypeClientHello:
handleClientHello(c, e, cfg, env)
handleClientHello(c, e, cfg, lobbySvc, driversSvc, env)
case transport.TypeJoinRace:
handleJoinRace(c, e, env)
case transport.TypeLeaveRace:
handleLeaveRace(c, e, env)
case transport.TypeInputState:
handleInput(c, e, env)
handleInput(c, e, lobbySvc, videoSvc, env)
case transport.TypePing:
handlePing(c, env)
case transport.TypeChatMessage:
@@ -541,7 +566,7 @@ func readPump(cfg *config.Config, c *realtime.Client, conn *websocket.Conn, e *c
// Message handlers --------------------------------------------------------
func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config, env *transport.Envelope) {
func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config, lobbySvc *lobby.Service, driversSvc *drivers.Service, env *transport.Envelope) {
hello := transport.ServerHello{
SessionID: c.ID,
RaceTick: 0,
@@ -552,6 +577,21 @@ func handleClientHello(c *realtime.Client, e *control.Engine, cfg *config.Config
hello.Config.MaxCars = 4
c.SessionID = c.ID
// Extract client_id (DriverID) if present in payload
payload, _ := env.Payload.(map[string]any)
if payload != nil {
if clientID, ok := payload["client_id"].(string); ok && clientID != "" {
c.DriverID = clientID
// Pre-populate driver in the lobby
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if drv, err := driversSvc.Get(ctx, clientID); err == nil {
_, _ = lobbySvc.AddDriver(clientID, drv.Name, "")
lobbySvc.SetDriverProfile(clientID, drv.Nickname, drv.AvatarURL, drv.ClanID, "")
}
cancel()
}
}
c.Send <- mustEncode(transport.TypeServerHello, hello)
}
@@ -583,7 +623,7 @@ func handleLeaveRace(c *realtime.Client, e *control.Engine, _ *transport.Envelop
e.RemoveCar(c.ID)
}
func handleInput(c *realtime.Client, e *control.Engine, env *transport.Envelope) {
func handleInput(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service, videoSvc *UDPVideoService, env *transport.Envelope) {
payload, ok := env.Payload.(map[string]any)
if !ok {
return
@@ -602,6 +642,30 @@ func handleInput(c *realtime.Client, e *control.Engine, env *transport.Envelope)
}
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 {
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"
}
if cmd != "" && cmd != c.LastUDPCommand {
c.LastUDPCommand = cmd
_ = videoSvc.SendCommand(uint8(*d.DeviceID), cmd)
}
}
// Optional lightweight ack (clients can disable for max throughput).
ack := transport.InputAck{
Seq: env.Seq,