From 41eaf49bb2640d6f759c12ad4861a57ea8f7bb29 Mon Sep 17 00:00:00 2001 From: IS1DI Date: Thu, 16 Jul 2026 14:55:14 +0400 Subject: [PATCH] feat: implement WebRTC signaling and UDP-based video streaming and control service --- hardware/esp32/client/client.ino | 131 +++++++++++++++++------- server/.env.example | 4 + server/cmd/poc-server/main.go | 55 +++++++--- server/cmd/poc-server/video_udp.go | 156 +++++++++++++++++++++++++---- server/cmd/poc-server/webrtc.go | 70 +++++++++---- server/docs/docs.go | 20 +++- server/docs/swagger.json | 20 +++- server/docs/swagger.yaml | 16 ++- server/web/index.html | 29 ++++++ server/web/main.js | 101 ++++++++++++++++++- server/web/webrtc_demo.html | 120 +++++++++++++++++++++- 11 files changed, 614 insertions(+), 108 deletions(-) diff --git a/hardware/esp32/client/client.ino b/hardware/esp32/client/client.ino index 1a71950..e8b1327 100644 --- a/hardware/esp32/client/client.ino +++ b/hardware/esp32/client/client.ino @@ -1,6 +1,16 @@ #include "esp_camera.h" #include #include +#include + +// === НАСТРОЙКА РЕЖИМА ЗАГЛУШКИ === +const bool USE_MOCK_IF_NO_CAMERA = true; // true - слать черный экран, false - ничего не слать + +Servo myServo; +const int servoPin = 13; // ПРИОРИТЕТ: Пин жестко закреплен за сервоприводом + +// !!! УНИКАЛЬНЫЙ ID ДЛЯ КАЖДОЙ ПЛАТЫ +const uint8_t DEVICE_ID = 1; // Конфигурация пинов (пример для Seeed Studio XIAO ESP32S3 Sense) #define PWDN_GPIO_NUM -1 @@ -10,7 +20,7 @@ #define SIOC_GPIO_NUM 39 #define Y9_GPIO_NUM 48 #define Y8_GPIO_NUM 47 -#define Y7_GPIO_NUM 13 +#define Y7_GPIO_NUM 18 // ИЗМЕНЕНО: Отключаем этот пин для камеры, отдаем под Серво #define Y6_GPIO_NUM 14 #define Y5_GPIO_NUM 12 #define Y4_GPIO_NUM 11 @@ -20,24 +30,45 @@ #define HREF_GPIO_NUM 38 #define PCLK_GPIO_NUM 37 - const char* ssid = "RT-WiFi-1712"; const char* password = "yt3xihY2Ci"; -const char* serverIP = "192.168.0.11"; +const char* serverIP = "192.168.0.10"; const int serverPort = 9999; const int localPort = 8888; -// !!! УНИКАЛЬНЫЙ ID ДЛЯ КАЖДОЙ ПЛАТЫ (например, 1 для первой, 2 для второй и т.д.) -const uint8_t DEVICE_ID = 1; + WiFiUDP udpVideo; WiFiUDP udpCmd; camera_config_t config; +// Флаг успешной инициализации камеры +bool isCameraInitialized = false; + +// Моковый минимальный валидный JPEG черного цвета +const uint8_t mockBlackJpeg[] = { + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x01, 0x00, 0x60, + 0x00, 0x60, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x08, + 0x00, 0x08, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09, 0xFF, 0xC4, 0x00, 0x14, + 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x00, 0x3F, 0xFF, 0xD9 +}; +const size_t mockSize = sizeof(mockBlackJpeg); + void setup() { Serial.begin(115200); - // Настройка камеры (QVGA для стабильности) + + // Инициализируем сервопривод в первую очередь + myServo.attach(servoPin, 500, 2500); + Serial.println("Сервопривод инициализирован на пине 13"); + + // Настройка конфигурации камеры config.ledc_channel = LEDC_CHANNEL_0; config.ledc_timer = LEDC_TIMER_0; config.pin_d0 = Y2_GPIO_NUM; @@ -45,7 +76,7 @@ void setup() { config.pin_d2 = Y4_GPIO_NUM; config.pin_d3 = Y5_GPIO_NUM; config.pin_d4 = Y6_GPIO_NUM; - config.pin_d5 = Y7_GPIO_NUM; + config.pin_d5 = Y7_GPIO_NUM; // Передаст -1, библиотека не тронет 13 пин config.pin_d6 = Y8_GPIO_NUM; config.pin_d7 = Y9_GPIO_NUM; config.pin_xclk = XCLK_GPIO_NUM; @@ -62,7 +93,15 @@ void setup() { config.grab_mode = CAMERA_GRAB_WHEN_EMPTY; config.fb_count = 2; - esp_camera_init(&config); + // Проверяем статус инициализации камеры + esp_err_t err = esp_camera_init(&config); + if (err == ESP_OK) { + isCameraInitialized = true; + Serial.println("Камера успешно инициализирована!"); + } else { + isCameraInitialized = false; + Serial.printf("Камера не найдена (код 0x%x). Режим мок-данных: %s\n", err, USE_MOCK_IF_NO_CAMERA ? "ВКЛ" : "ВЫКЛ"); + } // Подключение к Wi-Fi WiFi.begin(ssid, password); @@ -79,43 +118,58 @@ void setup() { void checkIncomingCommands() { int packetSize = udpCmd.parsePacket(); - if (packetSize > 0) { - char packetBuffer[255]; - int len = udpCmd.read(packetBuffer, 255); - if (len > 0) { packetBuffer[len] = 0; } - - String command = String(packetBuffer); - command.trim(); + if (packetSize == 5) { + uint8_t packetBuffer[5]; + int len = udpCmd.read(packetBuffer, 5); + if (len == 5) { + uint8_t steer = packetBuffer[0]; + uint8_t throttle = packetBuffer[1]; + uint8_t brake = packetBuffer[2]; + uint8_t gear = packetBuffer[3]; + uint8_t flags = packetBuffer[4]; - Serial.print("Получена команда: "); - Serial.println(command); + // ИСПРАВЛЕНО: Инвертирован диапазон (180, 0 вместо 0, 180) для исправления направления + int servoAngle = map(steer, 0, 200, 180, 0); + myServo.write(servoAngle); - Serial.print("Получена команда: "); - Serial.println(command); - - // Логика управления - if (command == "left") { - // Ваш код поворота налево - } else if (command == "right") { - // Ваш код поворота направо - } else if (command == "start") { - // Ваш код запуска моторов / логики - } else if (command == "stop") { - // Ваш код остановки + Serial.printf("Got control packet: Steer=%d (Angle=%d), Throttle=%d%%, Brake=%d%%, Gear=%d, Flags=0x%02X\n", + steer, servoAngle, throttle, brake, gear, flags); } + } else if (packetSize > 0) { + char garbage[255]; + udpCmd.read(garbage, sizeof(garbage)); } } void loop() { checkIncomingCommands(); - camera_fb_t * fb = esp_camera_fb_get(); - if (!fb) return; + uint8_t * buf = nullptr; + size_t bufferSize = 0; + camera_fb_t * fb = nullptr; - size_t bufferSize = fb->len; - uint8_t * buf = fb->buf; + // Пытаемся получить кадр, если камера запущена + if (isCameraInitialized) { + fb = esp_camera_fb_get(); + if (fb) { + bufferSize = fb->len; + buf = fb->buf; + } + } + + // Если кадра с камеры нет + if (!buf) { + // Если отправка заглушки отключена — выходим из цикла, продолжая слушать команды + if (!USE_MOCK_IF_NO_CAMERA) { + delay(10); + return; + } + // Если включена — подставляем моковый черный кадр + bufferSize = mockSize; + buf = (uint8_t *)mockBlackJpeg; + } - // Размер куска данных уменьшаем до 1023, чтобы вместе с 1 байтом ID пакет был 1024 байта + // Нарезка и отправка по UDP (оригинальная логика) size_t chunkSize = 1023; uint8_t packetBuffer[1024]; @@ -125,15 +179,18 @@ void loop() { sendSize = bufferSize - i; } - // Формируем пакет: первый байт — ID, дальше — видеоданные packetBuffer[0] = DEVICE_ID; memcpy(packetBuffer + 1, buf + i, sendSize); udpVideo.beginPacket(serverIP, serverPort); - udpVideo.write(packetBuffer, sendSize + 1); // Отправляем данные вместе с ID + udpVideo.write(packetBuffer, sendSize + 1); udpVideo.endPacket(); } - esp_camera_fb_return(fb); + // Возвращаем буфер только если он реальный + if (fb) { + esp_camera_fb_return(fb); + } + delay(10); } diff --git a/server/.env.example b/server/.env.example index f48a8e4..ceb024c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -19,7 +19,11 @@ X0GP_LOG_LEVEL=info X0GP_TICK_RATE=60 X0GP_SNAPSHOT_RATE=30 X0GP_MAX_CLIENTS=100 +X0GP_SEED_RACES=0 +X0GP_SEED_RESET=0 +# Optional: override path(s) to dotenv files (colon-separated). +# X0GP_DOTENV=.env:./configs/local.env # PoC: skip auth, allow multiple clients on the same track. X0GP_DEV_MODE=true diff --git a/server/cmd/poc-server/main.go b/server/cmd/poc-server/main.go index 53f48b2..6a13dcc 100644 --- a/server/cmd/poc-server/main.go +++ b/server/cmd/poc-server/main.go @@ -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) } } diff --git a/server/cmd/poc-server/video_udp.go b/server/cmd/poc-server/video_udp.go index d75de90..5da47d5 100644 --- a/server/cmd/poc-server/video_udp.go +++ b/server/cmd/poc-server/video_udp.go @@ -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[:], }) } } diff --git a/server/cmd/poc-server/webrtc.go b/server/cmd/poc-server/webrtc.go index 4939528..4fcfc4b 100644 --- a/server/cmd/poc-server/webrtc.go +++ b/server/cmd/poc-server/webrtc.go @@ -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) + } } } } diff --git a/server/docs/docs.go b/server/docs/docs.go index 0a0922c..6448569 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -2003,7 +2003,7 @@ const docTemplate = `{ }, "/api/video/control": { "post": { - "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).", "produces": [ "application/json" ], @@ -2020,10 +2020,20 @@ const docTemplate = `{ }, { "type": "string", - "description": "Command text", + "description": "Command text (legacy)", "name": "cmd", - "in": "query", - "required": true + "in": "query" + }, + { + "description": "Control packet JSON (steer, throttle, brake, gear, flags)", + "name": "body", + "in": "body", + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } } ], "responses": { @@ -2035,7 +2045,7 @@ const docTemplate = `{ } }, "400": { - "description": "Missing cmd parameter", + "description": "Missing parameter or invalid body", "schema": { "type": "string" } diff --git a/server/docs/swagger.json b/server/docs/swagger.json index b444bfb..0e8accd 100644 --- a/server/docs/swagger.json +++ b/server/docs/swagger.json @@ -2001,7 +2001,7 @@ }, "/api/video/control": { "post": { - "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).", "produces": [ "application/json" ], @@ -2018,10 +2018,20 @@ }, { "type": "string", - "description": "Command text", + "description": "Command text (legacy)", "name": "cmd", - "in": "query", - "required": true + "in": "query" + }, + { + "description": "Control packet JSON (steer, throttle, brake, gear, flags)", + "name": "body", + "in": "body", + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer" + } + } } ], "responses": { @@ -2033,7 +2043,7 @@ } }, "400": { - "description": "Missing cmd parameter", + "description": "Missing parameter or invalid body", "schema": { "type": "string" } diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index f747c78..ef4c730 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -2219,18 +2219,24 @@ paths: - system /api/video/control: post: - 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). parameters: - description: Device ID (0..127) in: query name: id type: integer - - description: Command text + - description: Command text (legacy) in: query name: cmd - required: true type: string + - description: Control packet JSON (steer, throttle, brake, gear, flags) + in: body + name: body + schema: + additionalProperties: + type: integer + type: object produces: - application/json responses: @@ -2240,7 +2246,7 @@ paths: additionalProperties: true type: object "400": - description: Missing cmd parameter + description: Missing parameter or invalid body schema: type: string "500": diff --git a/server/web/index.html b/server/web/index.html index b70cfb5..c175259 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -79,6 +79,35 @@
+ +
+
+
GEAR
+
+ + N + +
+
(Z / X)
+
+
+
DRS
+ +
OFF
+
(Q)
+
+
+
PIT LIMIT
+ +
OFF
+
(E)
+
+
+ +
+ Пакет управления (JSON): +
{}
+
diff --git a/server/web/main.js b/server/web/main.js index acb2dcb..45ca7d1 100644 --- a/server/web/main.js +++ b/server/web/main.js @@ -35,6 +35,14 @@ tPos: $('tPos'), tTsm: $('tTsm'), tCars: $('tCars'), + lblGear: $('lblGear'), + lblDrs: $('lblDrs'), + lblPit: $('lblPit'), + btnDrs: $('btnDrs'), + btnPit: $('btnPit'), + btnGearUp: $('btnGearUp'), + btnGearDown: $('btnGearDown'), + ctrlJson: $('ctrlJson'), }; // -------- State -------- @@ -53,7 +61,7 @@ snapshotCount: 0, snapshotWindow: [], keys: new Set(), - input: { throttle: 0, brake: 0, steer: 0, handbrake: false, buttons: 0 }, + input: { throttle: 0, brake: 0, steer: 0, handbrake: false, buttons: 0, gear: 0, drs: false, pitLimiter: false }, pingTimer: null, inputTimer: null, slotHint: 0, // сервер выдаёт слот; 0 по умолчанию @@ -113,6 +121,12 @@ state.input.steer = steer; state.input.handbrake = handbrake; + // Calculate buttons bitmask: bit 0 (DRS), bit 1 (Pit Limiter) + let buttons = 0; + if (state.input.drs) buttons |= 1; + if (state.input.pitLimiter) buttons |= 2; + state.input.buttons = buttons; + // Update UI bars ui.lblThrottle.textContent = throttle.toFixed(2); ui.lblBrake.textContent = brake.toFixed(2); @@ -129,6 +143,48 @@ ui.barSteer.style.width = '2%'; ui.wheelDisc.style.transform = `rotate(${steer * 90}deg)`; + + // Update Gear, DRS, Pit Limiter UI text & class if elements exist + if (ui.lblGear) { + let gName = 'N'; + if (state.input.gear === -1) gName = 'R'; + if (state.input.gear === 1) gName = 'D'; + ui.lblGear.textContent = gName; + } + if (ui.lblDrs) { + ui.lblDrs.textContent = state.input.drs ? 'ON' : 'OFF'; + ui.lblDrs.className = state.input.drs ? 'val active' : 'val'; + } + if (ui.lblPit) { + ui.lblPit.textContent = state.input.pitLimiter ? 'ON' : 'OFF'; + ui.lblPit.className = state.input.pitLimiter ? 'val active' : 'val'; + } + + // Format control packet as JSON and show in UI + if (ui.ctrlJson) { + const steerByte = Math.round((steer + 1.0) * 100); + const throttleByte = Math.round(throttle * 100); + const brakeByte = Math.round(brake * 100); + const gearByte = state.input.gear === -1 ? 0 : (state.input.gear === 1 ? 2 : 1); + + const jsonStr = JSON.stringify({ + sent_json: { + steering: steer, + throttle: throttle, + brake: brake, + gear: state.input.gear, + buttons: buttons + }, + binary_packet_bytes: { + byte0_steer: `0x${steerByte.toString(16).toUpperCase().padStart(2, '0')} (${steerByte})`, + byte1_throttle: `0x${throttleByte.toString(16).toUpperCase().padStart(2, '0')} (${throttleByte}%)`, + byte2_brake: `0x${brakeByte.toString(16).toUpperCase().padStart(2, '0')} (${brakeByte}%)`, + byte3_gear: `0x${gearByte.toString(16).toUpperCase().padStart(2, '0')} (${gearByte === 0 ? 'R' : (gearByte === 2 ? 'D' : 'N')})`, + byte4_flags: `0x${buttons.toString(16).toUpperCase().padStart(2, '0')} (DRS=${state.input.drs ? 1 : 0}, Pit=${state.input.pitLimiter ? 1 : 0})` + } + }, null, 2); + ui.ctrlJson.textContent = jsonStr; + } } // -------- Send helpers -------- @@ -450,6 +506,24 @@ if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault(); state.keys.add(k); + + // Toggle DRS, Pit Limiter, and shift gears + if (k === 'q') { + state.input.drs = !state.input.drs; + pollInput(); + } + if (k === 'e') { + state.input.pitLimiter = !state.input.pitLimiter; + pollInput(); + } + if (k === 'z') { + state.input.gear = Math.max(-1, state.input.gear - 1); + pollInput(); + } + if (k === 'x') { + state.input.gear = Math.min(1, state.input.gear + 1); + pollInput(); + } }); window.addEventListener('keyup', (e) => { state.keys.delete(keyName(e)); @@ -462,6 +536,31 @@ ui.btnJoin.addEventListener('click', joinRace); ui.btnLeave.addEventListener('click', leaveRace); + if (ui.btnDrs) { + ui.btnDrs.addEventListener('click', () => { + state.input.drs = !state.input.drs; + pollInput(); + }); + } + if (ui.btnPit) { + ui.btnPit.addEventListener('click', () => { + state.input.pitLimiter = !state.input.pitLimiter; + pollInput(); + }); + } + if (ui.btnGearUp) { + ui.btnGearUp.addEventListener('click', () => { + state.input.gear = Math.min(1, state.input.gear + 1); + pollInput(); + }); + } + if (ui.btnGearDown) { + ui.btnGearDown.addEventListener('click', () => { + state.input.gear = Math.max(-1, state.input.gear - 1); + pollInput(); + }); + } + // -------- Boot -------- setConn('disconnected', ''); log('x0gp PoC client ready. Press Connect.', 'meta'); diff --git a/server/web/webrtc_demo.html b/server/web/webrtc_demo.html index cbe8ce1..df3ef73 100644 --- a/server/web/webrtc_demo.html +++ b/server/web/webrtc_demo.html @@ -408,6 +408,35 @@
S
D
+ +
+
+
GEAR
+
+ + N + +
+
(Z / X)
+
+
+
DRS
+ +
OFF
+
(Q)
+
+
+
PIT LIMIT
+ +
OFF
+
(E)
+
+
+ +
+ Пакет управления (JSON): +
{}
+
@@ -489,6 +518,10 @@ let currentSteer = 0; // -1 to 1 let currentThrottle = 0; // 0 to 1 let currentBrake = 0; // 0 to 1 + let currentGear = 0; // 0: N, -1: R, 1: D + let drs = false; + let pitLimiter = false; + let buttons = 0; let pingsReceived = 0; let latencyStart = 0; @@ -513,6 +546,22 @@ activeKeys[key] = true; updateKeyVisuals(); } + if (key === 'q') { + drs = !drs; + updateKeyVisuals(); + } + if (key === 'e') { + pitLimiter = !pitLimiter; + updateKeyVisuals(); + } + if (key === 'z') { + currentGear = Math.max(-1, currentGear - 1); + updateKeyVisuals(); + } + if (key === 'x') { + currentGear = Math.min(1, currentGear + 1); + updateKeyVisuals(); + } }); window.addEventListener('keyup', (e) => { @@ -542,10 +591,76 @@ if (activeKeys.a) currentSteer = -1.0; if (activeKeys.d) currentSteer = 1.0; + buttons = 0; + if (drs) buttons |= 1; + if (pitLimiter) buttons |= 2; + + // Update UI Gear/DRS/Pit if elements exist + const lblGear = document.getElementById('lblGear'); + if (lblGear) { + let gName = 'N'; + if (currentGear === -1) gName = 'R'; + if (currentGear === 1) gName = 'D'; + lblGear.innerText = gName; + } + const lblDrs = document.getElementById('lblDrs'); + if (lblDrs) { + lblDrs.innerText = drs ? 'ON' : 'OFF'; + lblDrs.style.color = drs ? '#00ff66' : '#ef4444'; + } + const lblPit = document.getElementById('lblPit'); + if (lblPit) { + lblPit.innerText = pitLimiter ? 'ON' : 'OFF'; + lblPit.style.color = pitLimiter ? '#00ff66' : '#ef4444'; + } + + const ctrlJson = document.getElementById('ctrlJson'); + if (ctrlJson) { + const steerByte = Math.round((currentSteer + 1.0) * 100); + const throttleByte = Math.round(currentThrottle * 100); + const brakeByte = Math.round(currentBrake * 100); + const gearByte = currentGear === -1 ? 0 : (currentGear === 1 ? 2 : 1); + + ctrlJson.innerText = JSON.stringify({ + sent_json: { + steering: currentSteer, + throttle: currentThrottle, + brake: currentBrake, + gear: currentGear, + buttons: buttons + }, + binary_packet_bytes: { + byte0_steer: `0x${steerByte.toString(16).toUpperCase().padStart(2, '0')} (${steerByte})`, + byte1_throttle: `0x${throttleByte.toString(16).toUpperCase().padStart(2, '0')} (${throttleByte}%)`, + byte2_brake: `0x${brakeByte.toString(16).toUpperCase().padStart(2, '0')} (${brakeByte}%)`, + byte3_gear: `0x${gearByte.toString(16).toUpperCase().padStart(2, '0')} (${gearByte === 0 ? 'R' : (gearByte === 2 ? 'D' : 'N')})`, + byte4_flags: `0x${buttons.toString(16).toUpperCase().padStart(2, '0')} (DRS=${drs ? 1 : 0}, Pit=${pitLimiter ? 1 : 0})` + } + }, null, 2); + } + // Log control changes to UI console - log(`Controls updated: throttle=${currentThrottle}, steering=${currentSteer}, brake=${currentBrake}`, 'out'); + log(`Controls updated: throttle=${currentThrottle}, steering=${currentSteer}, brake=${currentBrake}, gear=${currentGear}, buttons=${buttons}`, 'out'); } + // Click listeners for the custom controls + document.getElementById('btnGearDown').addEventListener('click', () => { + currentGear = Math.max(-1, currentGear - 1); + updateKeyVisuals(); + }); + document.getElementById('btnGearUp').addEventListener('click', () => { + currentGear = Math.min(1, currentGear + 1); + updateKeyVisuals(); + }); + document.getElementById('btnDrs').addEventListener('click', () => { + drs = !drs; + updateKeyVisuals(); + }); + document.getElementById('btnPit').addEventListener('click', () => { + pitLimiter = !pitLimiter; + updateKeyVisuals(); + }); + connectBtn.addEventListener('click', () => { if (pc) { disconnectCockpit(); @@ -742,7 +857,8 @@ throttle: currentThrottle, steering: currentSteer, brake: currentBrake, - gear: 1, + gear: currentGear, + buttons: buttons, timestamp: Date.now() }; dataChannel.send(JSON.stringify(payload));