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

- Implement UDPVideoService to stream MJPEG video frames from ESP32s via UDP and send control commands.
- Normalize race database schema:
  - Remove redundant driver_ids array from races table (migration 011).
  - Move per-driver outcomes (total_time_ms, best_lap_ms, position) to race_drivers table (migration 012).
  - Add device_id column to lobby_drivers to link active physical devices (migration 013).
- Update WebSocket handler to pass lobbySvc, driversSvc, and videoSvc for driver identity tracking, syncing in-memory driver metadata, and forwarding WS controls to physical cars.
- Add CORS middleware to HTTP routes.
- Regenerate Swagger documentation.
This commit is contained in:
x0gp
2026-07-06 12:38:34 +04:00
parent 978d36c505
commit 49351b4928
19 changed files with 1406 additions and 132 deletions
+48 -1
View File
@@ -94,7 +94,9 @@ func (s *Service) CreateRace(opts CreateRaceOptions) (RaceMeta, error) {
hook(meta)
}
s.bump("race_created", id, "")
s.mirror(func(ctx context.Context, p Persistence) error { return p.UpsertRace(ctx, meta) })
// Synchronous mirror so the race row is in Postgres before any
// subsequent AddDriver mirrors try to attach drivers (FK).
s.mirrorSync(func(ctx context.Context, p Persistence) error { return p.UpsertRace(ctx, meta) })
return meta, nil
}
@@ -185,6 +187,7 @@ func (s *Service) RemoveDriver(id string) {
d.Status = DriverStatusOffline
d.LastSeenMs = time.Now().UnixMilli()
d.RaceID = ""
d.DeviceID = nil
hook := s.onRaceRemoved
s.mu.Unlock()
@@ -241,6 +244,10 @@ func (s *Service) AddDriverToRace(driverID, raceID string) error {
s.mu.Unlock()
return ErrDriverNotFound
}
if d.DeviceID == nil {
s.mu.Unlock()
return fmt.Errorf("driver %s has no device selected", driverID)
}
r, ok := s.races[raceID]
if !ok {
s.mu.Unlock()
@@ -299,6 +306,7 @@ func (s *Service) RemoveDriverFromRace(driverID string) {
}
d.Status = DriverStatusIdle
d.RaceID = ""
d.DeviceID = nil
prevRaceID := d.RaceID
hook := s.onRaceRemoved
s.mu.Unlock()
@@ -341,6 +349,7 @@ func (s *Service) SetRaceStatus(raceID string, status RaceStatus) error {
if d, ok := s.drivers[did]; ok {
d.Status = DriverStatusIdle
d.RaceID = ""
d.DeviceID = nil
affected = append(affected, *d)
}
}
@@ -445,6 +454,44 @@ func (s *Service) GetDriver(driverID string) (DriverMeta, error) {
return *d, nil
}
// SelectDevice assigns a physical device ID (0..127) to a driver.
// Pass deviceID = nil to release the device.
// Returns an error if the device is already taken by another online driver.
func (s *Service) SelectDevice(driverID string, deviceID *int) error {
if deviceID != nil {
if *deviceID < 0 || *deviceID > 127 {
return fmt.Errorf("invalid device ID: must be 0..127")
}
}
s.mu.Lock()
d, ok := s.drivers[driverID]
if !ok {
s.mu.Unlock()
return ErrDriverNotFound
}
// Enforce that device cannot be selected if already assigned to another driver.
if deviceID != nil {
for otherID, otherD := range s.drivers {
if otherID != driverID && otherD.Status != DriverStatusOffline && otherD.DeviceID != nil && *otherD.DeviceID == *deviceID {
s.mu.Unlock()
return fmt.Errorf("device %d is already taken by driver %s", *deviceID, otherID)
}
}
}
d.DeviceID = deviceID
out := *d
s.mu.Unlock()
s.bump("driver_updated", "", driverID)
s.mirror(func(ctx context.Context, p Persistence) error {
return p.UpsertDriver(ctx, out)
})
return nil
}
// ListRaces returns a snapshot copy of all races.
func (s *Service) ListRaces() []RaceMeta {
s.mu.RLock()