mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
feat: implement WebRTC signaling and UDP-based video streaming and control service
This commit is contained in:
@@ -1,6 +1,16 @@
|
|||||||
#include "esp_camera.h"
|
#include "esp_camera.h"
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
#include <WiFiUdp.h>
|
#include <WiFiUdp.h>
|
||||||
|
#include <ESP32Servo.h>
|
||||||
|
|
||||||
|
// === НАСТРОЙКА РЕЖИМА ЗАГЛУШКИ ===
|
||||||
|
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)
|
// Конфигурация пинов (пример для Seeed Studio XIAO ESP32S3 Sense)
|
||||||
#define PWDN_GPIO_NUM -1
|
#define PWDN_GPIO_NUM -1
|
||||||
@@ -10,7 +20,7 @@
|
|||||||
#define SIOC_GPIO_NUM 39
|
#define SIOC_GPIO_NUM 39
|
||||||
#define Y9_GPIO_NUM 48
|
#define Y9_GPIO_NUM 48
|
||||||
#define Y8_GPIO_NUM 47
|
#define Y8_GPIO_NUM 47
|
||||||
#define Y7_GPIO_NUM 13
|
#define Y7_GPIO_NUM 18 // ИЗМЕНЕНО: Отключаем этот пин для камеры, отдаем под Серво
|
||||||
#define Y6_GPIO_NUM 14
|
#define Y6_GPIO_NUM 14
|
||||||
#define Y5_GPIO_NUM 12
|
#define Y5_GPIO_NUM 12
|
||||||
#define Y4_GPIO_NUM 11
|
#define Y4_GPIO_NUM 11
|
||||||
@@ -20,24 +30,45 @@
|
|||||||
#define HREF_GPIO_NUM 38
|
#define HREF_GPIO_NUM 38
|
||||||
#define PCLK_GPIO_NUM 37
|
#define PCLK_GPIO_NUM 37
|
||||||
|
|
||||||
|
|
||||||
const char* ssid = "RT-WiFi-1712";
|
const char* ssid = "RT-WiFi-1712";
|
||||||
const char* password = "yt3xihY2Ci";
|
const char* password = "yt3xihY2Ci";
|
||||||
|
|
||||||
const char* serverIP = "192.168.0.11";
|
const char* serverIP = "192.168.0.10";
|
||||||
const int serverPort = 9999;
|
const int serverPort = 9999;
|
||||||
const int localPort = 8888;
|
const int localPort = 8888;
|
||||||
|
|
||||||
// !!! УНИКАЛЬНЫЙ ID ДЛЯ КАЖДОЙ ПЛАТЫ (например, 1 для первой, 2 для второй и т.д.)
|
|
||||||
const uint8_t DEVICE_ID = 1;
|
|
||||||
|
|
||||||
WiFiUDP udpVideo;
|
WiFiUDP udpVideo;
|
||||||
WiFiUDP udpCmd;
|
WiFiUDP udpCmd;
|
||||||
camera_config_t config;
|
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() {
|
void setup() {
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
// Настройка камеры (QVGA для стабильности)
|
|
||||||
|
// Инициализируем сервопривод в первую очередь
|
||||||
|
myServo.attach(servoPin, 500, 2500);
|
||||||
|
Serial.println("Сервопривод инициализирован на пине 13");
|
||||||
|
|
||||||
|
// Настройка конфигурации камеры
|
||||||
config.ledc_channel = LEDC_CHANNEL_0;
|
config.ledc_channel = LEDC_CHANNEL_0;
|
||||||
config.ledc_timer = LEDC_TIMER_0;
|
config.ledc_timer = LEDC_TIMER_0;
|
||||||
config.pin_d0 = Y2_GPIO_NUM;
|
config.pin_d0 = Y2_GPIO_NUM;
|
||||||
@@ -45,7 +76,7 @@ void setup() {
|
|||||||
config.pin_d2 = Y4_GPIO_NUM;
|
config.pin_d2 = Y4_GPIO_NUM;
|
||||||
config.pin_d3 = Y5_GPIO_NUM;
|
config.pin_d3 = Y5_GPIO_NUM;
|
||||||
config.pin_d4 = Y6_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_d6 = Y8_GPIO_NUM;
|
||||||
config.pin_d7 = Y9_GPIO_NUM;
|
config.pin_d7 = Y9_GPIO_NUM;
|
||||||
config.pin_xclk = XCLK_GPIO_NUM;
|
config.pin_xclk = XCLK_GPIO_NUM;
|
||||||
@@ -62,7 +93,15 @@ void setup() {
|
|||||||
config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
|
config.grab_mode = CAMERA_GRAB_WHEN_EMPTY;
|
||||||
config.fb_count = 2;
|
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
|
// Подключение к Wi-Fi
|
||||||
WiFi.begin(ssid, password);
|
WiFi.begin(ssid, password);
|
||||||
@@ -79,43 +118,58 @@ void setup() {
|
|||||||
|
|
||||||
void checkIncomingCommands() {
|
void checkIncomingCommands() {
|
||||||
int packetSize = udpCmd.parsePacket();
|
int packetSize = udpCmd.parsePacket();
|
||||||
if (packetSize > 0) {
|
if (packetSize == 5) {
|
||||||
char packetBuffer[255];
|
uint8_t packetBuffer[5];
|
||||||
int len = udpCmd.read(packetBuffer, 255);
|
int len = udpCmd.read(packetBuffer, 5);
|
||||||
if (len > 0) { packetBuffer[len] = 0; }
|
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];
|
||||||
|
|
||||||
String command = String(packetBuffer);
|
// ИСПРАВЛЕНО: Инвертирован диапазон (180, 0 вместо 0, 180) для исправления направления
|
||||||
command.trim();
|
int servoAngle = map(steer, 0, 200, 180, 0);
|
||||||
|
myServo.write(servoAngle);
|
||||||
|
|
||||||
Serial.print("Получена команда: ");
|
Serial.printf("Got control packet: Steer=%d (Angle=%d), Throttle=%d%%, Brake=%d%%, Gear=%d, Flags=0x%02X\n",
|
||||||
Serial.println(command);
|
steer, servoAngle, throttle, brake, gear, flags);
|
||||||
|
|
||||||
Serial.print("Получена команда: ");
|
|
||||||
Serial.println(command);
|
|
||||||
|
|
||||||
// Логика управления
|
|
||||||
if (command == "left") {
|
|
||||||
// Ваш код поворота налево
|
|
||||||
} else if (command == "right") {
|
|
||||||
// Ваш код поворота направо
|
|
||||||
} else if (command == "start") {
|
|
||||||
// Ваш код запуска моторов / логики
|
|
||||||
} else if (command == "stop") {
|
|
||||||
// Ваш код остановки
|
|
||||||
}
|
}
|
||||||
|
} else if (packetSize > 0) {
|
||||||
|
char garbage[255];
|
||||||
|
udpCmd.read(garbage, sizeof(garbage));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
checkIncomingCommands();
|
checkIncomingCommands();
|
||||||
|
|
||||||
camera_fb_t * fb = esp_camera_fb_get();
|
uint8_t * buf = nullptr;
|
||||||
if (!fb) return;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Размер куска данных уменьшаем до 1023, чтобы вместе с 1 байтом ID пакет был 1024 байта
|
// Если кадра с камеры нет
|
||||||
|
if (!buf) {
|
||||||
|
// Если отправка заглушки отключена — выходим из цикла, продолжая слушать команды
|
||||||
|
if (!USE_MOCK_IF_NO_CAMERA) {
|
||||||
|
delay(10);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Если включена — подставляем моковый черный кадр
|
||||||
|
bufferSize = mockSize;
|
||||||
|
buf = (uint8_t *)mockBlackJpeg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нарезка и отправка по UDP (оригинальная логика)
|
||||||
size_t chunkSize = 1023;
|
size_t chunkSize = 1023;
|
||||||
uint8_t packetBuffer[1024];
|
uint8_t packetBuffer[1024];
|
||||||
|
|
||||||
@@ -125,15 +179,18 @@ void loop() {
|
|||||||
sendSize = bufferSize - i;
|
sendSize = bufferSize - i;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Формируем пакет: первый байт — ID, дальше — видеоданные
|
|
||||||
packetBuffer[0] = DEVICE_ID;
|
packetBuffer[0] = DEVICE_ID;
|
||||||
memcpy(packetBuffer + 1, buf + i, sendSize);
|
memcpy(packetBuffer + 1, buf + i, sendSize);
|
||||||
|
|
||||||
udpVideo.beginPacket(serverIP, serverPort);
|
udpVideo.beginPacket(serverIP, serverPort);
|
||||||
udpVideo.write(packetBuffer, sendSize + 1); // Отправляем данные вместе с ID
|
udpVideo.write(packetBuffer, sendSize + 1);
|
||||||
udpVideo.endPacket();
|
udpVideo.endPacket();
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_camera_fb_return(fb);
|
// Возвращаем буфер только если он реальный
|
||||||
|
if (fb) {
|
||||||
|
esp_camera_fb_return(fb);
|
||||||
|
}
|
||||||
|
|
||||||
delay(10);
|
delay(10);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ X0GP_LOG_LEVEL=info
|
|||||||
X0GP_TICK_RATE=60
|
X0GP_TICK_RATE=60
|
||||||
X0GP_SNAPSHOT_RATE=30
|
X0GP_SNAPSHOT_RATE=30
|
||||||
X0GP_MAX_CLIENTS=100
|
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.
|
# PoC: skip auth, allow multiple clients on the same track.
|
||||||
X0GP_DEV_MODE=true
|
X0GP_DEV_MODE=true
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -685,6 +687,9 @@ func handleInput(c *realtime.Client, e *control.Engine, lobbySvc *lobby.Service,
|
|||||||
if v, ok := payload["gear"].(float64); ok {
|
if v, ok := payload["gear"].(float64); ok {
|
||||||
in.Gear = int(v)
|
in.Gear = int(v)
|
||||||
}
|
}
|
||||||
|
if v, ok := payload["buttons"].(float64); ok {
|
||||||
|
in.Buttons = uint32(v)
|
||||||
|
}
|
||||||
car := e.GetCarByDriver(c.ID)
|
car := e.GetCarByDriver(c.ID)
|
||||||
if car == nil {
|
if car == nil {
|
||||||
return
|
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
|
// Forward command transitions to ESP32 physical car if car has a device_id assigned
|
||||||
if car.DeviceID != nil {
|
if car.DeviceID != nil {
|
||||||
var cmd string
|
var packet [5]byte
|
||||||
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 {
|
// 1. Steer: mapping float64 [-1.0, 1.0] to [0..200] (100 is center)
|
||||||
c.LastUDPCommand = cmd
|
steerVal := int(math.Round((in.Steering + 1.0) * 100.0))
|
||||||
_ = videoSvc.SendCommand(uint8(*car.DeviceID), cmd)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
@@ -172,7 +173,6 @@ func (s *UDPVideoService) Start(ctx context.Context, wg *sync.WaitGroup) {
|
|||||||
}
|
}
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
if !hasDevice {
|
if !hasDevice {
|
||||||
s.logger.Warn("cannot send command: no ESP32 devices discovered yet")
|
s.logger.Warn("cannot send command: no ESP32 devices discovered yet")
|
||||||
continue
|
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.
|
// SendControlPacket sends a 5-byte binary control packet to the ESP32 representing deviceID.
|
||||||
func (s *UDPVideoService) SendCommand(deviceID uint8, cmd string) error {
|
func (s *UDPVideoService) SendControlPacket(deviceID uint8, packet [5]byte) error {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
dev, ok := s.devices[deviceID]
|
dev, ok := s.devices[deviceID]
|
||||||
conn := s.conn
|
conn := s.conn
|
||||||
s.mu.RUnlock()
|
s.mu.RUnlock()
|
||||||
|
|
||||||
if !ok || dev.addr == nil {
|
if !ok || dev.addr == nil {
|
||||||
return fmt.Errorf("ESP32 device %d address unknown (no video packets received yet)", deviceID)
|
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,
|
Port: s.espCmdPort,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := conn.WriteToUDP([]byte(cmd), cmdAddr)
|
_, err := conn.WriteToUDP(packet[:], cmdAddr)
|
||||||
return err
|
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) {
|
func (s *UDPVideoService) broadcastFrame(deviceID uint8, frame []byte) {
|
||||||
// dev.latestFrame is updated inside the caller's lock (Start loop) to avoid races
|
// dev.latestFrame is updated inside the caller's lock (Start loop) to avoid races
|
||||||
if dev, ok := s.devices[deviceID]; ok {
|
if dev, ok := s.devices[deviceID]; ok {
|
||||||
@@ -328,13 +352,14 @@ func (s *UDPVideoService) StreamHandler() http.HandlerFunc {
|
|||||||
|
|
||||||
// ControlHandler godoc
|
// ControlHandler godoc
|
||||||
// @Summary Send command to ESP32
|
// @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
|
// @Tags video
|
||||||
// @Param id query int false "Device ID (0..127)"
|
// @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
|
// @Produce json
|
||||||
// @Success 200 {object} map[string]interface{} "Command sent"
|
// @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"
|
// @Failure 500 {string} string "Internal error"
|
||||||
// @Router /api/video/control [post]
|
// @Router /api/video/control [post]
|
||||||
func (s *UDPVideoService) ControlHandler() http.HandlerFunc {
|
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")
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed")
|
||||||
return
|
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")
|
idVal := r.URL.Query().Get("id")
|
||||||
if idVal == "" {
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
writeError(w, http.StatusInternalServerError, "internal_error", err.Error())
|
||||||
return
|
return
|
||||||
@@ -375,7 +494,8 @@ func (s *UDPVideoService) ControlHandler() http.HandlerFunc {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"device_id": deviceID,
|
"device_id": deviceID,
|
||||||
"command": cmd,
|
"command": cmdUsed,
|
||||||
|
"packet": packet[:],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -269,6 +271,10 @@ func (s *WebRTCService) handleDataChannelMessage(session *driverSession, data []
|
|||||||
in.Gear = int(v)
|
in.Gear = int(v)
|
||||||
hasInput = true
|
hasInput = true
|
||||||
}
|
}
|
||||||
|
if v, ok := payload["buttons"].(float64); ok {
|
||||||
|
in.Buttons = uint32(v)
|
||||||
|
hasInput = true
|
||||||
|
}
|
||||||
|
|
||||||
if hasInput {
|
if hasInput {
|
||||||
s.logger.Debug("received inputs on webrtc data channel", "driver_id", session.driverID, "throttle", in.Throttle, "steering", in.Steering)
|
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)
|
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
|
// Resolve destination device
|
||||||
var destDeviceID *uint8
|
var destDeviceID *uint8
|
||||||
if car != nil && car.DeviceID != nil {
|
if car != nil && car.DeviceID != nil {
|
||||||
@@ -300,14 +293,49 @@ func (s *WebRTCService) handleDataChannelMessage(session *driverSession, data []
|
|||||||
destDeviceID = &session.deviceID
|
destDeviceID = &session.deviceID
|
||||||
}
|
}
|
||||||
|
|
||||||
if destDeviceID != nil && cmd != "" && cmd != session.lastCommand {
|
if destDeviceID != nil {
|
||||||
s.logger.Info("webrtc control state transition", "driver_id", session.driverID, "old_cmd", session.lastCommand, "new_cmd", cmd)
|
var packet [5]byte
|
||||||
session.lastCommand = cmd
|
|
||||||
err := s.videoSvc.SendCommand(*destDeviceID, cmd)
|
// 1. Steer: mapping float64 [-1.0, 1.0] to [0..200] (100 is center)
|
||||||
if err != nil {
|
steerVal := int(math.Round((in.Steering + 1.0) * 100.0))
|
||||||
s.logger.Error("failed to forward command to ESP32", "device_id", *destDeviceID, "cmd", cmd, "err", err)
|
if steerVal < 0 { steerVal = 0 }
|
||||||
} else {
|
if steerVal > 200 { steerVal = 200 }
|
||||||
s.logger.Info("forwarded command to ESP32 via UDP", "device_id", *destDeviceID, "cmd", cmd)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-5
@@ -2003,7 +2003,7 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
"/api/video/control": {
|
"/api/video/control": {
|
||||||
"post": {
|
"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": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -2020,10 +2020,20 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Command text",
|
"description": "Command text (legacy)",
|
||||||
"name": "cmd",
|
"name": "cmd",
|
||||||
"in": "query",
|
"in": "query"
|
||||||
"required": true
|
},
|
||||||
|
{
|
||||||
|
"description": "Control packet JSON (steer, throttle, brake, gear, flags)",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -2035,7 +2045,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Missing cmd parameter",
|
"description": "Missing parameter or invalid body",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2001,7 +2001,7 @@
|
|||||||
},
|
},
|
||||||
"/api/video/control": {
|
"/api/video/control": {
|
||||||
"post": {
|
"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": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -2018,10 +2018,20 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Command text",
|
"description": "Command text (legacy)",
|
||||||
"name": "cmd",
|
"name": "cmd",
|
||||||
"in": "query",
|
"in": "query"
|
||||||
"required": true
|
},
|
||||||
|
{
|
||||||
|
"description": "Control packet JSON (steer, throttle, brake, gear, flags)",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@@ -2033,7 +2043,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Missing cmd parameter",
|
"description": "Missing parameter or invalid body",
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2219,18 +2219,24 @@ paths:
|
|||||||
- system
|
- system
|
||||||
/api/video/control:
|
/api/video/control:
|
||||||
post:
|
post:
|
||||||
description: Sends a text command (e.g. start, stop, left, right) to the ESP32
|
description: Sends a text command or a JSON control packet to the ESP32 command
|
||||||
command port (UDP 8888).
|
port (UDP 8888).
|
||||||
parameters:
|
parameters:
|
||||||
- description: Device ID (0..127)
|
- description: Device ID (0..127)
|
||||||
in: query
|
in: query
|
||||||
name: id
|
name: id
|
||||||
type: integer
|
type: integer
|
||||||
- description: Command text
|
- description: Command text (legacy)
|
||||||
in: query
|
in: query
|
||||||
name: cmd
|
name: cmd
|
||||||
required: true
|
|
||||||
type: string
|
type: string
|
||||||
|
- description: Control packet JSON (steer, throttle, brake, gear, flags)
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
schema:
|
||||||
|
additionalProperties:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -2240,7 +2246,7 @@ paths:
|
|||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
type: object
|
type: object
|
||||||
"400":
|
"400":
|
||||||
description: Missing cmd parameter
|
description: Missing parameter or invalid body
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
"500":
|
"500":
|
||||||
|
|||||||
@@ -79,6 +79,35 @@
|
|||||||
<div class="wheel">
|
<div class="wheel">
|
||||||
<div class="wheel-disc" id="wheelDisc"></div>
|
<div class="wheel-disc" id="wheelDisc"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="control-extra" style="display: flex; gap: 10px; margin-top: 15px; justify-content: space-around; border-top: 1px solid #333; padding-top: 12px;">
|
||||||
|
<div class="gear-indicator" style="text-align: center;">
|
||||||
|
<div style="font-size: 0.8rem; color: #aaa;">GEAR</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px; margin-top: 4px;">
|
||||||
|
<button id="btnGearDown" class="btn" style="padding: 2px 8px; font-size: 0.8rem;">-</button>
|
||||||
|
<b id="lblGear" style="font-size: 1.3rem; min-width: 15px; display: inline-block;">N</b>
|
||||||
|
<button id="btnGearUp" class="btn" style="padding: 2px 8px; font-size: 0.8rem;">+</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.7rem; color: #888; margin-top: 2px;">(Z / X)</div>
|
||||||
|
</div>
|
||||||
|
<div class="sys-toggle" style="text-align: center;">
|
||||||
|
<div style="font-size: 0.8rem; color: #aaa;">DRS</div>
|
||||||
|
<button id="btnDrs" class="btn" style="padding: 2px 6px; font-size: 0.8rem; margin-top: 4px;">Toggle</button>
|
||||||
|
<div id="lblDrs" class="val" style="font-weight: bold; font-size: 0.95rem; margin-top: 4px; color: #ff3b30;">OFF</div>
|
||||||
|
<div style="font-size: 0.7rem; color: #888; margin-top: 2px;">(Q)</div>
|
||||||
|
</div>
|
||||||
|
<div class="sys-toggle" style="text-align: center;">
|
||||||
|
<div style="font-size: 0.8rem; color: #aaa;">PIT LIMIT</div>
|
||||||
|
<button id="btnPit" class="btn" style="padding: 2px 6px; font-size: 0.8rem; margin-top: 4px;">Toggle</button>
|
||||||
|
<div id="lblPit" class="val" style="font-weight: bold; font-size: 0.95rem; margin-top: 4px; color: #ff3b30;">OFF</div>
|
||||||
|
<div style="font-size: 0.7rem; color: #888; margin-top: 2px;">(E)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 15px; border-top: 1px solid #333; padding-top: 12px;">
|
||||||
|
<span style="font-size: 0.8rem; color: #aaa;">Пакет управления (JSON):</span>
|
||||||
|
<pre id="ctrlJson" style="font-family: monospace; font-size: 0.75rem; background: #111; border: 1px solid #222; padding: 8px; border-radius: 4px; color: #00ff66; margin: 6px 0 0 0; overflow-x: auto; max-height: 250px; text-align: left; line-height: 1.2;">{}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
+100
-1
@@ -35,6 +35,14 @@
|
|||||||
tPos: $('tPos'),
|
tPos: $('tPos'),
|
||||||
tTsm: $('tTsm'),
|
tTsm: $('tTsm'),
|
||||||
tCars: $('tCars'),
|
tCars: $('tCars'),
|
||||||
|
lblGear: $('lblGear'),
|
||||||
|
lblDrs: $('lblDrs'),
|
||||||
|
lblPit: $('lblPit'),
|
||||||
|
btnDrs: $('btnDrs'),
|
||||||
|
btnPit: $('btnPit'),
|
||||||
|
btnGearUp: $('btnGearUp'),
|
||||||
|
btnGearDown: $('btnGearDown'),
|
||||||
|
ctrlJson: $('ctrlJson'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// -------- State --------
|
// -------- State --------
|
||||||
@@ -53,7 +61,7 @@
|
|||||||
snapshotCount: 0,
|
snapshotCount: 0,
|
||||||
snapshotWindow: [],
|
snapshotWindow: [],
|
||||||
keys: new Set(),
|
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,
|
pingTimer: null,
|
||||||
inputTimer: null,
|
inputTimer: null,
|
||||||
slotHint: 0, // сервер выдаёт слот; 0 по умолчанию
|
slotHint: 0, // сервер выдаёт слот; 0 по умолчанию
|
||||||
@@ -113,6 +121,12 @@
|
|||||||
state.input.steer = steer;
|
state.input.steer = steer;
|
||||||
state.input.handbrake = handbrake;
|
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
|
// Update UI bars
|
||||||
ui.lblThrottle.textContent = throttle.toFixed(2);
|
ui.lblThrottle.textContent = throttle.toFixed(2);
|
||||||
ui.lblBrake.textContent = brake.toFixed(2);
|
ui.lblBrake.textContent = brake.toFixed(2);
|
||||||
@@ -129,6 +143,48 @@
|
|||||||
ui.barSteer.style.width = '2%';
|
ui.barSteer.style.width = '2%';
|
||||||
|
|
||||||
ui.wheelDisc.style.transform = `rotate(${steer * 90}deg)`;
|
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 --------
|
// -------- Send helpers --------
|
||||||
@@ -450,6 +506,24 @@
|
|||||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||||
if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault();
|
if (['arrowup','arrowdown','arrowleft','arrowright',' '].includes(k)) e.preventDefault();
|
||||||
state.keys.add(k);
|
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) => {
|
window.addEventListener('keyup', (e) => {
|
||||||
state.keys.delete(keyName(e));
|
state.keys.delete(keyName(e));
|
||||||
@@ -462,6 +536,31 @@
|
|||||||
ui.btnJoin.addEventListener('click', joinRace);
|
ui.btnJoin.addEventListener('click', joinRace);
|
||||||
ui.btnLeave.addEventListener('click', leaveRace);
|
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 --------
|
// -------- Boot --------
|
||||||
setConn('disconnected', '');
|
setConn('disconnected', '');
|
||||||
log('x0gp PoC client ready. Press Connect.', 'meta');
|
log('x0gp PoC client ready. Press Connect.', 'meta');
|
||||||
|
|||||||
+118
-2
@@ -408,6 +408,35 @@
|
|||||||
<div id="keyS" class="key-btn">S</div>
|
<div id="keyS" class="key-btn">S</div>
|
||||||
<div id="keyD" class="key-btn">D</div>
|
<div id="keyD" class="key-btn">D</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="control-extra" style="display: flex; gap: 10px; margin-top: 15px; justify-content: space-around; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; width: 100%;">
|
||||||
|
<div class="gear-indicator" style="text-align: center;">
|
||||||
|
<div style="font-size: 0.8rem; color: #8a8d9a;">GEAR</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 5px; margin-top: 4px;">
|
||||||
|
<button id="btnGearDown" class="btn" style="padding: 2px 8px; font-size: 0.8rem; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">-</button>
|
||||||
|
<b id="lblGear" style="font-size: 1.3rem; min-width: 15px; display: inline-block;">N</b>
|
||||||
|
<button id="btnGearUp" class="btn" style="padding: 2px 8px; font-size: 0.8rem; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">+</button>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.7rem; color: #6b7280; margin-top: 2px;">(Z / X)</div>
|
||||||
|
</div>
|
||||||
|
<div class="sys-toggle" style="text-align: center;">
|
||||||
|
<div style="font-size: 0.8rem; color: #8a8d9a;">DRS</div>
|
||||||
|
<button id="btnDrs" class="btn" style="padding: 2px 6px; font-size: 0.8rem; margin-top: 4px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">Toggle</button>
|
||||||
|
<div id="lblDrs" class="val" style="font-weight: bold; font-size: 0.95rem; margin-top: 4px; color: #ef4444;">OFF</div>
|
||||||
|
<div style="font-size: 0.7rem; color: #6b7280; margin-top: 2px;">(Q)</div>
|
||||||
|
</div>
|
||||||
|
<div class="sys-toggle" style="text-align: center;">
|
||||||
|
<div style="font-size: 0.8rem; color: #8a8d9a;">PIT LIMIT</div>
|
||||||
|
<button id="btnPit" class="btn" style="padding: 2px 6px; font-size: 0.8rem; margin-top: 4px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; cursor: pointer;">Toggle</button>
|
||||||
|
<div id="lblPit" class="val" style="font-weight: bold; font-size: 0.95rem; margin-top: 4px; color: #ef4444;">OFF</div>
|
||||||
|
<div style="font-size: 0.7rem; color: #6b7280; margin-top: 2px;">(E)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 15px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; width: 100%;">
|
||||||
|
<span style="font-size: 0.8rem; color: #8a8d9a;">Пакет управления (JSON):</span>
|
||||||
|
<pre id="ctrlJson" style="font-family: monospace; font-size: 0.75rem; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.05); padding: 8px; border-radius: 4px; color: #00ff66; margin: 6px 0 0 0; overflow-x: auto; max-height: 250px; text-align: left; line-height: 1.2;">{}</pre>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -489,6 +518,10 @@
|
|||||||
let currentSteer = 0; // -1 to 1
|
let currentSteer = 0; // -1 to 1
|
||||||
let currentThrottle = 0; // 0 to 1
|
let currentThrottle = 0; // 0 to 1
|
||||||
let currentBrake = 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 pingsReceived = 0;
|
||||||
let latencyStart = 0;
|
let latencyStart = 0;
|
||||||
|
|
||||||
@@ -513,6 +546,22 @@
|
|||||||
activeKeys[key] = true;
|
activeKeys[key] = true;
|
||||||
updateKeyVisuals();
|
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) => {
|
window.addEventListener('keyup', (e) => {
|
||||||
@@ -542,10 +591,76 @@
|
|||||||
if (activeKeys.a) currentSteer = -1.0;
|
if (activeKeys.a) currentSteer = -1.0;
|
||||||
if (activeKeys.d) 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 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', () => {
|
connectBtn.addEventListener('click', () => {
|
||||||
if (pc) {
|
if (pc) {
|
||||||
disconnectCockpit();
|
disconnectCockpit();
|
||||||
@@ -742,7 +857,8 @@
|
|||||||
throttle: currentThrottle,
|
throttle: currentThrottle,
|
||||||
steering: currentSteer,
|
steering: currentSteer,
|
||||||
brake: currentBrake,
|
brake: currentBrake,
|
||||||
gear: 1,
|
gear: currentGear,
|
||||||
|
buttons: buttons,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
dataChannel.send(JSON.stringify(payload));
|
dataChannel.send(JSON.stringify(payload));
|
||||||
|
|||||||
Reference in New Issue
Block a user