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:
@@ -26,6 +26,7 @@ import (
|
||||
|
||||
"github.com/x0gp/server/internal/clans"
|
||||
"github.com/x0gp/server/internal/drivers"
|
||||
"github.com/x0gp/server/internal/lobby"
|
||||
"github.com/x0gp/server/internal/transport"
|
||||
)
|
||||
|
||||
@@ -282,13 +283,84 @@ func driversListHandler(svc *drivers.Service) http.HandlerFunc {
|
||||
// @Router /api/drivers/{id} [get]
|
||||
// @Router /api/drivers/{id} [put]
|
||||
// @Router /api/drivers/{id} [delete]
|
||||
func driversByIDHandler(svc *drivers.Service) http.HandlerFunc {
|
||||
// @Router /api/drivers/{id}/device [put]
|
||||
func driversByIDHandler(svc *drivers.Service, lobbySvc *lobby.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/api/drivers/")
|
||||
if id == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing driver id")
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(id, "/device") {
|
||||
driverID := strings.TrimSuffix(id, "/device")
|
||||
if driverID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "missing driver id")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPut {
|
||||
w.Header().Set("Allow", "PUT")
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method)
|
||||
return
|
||||
}
|
||||
|
||||
// Read device_id from JSON body or query param
|
||||
var deviceID *int
|
||||
type deviceRequest struct {
|
||||
DeviceID *int `json:"device_id"`
|
||||
}
|
||||
var req deviceRequest
|
||||
if r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
|
||||
deviceID = req.DeviceID
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to query parameter if body didn't provide it
|
||||
if deviceID == nil {
|
||||
qVal := r.URL.Query().Get("device_id")
|
||||
if qVal != "" {
|
||||
if qVal == "null" {
|
||||
deviceID = nil
|
||||
} else if idInt, err := strconv.Atoi(qVal); err == nil {
|
||||
deviceID = &idInt
|
||||
} else {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid device_id query parameter")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate driver exists in DB
|
||||
drv, err := svc.Get(r.Context(), driverID)
|
||||
if err != nil {
|
||||
if errors.Is(err, drivers.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "driver not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure driver is registered in lobby
|
||||
_, _ = lobbySvc.AddDriver(driverID, drv.Name, "")
|
||||
lobbySvc.SetDriverProfile(driverID, drv.Nickname, drv.AvatarURL, drv.ClanID, "")
|
||||
|
||||
// Select device in lobby (handles constraints and write-through)
|
||||
if err := lobbySvc.SelectDevice(driverID, deviceID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Return updated lobby driver status
|
||||
lobbyDriver, err := lobbySvc.GetDriver(driverID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, lobbyDriver)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(id, "by-nick/") {
|
||||
nick := strings.TrimPrefix(id, "by-nick/")
|
||||
if nick == "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -85,7 +85,7 @@ func racesListHandler(svc *races.Service) http.HandlerFunc {
|
||||
writeJSON(w, http.StatusOK, transport.RaceListResponse{
|
||||
Items: items,
|
||||
NextCursor: res.NextCursor,
|
||||
Count: len(items),
|
||||
Count: res.TotalCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -382,10 +382,15 @@ func racePlansListHandler(svc *races.Service) http.HandlerFunc {
|
||||
last := out[len(out)-1]
|
||||
nextCur = races.Cursor{Ms: last.StartAtMs, ID: last.ID}.Encode()
|
||||
}
|
||||
totalCount, err := svc.CountPlans(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, transport.RacePlanListResponse{
|
||||
Items: out,
|
||||
NextCursor: nextCur,
|
||||
Count: len(out),
|
||||
Count: totalCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type deviceState struct {
|
||||
addr *net.UDPAddr
|
||||
latestFrame []byte
|
||||
currentFrame []byte
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// UDPVideoService handles UDP video frame collection and command forwarding.
|
||||
type UDPVideoService struct {
|
||||
logger *slog.Logger
|
||||
listenAddr string
|
||||
espCmdPort int
|
||||
|
||||
mu sync.RWMutex
|
||||
devices map[uint8]*deviceState
|
||||
|
||||
clientsMu sync.RWMutex
|
||||
clients map[uint8]map[chan []byte]struct{}
|
||||
|
||||
conn *net.UDPConn
|
||||
}
|
||||
|
||||
// NewUDPVideoService initializes a new UDPVideoService.
|
||||
func NewUDPVideoService(logger *slog.Logger, listenAddr string, espCmdPort int) *UDPVideoService {
|
||||
return &UDPVideoService{
|
||||
logger: logger,
|
||||
listenAddr: listenAddr,
|
||||
espCmdPort: espCmdPort,
|
||||
devices: make(map[uint8]*deviceState),
|
||||
clients: make(map[uint8]map[chan []byte]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start runs the UDP receiver loop (gracefully shut down by ctx).
|
||||
// Stdin command reader runs in a separate un-tracked goroutine so it doesn't block shutdown.
|
||||
func (s *UDPVideoService) Start(ctx context.Context, wg *sync.WaitGroup) {
|
||||
addr, err := net.ResolveUDPAddr("udp", s.listenAddr)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to resolve UDP address", "listenAddr", s.listenAddr, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
conn, err := net.ListenUDP("udp", addr)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to listen UDP", "listenAddr", s.listenAddr, "err", err)
|
||||
return
|
||||
}
|
||||
s.conn = conn
|
||||
|
||||
s.logger.Info("UDP video server started. Listening for video frames...", "listenAddr", s.listenAddr)
|
||||
|
||||
// Goroutine for handling UDP receive loop
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer conn.Close()
|
||||
|
||||
buf := make([]byte, 65535)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.logger.Info("stopping UDP video receiver loop")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Use a read deadline to check for context cancellation periodically
|
||||
_ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
n, remoteAddr, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
s.logger.Debug("UDP read error", "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if n < 1 {
|
||||
continue
|
||||
}
|
||||
deviceID := buf[0] // first byte is DEVICE_ID (0..127)
|
||||
payload := buf[1:n]
|
||||
|
||||
s.mu.Lock()
|
||||
dev, ok := s.devices[deviceID]
|
||||
if !ok {
|
||||
dev = &deviceState{
|
||||
addr: remoteAddr,
|
||||
lastSeen: time.Now(),
|
||||
}
|
||||
s.devices[deviceID] = dev
|
||||
s.logger.Info("ESP32 video source discovered", "device_id", deviceID, "ip", remoteAddr.IP.String())
|
||||
} else {
|
||||
if dev.addr == nil || dev.addr.IP.String() != remoteAddr.IP.String() {
|
||||
dev.addr = remoteAddr
|
||||
s.logger.Info("ESP32 video source IP updated", "device_id", deviceID, "ip", remoteAddr.IP.String())
|
||||
}
|
||||
dev.lastSeen = time.Now()
|
||||
}
|
||||
|
||||
// Reassemble JPEG frame for this device
|
||||
if len(payload) >= 2 && payload[0] == 0xFF && payload[1] == 0xD8 {
|
||||
dev.currentFrame = make([]byte, 0, 65535)
|
||||
}
|
||||
dev.currentFrame = append(dev.currentFrame, payload...)
|
||||
|
||||
if len(payload) >= 2 && payload[len(payload)-2] == 0xFF && payload[len(payload)-1] == 0xD9 {
|
||||
// Frame fully assembled
|
||||
s.broadcastFrame(deviceID, dev.currentFrame)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Stdin command reader (run in background, not tracked by wg to avoid blocking shutdown)
|
||||
go func() {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
s.logger.Info("Console command reader active. Enter commands (e.g., '1 left', '1 start', or 'left') in console:")
|
||||
|
||||
for {
|
||||
text, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
// Stdin closed or EOF (e.g. non-interactive/background mode)
|
||||
s.logger.Debug("stdin closed, stopping console command reader", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Format: "ID command" (e.g. "1 left") or "command" (defaults to the first discovered/available device)
|
||||
parts := strings.SplitN(text, " ", 2)
|
||||
var deviceID uint8
|
||||
var cmd string
|
||||
hasDevice := false
|
||||
|
||||
if len(parts) == 2 {
|
||||
if idVal, err := strconv.Atoi(parts[0]); err == nil && idVal >= 0 && idVal <= 127 {
|
||||
deviceID = uint8(idVal)
|
||||
cmd = parts[1]
|
||||
hasDevice = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasDevice {
|
||||
cmd = text
|
||||
// Find the first discovered/active device
|
||||
s.mu.RLock()
|
||||
for id := range s.devices {
|
||||
deviceID = id
|
||||
hasDevice = true
|
||||
break
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
}
|
||||
|
||||
if !hasDevice {
|
||||
s.logger.Warn("cannot send command: no ESP32 devices discovered yet")
|
||||
continue
|
||||
}
|
||||
|
||||
err = s.SendCommand(deviceID, cmd)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to send command to ESP32", "device_id", deviceID, "cmd", cmd, "err", err)
|
||||
} else {
|
||||
s.logger.Info("command sent to ESP32", "device_id", deviceID, "cmd", cmd)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SendCommand sends a command string to the ESP32 representing deviceID.
|
||||
func (s *UDPVideoService) SendCommand(deviceID uint8, cmd string) 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)
|
||||
}
|
||||
if conn == nil {
|
||||
return fmt.Errorf("UDP service not started")
|
||||
}
|
||||
|
||||
cmdAddr := &net.UDPAddr{
|
||||
IP: dev.addr.IP,
|
||||
Port: s.espCmdPort,
|
||||
}
|
||||
|
||||
_, err := conn.WriteToUDP([]byte(cmd), cmdAddr)
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
dev.latestFrame = make([]byte, len(frame))
|
||||
copy(dev.latestFrame, frame)
|
||||
}
|
||||
|
||||
s.clientsMu.RLock()
|
||||
defer s.clientsMu.RUnlock()
|
||||
deviceClients, ok := s.clients[deviceID]
|
||||
if ok {
|
||||
for ch := range deviceClients {
|
||||
select {
|
||||
case ch <- frame:
|
||||
default:
|
||||
// Drop frame if client is slow to read
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UDPVideoService) registerClient(deviceID uint8, ch chan []byte) {
|
||||
s.clientsMu.Lock()
|
||||
defer s.clientsMu.Unlock()
|
||||
if s.clients[deviceID] == nil {
|
||||
s.clients[deviceID] = make(map[chan []byte]struct{})
|
||||
}
|
||||
s.clients[deviceID][ch] = struct{}{}
|
||||
}
|
||||
|
||||
func (s *UDPVideoService) unregisterClient(deviceID uint8, ch chan []byte) {
|
||||
s.clientsMu.Lock()
|
||||
defer s.clientsMu.Unlock()
|
||||
if s.clients[deviceID] != nil {
|
||||
delete(s.clients[deviceID], ch)
|
||||
if len(s.clients[deviceID]) == 0 {
|
||||
delete(s.clients, deviceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StreamHandler godoc
|
||||
// @Summary Get MJPEG video stream from ESP32
|
||||
// @Description Streams video frames received via UDP as multipart/x-mixed-replace. Suitable for raw img tags in browser.
|
||||
// @Tags video
|
||||
// @Produce multipart/x-mixed-replace
|
||||
// @Param id query int false "Device ID (0..127)"
|
||||
// @Success 200 {string} string "MJPEG stream"
|
||||
// @Router /api/video/stream [get]
|
||||
func (s *UDPVideoService) StreamHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
idVal := r.URL.Query().Get("id")
|
||||
deviceID := uint8(1) // default device ID is 1 as per client.ino
|
||||
if idVal != "" {
|
||||
if idInt, err := strconv.Atoi(idVal); err == nil && idInt >= 0 && idInt <= 127 {
|
||||
deviceID = uint8(idInt)
|
||||
} else {
|
||||
http.Error(w, "invalid device id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
w.Header().Set("Cache-Control", "no-cache, private, max-age=0, no-store, must-revalidate")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
|
||||
frameCh := make(chan []byte, 10)
|
||||
s.registerClient(deviceID, frameCh)
|
||||
defer s.unregisterClient(deviceID, frameCh)
|
||||
|
||||
// Send the latest frame immediately if we have one
|
||||
s.mu.RLock()
|
||||
var initFrame []byte
|
||||
if dev, ok := s.devices[deviceID]; ok {
|
||||
initFrame = dev.latestFrame
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if len(initFrame) > 0 {
|
||||
_, _ = fmt.Fprintf(w, "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n", len(initFrame))
|
||||
_, _ = w.Write(initFrame)
|
||||
_, _ = w.Write([]byte("\r\n"))
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case frame, ok := <-frameCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %d\r\n\r\n", len(frame))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = w.Write(frame)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = w.Write([]byte("\r\n"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
// @Tags video
|
||||
// @Param id query int false "Device ID (0..127)"
|
||||
// @Param cmd query string true "Command text"
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{} "Command sent"
|
||||
// @Failure 400 {string} string "Missing cmd parameter"
|
||||
// @Failure 500 {string} string "Internal error"
|
||||
// @Router /api/video/control [post]
|
||||
func (s *UDPVideoService) ControlHandler() 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
|
||||
}
|
||||
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 == "" {
|
||||
idVal = r.FormValue("id")
|
||||
}
|
||||
deviceID := uint8(1)
|
||||
if idVal != "" {
|
||||
if idInt, err := strconv.Atoi(idVal); err == nil && idInt >= 0 && idInt <= 127 {
|
||||
deviceID = uint8(idInt)
|
||||
} else {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "invalid device id")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := s.SendCommand(deviceID, cmd)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "success",
|
||||
"device_id": deviceID,
|
||||
"command": cmd,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user