mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user